Skip to content

Commit 9f7d044

Browse files
authored
feat: add rule enforces Self types on constructors (#20)
1 parent 82968ec commit 9f7d044

File tree

8 files changed

+291
-199
lines changed

8 files changed

+291
-199
lines changed

README.md

+4
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Custom flake8 rules
2121
* TUT300 - no expressions in the main body, unless under __name__ == "__main__", prevents global side effects
2222
* TUT4:
2323
* TUT400 - detect strings that were likely meant to be f-strings
24+
* TODO: we should exclude regex-es
2425
* TUT410 - detect redundant type annotations
2526
* TUT411 - detect redundant type annotations for generic assignment
2627
* Don't do `x: Foo[bar] = Foo()` ; do `x = Foo[bar]()`
@@ -45,6 +46,9 @@ Custom flake8 rules
4546
* All the same exceptions as 511
4647
* TUT520 - `NotImplemented` is only allowed within a dunder method on a class
4748
* Any other usage is very likely incorrect
49+
* TUT530 - a constructor must return `Self` type rather than the class type
50+
* This encourages good use of constructors that play well with inheritence
51+
* We detect methods whose return type includes the class type without having any input of the type
4852
* TUT6
4953
* TUT610 - a function definition allows too many positional arguments (configurable with `max-definition-positional-args`)
5054
* TUT620 - a function invocation uses too many positional arguments (configurable with `max-invocation-positional-args`)

poetry.lock

+173-192
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ python = "^3.10"
1212
flake8 = "^6.0.0"
1313
isort = "^5.10.1"
1414
black = "^22.3.0"
15-
mypy = "^0.961"
15+
mypy = "^1.11.0"
1616
astpretty = "^3.0.0"
1717
pre-commit = "^2.19.0"
1818

tutor_flake/common.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import ast
22
from typing import List, NamedTuple, Optional, Type, Union
33

4+
from typing_extensions import Self
5+
46

57
class Flake8Error(NamedTuple):
68
line_number: int
@@ -15,9 +17,12 @@ def construct( # noqa: TUT610
1517
code: int,
1618
description: str,
1719
rule_cls: type,
18-
) -> "Flake8Error":
19-
return Flake8Error(
20-
node.lineno, node.col_offset, f"TUT{code} {description}", rule_cls
20+
) -> Self:
21+
return cls(
22+
node.lineno, # type: ignore
23+
node.col_offset, # type: ignore
24+
f"TUT{code} {description}",
25+
rule_cls,
2126
)
2227

2328

tutor_flake/example_code/test_classvar_rules.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from enum import Enum
44
from typing import ClassVar, Final, List, NamedTuple, Protocol, TypeVar
55

6+
from typing_extensions import Self
7+
68

79
class DummyClass:
810

@@ -63,7 +65,7 @@ def __str__(self) -> str:
6365
return super().__str__()
6466

6567
@classmethod
66-
def construct(cls) -> "ExampleDataclass":
68+
def construct(cls) -> Self:
6769
raise NotImplementedError
6870

6971
# other inline comment
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from contextlib import asynccontextmanager
2+
from typing import AsyncGenerator, List, Tuple
3+
4+
from typing_extensions import Self
5+
6+
7+
class FooBar:
8+
@classmethod
9+
def bad_cls_construct(cls) -> "FooBar": # noqa: TUT530
10+
raise NotImplementedError
11+
12+
@staticmethod
13+
def bad_static_struct() -> "FooBar": # noqa: TUT530
14+
raise NotImplementedError
15+
16+
def bad_self_method(self) -> "FooBar": # noqa: TUT530
17+
raise NotImplementedError
18+
19+
def bad_complicated_return(self) -> Tuple[None, "FooBar"]: # noqa: TUT530
20+
raise NotImplementedError
21+
22+
@asynccontextmanager
23+
async def bad_complicated_return_2( # noqa: TUT530 TUT210
24+
self,
25+
) -> AsyncGenerator["FooBar", None]:
26+
yield self
27+
28+
async def return_allowed_because_of_args(
29+
self, arg_1: List["FooBar"]
30+
) -> List["FooBar"]:
31+
raise NotImplementedError
32+
33+
def return_allowed_because_of_positional_only_arg(
34+
self,
35+
arg_1: "FooBar",
36+
) -> "FooBar":
37+
raise NotImplementedError
38+
39+
def return_allowed_because_of_keyword_only_arg(
40+
self, *, arg_1: "FooBar"
41+
) -> "FooBar":
42+
raise NotImplementedError
43+
44+
@classmethod
45+
def good_cls_construct(cls) -> Self:
46+
raise NotImplementedError

tutor_flake/plugin.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import Any, Callable, ClassVar, Generator, Iterable, List, Optional, TypeVar
99

1010
from flake8.options.manager import OptionManager
11+
from typing_extensions import Self
1112

1213
from tutor_flake import __version__
1314
from tutor_flake.common import Flake8Error
@@ -20,6 +21,7 @@
2021
)
2122
from tutor_flake.rules.classvar import ClassvarCheck
2223
from tutor_flake.rules.compact_generic import CompactGeneric
24+
from tutor_flake.rules.constructor import ConstructorIsWellTyped
2325
from tutor_flake.rules.dataclass import DataclassRenamed
2426
from tutor_flake.rules.no_sideeffects import NoSideeffects
2527
from tutor_flake.rules.not_implemented import NotImplementedCheck
@@ -75,8 +77,8 @@ def add_options(option_manager: OptionManager) -> None:
7577
)
7678

7779
@classmethod
78-
def parse_options(cls, options: Namespace) -> "TutorFlakeConfig":
79-
return TutorFlakeConfig(
80+
def parse_options(cls, options: Namespace) -> Self:
81+
return cls(
8082
options.max_definition_positional_args,
8183
options.max_invocation_positional_args,
8284
options.non_init_classes,
@@ -187,6 +189,7 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> Iterable[Flake8Error]:
187189
ChildClassCallsSuperMethods.check(
188190
node, self.parents, self.config.non_init_classes
189191
),
192+
ConstructorIsWellTyped.check(node, self.parents),
190193
)
191194

192195
@visitor_decorator
@@ -210,6 +213,7 @@ def visit_AsyncFunctionDef(
210213
),
211214
ConsecutiveSameTypedPositionalArgs.check(node),
212215
AsyncFunctionsAreAsynchronous.check(node),
216+
ConstructorIsWellTyped.check(node, self.parents),
213217
)
214218

215219
@visitor_decorator

tutor_flake/rules/constructor.py

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import ast
2+
import itertools
3+
from ast import Constant, expr, walk
4+
from typing import Generator, List
5+
6+
from tutor_flake.common import Flake8Error
7+
8+
9+
class ConstructorIsWellTyped:
10+
@classmethod
11+
def check(
12+
cls, func: ast.FunctionDef | ast.AsyncFunctionDef, parents: List[ast.AST]
13+
) -> Generator[Flake8Error, None, None]:
14+
class_definition = next(
15+
(
16+
parent
17+
for parent in reversed(parents)
18+
if isinstance(parent, ast.ClassDef)
19+
),
20+
None,
21+
)
22+
if class_definition is None:
23+
return
24+
class_name = class_definition.name
25+
for func_arg in itertools.chain(
26+
func.args.posonlyargs, func.args.args, func.args.kwonlyargs
27+
):
28+
if (
29+
annotation := func_arg.annotation
30+
) is not None and does_type_annotation_include_type(annotation, class_name):
31+
# we allow the return type if an argument includes the class name
32+
return
33+
if (
34+
func_return := func.returns
35+
) is not None and does_type_annotation_include_type(func_return, class_name):
36+
yield Flake8Error.construct(
37+
func,
38+
530,
39+
f"Funciton `{func.name}`'s return value includes the class type: `{class_name}`."
40+
" Likely you want to return the Self type imported from `typing` or `typing_extensions`"
41+
" as child types likely should return their own type.",
42+
cls,
43+
)
44+
45+
46+
def does_type_annotation_include_type(annotation: expr, check_type: str) -> bool:
47+
for child_node in walk(annotation):
48+
if isinstance(child_node, Constant) and child_node.value == check_type:
49+
return True
50+
return False

0 commit comments

Comments
 (0)