community-of-python-flake8-plugin 0.5.2__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/PKG-INFO +29 -5
  2. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/README.md +26 -3
  3. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/pyproject.toml +9 -2
  4. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/checks/async_get_prefix.py +4 -2
  5. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/checks/dataclass_config.py +4 -2
  6. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/checks/final_class.py +4 -3
  7. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/checks/for_loop_one_prefix.py +4 -3
  8. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/checks/function_keyword_only_args.py +4 -3
  9. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/checks/function_verb.py +4 -3
  10. community_of_python_flake8_plugin-0.6.0/src/community_of_python_flake8_plugin/checks/immutable_variable.py +130 -0
  11. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/checks/mapping_proxy.py +26 -37
  12. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/checks/module_import_stdlib.py +4 -2
  13. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/checks/name_length.py +4 -3
  14. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/checks/scalar_annotation.py +4 -3
  15. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/checks/temp_var.py +4 -2
  16. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/plugin.py +2 -2
  17. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/violation_codes.py +9 -0
  18. community_of_python_flake8_plugin-0.6.0/src/cop_extensions/__init__.py +19 -0
  19. community_of_python_flake8_plugin-0.6.0/src/cop_extensions/py.typed +0 -0
  20. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/__init__.py +0 -0
  21. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/checks/__init__.py +0 -0
  22. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/checks/disabled/__init__.py +0 -0
  23. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/checks/disabled/module_import_many_names.py +0 -0
  24. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/constants.py +0 -0
  25. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/py.typed +0 -0
  26. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/utils.py +0 -0
  27. {community_of_python_flake8_plugin-0.5.2 → community_of_python_flake8_plugin-0.6.0}/src/community_of_python_flake8_plugin/violations.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: community-of-python-flake8-plugin
3
- Version: 0.5.2
3
+ Version: 0.6.0
4
4
  Summary: Community of Python flake8 plugin
5
5
  Author: Lev Vereshchagin
6
6
  Author-email: Lev Vereshchagin <mail@vrslev.com>
7
- Requires-Dist: flake8
7
+ Requires-Dist: flake8 ; extra == 'flake8'
8
8
  Requires-Python: >=3.10
9
+ Provides-Extra: flake8
9
10
  Description-Content-Type: text/markdown
10
11
 
11
12
  # Community of Python Flake8 Plugin
@@ -32,19 +33,42 @@ This plugin implements the following code style checks:
32
33
  - **COP014**: Use dataclasses with `kw_only=True`, `slots=True`, `frozen=True`
33
34
  - **COP015**: For-loop variables must be prefixed with `one_`
34
35
  - **COP016**: Add `*` or `/` when defining more than two regular arguments
36
+ - **COP017**: Avoid reassigning variables not annotated with `Mutable`
37
+
38
+ ### COP017: immutable variables by default
39
+
40
+ Variables are treated as immutable: reassigning a name with `=` in the same scope is a violation. When reassignment is intended, allow it explicitly with the `Mutable` annotation on the first binding:
41
+
42
+ ```python
43
+ from cop_extensions import Mutable
44
+
45
+ counter_value: Mutable[int] = 0
46
+ counter_value = compute_next(counter_value) # OK
47
+
48
+ total_value = 0
49
+ total_value = compute_next(total_value) # COP017
50
+ ```
51
+
52
+ The `cop_extensions` package ships with the plugin distribution and has no runtime dependencies, so importing `Mutable` in production code does not pull in flake8. `Mutable` is matched by name, so any import source works (e.g. an in-house `typing_extensions.Mutable`). Augmented assignments (`+=`), for-loop variables, attribute and subscript targets, and names declared `global`/`nonlocal` are not checked.
35
53
 
36
54
  ## Installation
37
55
 
38
- Install the plugin using `uv` (recommended):
56
+ Install the plugin using `uv` (recommended). For linting, use the `flake8` extra:
39
57
 
40
58
  ```bash
41
- uv add --dev community-of-python-flake8-plugin
59
+ uv add --dev "community-of-python-flake8-plugin[flake8]"
42
60
  ```
43
61
 
44
62
  Or install via pip:
45
63
 
46
64
  ```bash
47
- pip install community-of-python-flake8-plugin
65
+ pip install "community-of-python-flake8-plugin[flake8]"
66
+ ```
67
+
68
+ To use the `cop_extensions.Mutable` marker in production code, add the package without the extra — it has no runtime dependencies:
69
+
70
+ ```bash
71
+ uv add community-of-python-flake8-plugin
48
72
  ```
49
73
 
50
74
  ## Usage
@@ -22,19 +22,42 @@ This plugin implements the following code style checks:
22
22
  - **COP014**: Use dataclasses with `kw_only=True`, `slots=True`, `frozen=True`
23
23
  - **COP015**: For-loop variables must be prefixed with `one_`
24
24
  - **COP016**: Add `*` or `/` when defining more than two regular arguments
25
+ - **COP017**: Avoid reassigning variables not annotated with `Mutable`
26
+
27
+ ### COP017: immutable variables by default
28
+
29
+ Variables are treated as immutable: reassigning a name with `=` in the same scope is a violation. When reassignment is intended, allow it explicitly with the `Mutable` annotation on the first binding:
30
+
31
+ ```python
32
+ from cop_extensions import Mutable
33
+
34
+ counter_value: Mutable[int] = 0
35
+ counter_value = compute_next(counter_value) # OK
36
+
37
+ total_value = 0
38
+ total_value = compute_next(total_value) # COP017
39
+ ```
40
+
41
+ The `cop_extensions` package ships with the plugin distribution and has no runtime dependencies, so importing `Mutable` in production code does not pull in flake8. `Mutable` is matched by name, so any import source works (e.g. an in-house `typing_extensions.Mutable`). Augmented assignments (`+=`), for-loop variables, attribute and subscript targets, and names declared `global`/`nonlocal` are not checked.
25
42
 
26
43
  ## Installation
27
44
 
28
- Install the plugin using `uv` (recommended):
45
+ Install the plugin using `uv` (recommended). For linting, use the `flake8` extra:
29
46
 
30
47
  ```bash
31
- uv add --dev community-of-python-flake8-plugin
48
+ uv add --dev "community-of-python-flake8-plugin[flake8]"
32
49
  ```
33
50
 
34
51
  Or install via pip:
35
52
 
36
53
  ```bash
37
- pip install community-of-python-flake8-plugin
54
+ pip install "community-of-python-flake8-plugin[flake8]"
55
+ ```
56
+
57
+ To use the `cop_extensions.Mutable` marker in production code, add the package without the extra — it has no runtime dependencies:
58
+
59
+ ```bash
60
+ uv add community-of-python-flake8-plugin
38
61
  ```
39
62
 
40
63
  ## Usage
@@ -1,11 +1,14 @@
1
1
  [project]
2
2
  name = "community-of-python-flake8-plugin"
3
- version = "0.5.2"
3
+ version = "0.6.0"
4
4
  description = "Community of Python flake8 plugin"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Lev Vereshchagin", email = "mail@vrslev.com" }]
7
7
  requires-python = ">=3.10"
8
- dependencies = ["flake8"]
8
+ dependencies = []
9
+
10
+ [project.optional-dependencies]
11
+ flake8 = ["flake8"]
9
12
 
10
13
  [project.entry-points."flake8.extension"]
11
14
  COP = "community_of_python_flake8_plugin.plugin:CommunityOfPythonFlake8Plugin"
@@ -15,6 +18,7 @@ addopts = "--cov"
15
18
 
16
19
  [dependency-groups]
17
20
  dev = [
21
+ "flake8",
18
22
  "mypy",
19
23
  "ruff",
20
24
  "auto-typing-final",
@@ -27,6 +31,9 @@ dev = [
27
31
  requires = ["uv_build"]
28
32
  build-backend = "uv_build"
29
33
 
34
+ [tool.uv.build-backend]
35
+ module-name = ["community_of_python_flake8_plugin", "cop_extensions"]
36
+
30
37
  [tool.ruff]
31
38
  fix = true
32
39
  unsafe-fixes = true
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
  import ast
3
+ import dataclasses
3
4
  import typing
4
5
 
5
6
  from community_of_python_flake8_plugin.violation_codes import ViolationCodes
@@ -7,9 +8,10 @@ from community_of_python_flake8_plugin.violations import Violation
7
8
 
8
9
 
9
10
  @typing.final
11
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
10
12
  class AsyncGetPrefixCheck(ast.NodeVisitor):
11
- def __init__(self, syntax_tree: ast.AST) -> None: # noqa: ARG002
12
- self.violations: list[Violation] = []
13
+ syntax_tree: ast.AST
14
+ violations: list[Violation] = dataclasses.field(default_factory=list)
13
15
 
14
16
  def visit_AsyncFunctionDef(self, ast_node: ast.AsyncFunctionDef) -> None:
15
17
  # Always flag async functions with get_ prefix
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
  import ast
3
+ import dataclasses
3
4
  import typing
4
5
 
5
6
  from community_of_python_flake8_plugin.constants import FINAL_CLASS_EXCLUDED_BASES
@@ -66,9 +67,10 @@ def is_model_factory(class_node: ast.ClassDef) -> bool:
66
67
 
67
68
 
68
69
  @typing.final
70
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
69
71
  class DataclassConfigCheck(ast.NodeVisitor):
70
- def __init__(self, syntax_tree: ast.AST) -> None: # noqa: ARG002
71
- self.violations: list[Violation] = []
72
+ syntax_tree: ast.AST
73
+ violations: list[Violation] = dataclasses.field(default_factory=list)
72
74
 
73
75
  def visit_ClassDef(self, ast_node: ast.ClassDef) -> None:
74
76
  # Skip whitelisted classes and classes that inherit from Exception or other special classes
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
  import ast
3
+ import dataclasses
3
4
  import typing
4
5
 
5
6
  from community_of_python_flake8_plugin.utils import check_inherits_from_bases
@@ -55,10 +56,10 @@ def has_local_subclasses(syntax_tree: ast.AST, class_node: ast.ClassDef) -> bool
55
56
 
56
57
 
57
58
  @typing.final
59
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
58
60
  class FinalClassCheck(ast.NodeVisitor):
59
- def __init__(self, syntax_tree: ast.AST) -> None:
60
- self.syntax_tree = syntax_tree
61
- self.violations: list[Violation] = []
61
+ syntax_tree: ast.AST
62
+ violations: list[Violation] = dataclasses.field(default_factory=list)
62
63
 
63
64
  def visit_ClassDef(self, ast_node: ast.ClassDef) -> None:
64
65
  self._check_final_decorator(ast_node)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
  import ast
3
+ import dataclasses
3
4
  import typing
4
5
 
5
6
  from community_of_python_flake8_plugin.violation_codes import ViolationCodes
@@ -13,10 +14,10 @@ def _is_ignored_target(target_node: ast.expr) -> bool:
13
14
 
14
15
 
15
16
  @typing.final
17
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
16
18
  class COP015ForLoopOnePrefixCheck(ast.NodeVisitor):
17
- def __init__(self, syntax_tree: ast.AST) -> None:
18
- self.violations: list[Violation] = []
19
- self.syntax_tree: typing.Final[ast.AST] = syntax_tree
19
+ syntax_tree: ast.AST
20
+ violations: list[Violation] = dataclasses.field(default_factory=list)
20
21
 
21
22
  def visit_ListComp(self, ast_node: ast.ListComp) -> None:
22
23
  # Validate targets in generators (the 'v' in 'for v in lst')
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
  import ast
3
+ import dataclasses
3
4
  import typing
4
5
 
5
6
  from community_of_python_flake8_plugin.violation_codes import ViolationCodes
@@ -46,10 +47,10 @@ def check_has_positional_or_keyword_only_separator(arguments_node: ast.arguments
46
47
 
47
48
 
48
49
  @typing.final
50
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
49
51
  class COP016FunctionKeywordOnlyArgsCheck(ast.NodeVisitor):
50
- def __init__(self, syntax_tree: ast.AST) -> None:
51
- self.violations: list[Violation] = []
52
- self.syntax_tree: typing.Final[ast.AST] = syntax_tree
52
+ syntax_tree: ast.AST
53
+ violations: list[Violation] = dataclasses.field(default_factory=list)
53
54
 
54
55
  def visit_FunctionDef(self, ast_node: ast.FunctionDef) -> None:
55
56
  self.validate_function_definition(ast_node)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
  import ast
3
+ import dataclasses
3
4
  import typing
4
5
 
5
6
  from community_of_python_flake8_plugin.constants import FINAL_CLASS_EXCLUDED_BASES, VERB_PREFIXES
@@ -79,10 +80,10 @@ def check_is_fixture_decorator(decorator: ast.expr) -> bool:
79
80
 
80
81
 
81
82
  @typing.final
83
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
82
84
  class FunctionVerbCheck(ast.NodeVisitor):
83
- def __init__(self, syntax_tree: ast.AST) -> None:
84
- self.violations: list[Violation] = []
85
- self.syntax_tree: typing.Final[ast.AST] = syntax_tree
85
+ syntax_tree: ast.AST
86
+ violations: list[Violation] = dataclasses.field(default_factory=list)
86
87
 
87
88
  def visit_FunctionDef(self, ast_node: ast.FunctionDef) -> None:
88
89
  self.validate_function_name(ast_node, find_parent_class_definition(self.syntax_tree, ast_node))
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+ import ast
3
+ import dataclasses
4
+ import typing
5
+
6
+ from community_of_python_flake8_plugin.violation_codes import ViolationCodes
7
+ from community_of_python_flake8_plugin.violations import Violation
8
+
9
+
10
+ if typing.TYPE_CHECKING:
11
+ from collections.abc import Iterable, Sequence
12
+
13
+
14
+ MUTABLE_ANNOTATION_NAME: typing.Final = "Mutable"
15
+ NESTED_SCOPE_NODE_TYPES: typing.Final = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
16
+
17
+
18
+ def check_is_mutable_annotation(annotation_node: ast.AST) -> bool:
19
+ if isinstance(annotation_node, ast.Name):
20
+ return annotation_node.id == MUTABLE_ANNOTATION_NAME
21
+ if isinstance(annotation_node, ast.Attribute):
22
+ return annotation_node.attr == MUTABLE_ANNOTATION_NAME
23
+ if isinstance(annotation_node, ast.Subscript):
24
+ return check_is_mutable_annotation(annotation_node.value)
25
+ return False
26
+
27
+
28
+ def extract_assigned_names(target_node: ast.expr) -> Iterable[str]:
29
+ if isinstance(target_node, ast.Name):
30
+ yield target_node.id
31
+ elif isinstance(target_node, (ast.Tuple, ast.List)):
32
+ for one_element in target_node.elts:
33
+ yield from extract_assigned_names(one_element)
34
+ elif isinstance(target_node, ast.Starred):
35
+ yield from extract_assigned_names(target_node.value)
36
+
37
+
38
+ def iter_scope_child_nodes(scope_body: Iterable[ast.stmt]) -> Iterable[ast.AST]:
39
+ for one_statement in scope_body:
40
+ yield from iter_same_scope_nodes(one_statement)
41
+
42
+
43
+ def iter_same_scope_nodes(ast_node: ast.AST) -> Iterable[ast.AST]:
44
+ yield ast_node
45
+ if isinstance(ast_node, NESTED_SCOPE_NODE_TYPES):
46
+ return
47
+ for one_child_node in ast.iter_child_nodes(ast_node):
48
+ yield from iter_same_scope_nodes(one_child_node)
49
+
50
+
51
+ @typing.final
52
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
53
+ class COP017ImmutableVariableCheck(ast.NodeVisitor):
54
+ syntax_tree: ast.AST
55
+ violations: list[Violation] = dataclasses.field(default_factory=list)
56
+
57
+ def visit_Module(self, ast_node: ast.Module) -> None:
58
+ self.validate_scope(ast_node.body)
59
+ self.generic_visit(ast_node)
60
+
61
+ def visit_FunctionDef(self, ast_node: ast.FunctionDef) -> None:
62
+ self.validate_scope(ast_node.body)
63
+ self.generic_visit(ast_node)
64
+
65
+ def visit_AsyncFunctionDef(self, ast_node: ast.AsyncFunctionDef) -> None:
66
+ self.validate_scope(ast_node.body)
67
+ self.generic_visit(ast_node)
68
+
69
+ def visit_ClassDef(self, ast_node: ast.ClassDef) -> None:
70
+ self.validate_scope(ast_node.body)
71
+ self.generic_visit(ast_node)
72
+
73
+ def validate_scope(self, scope_body: Sequence[ast.stmt]) -> None:
74
+ skipped_names: typing.Final = self.collect_outer_scope_names(scope_body)
75
+ mutable_flag_by_name: typing.Final[dict[str, bool]] = {}
76
+ for one_scope_node in iter_scope_child_nodes(scope_body):
77
+ if isinstance(one_scope_node, ast.Assign):
78
+ self.validate_assignment(
79
+ one_scope_node, skipped_names=skipped_names, mutable_flag_by_name=mutable_flag_by_name
80
+ )
81
+ elif isinstance(one_scope_node, ast.AnnAssign):
82
+ self.validate_annotated_assignment(
83
+ one_scope_node, skipped_names=skipped_names, mutable_flag_by_name=mutable_flag_by_name
84
+ )
85
+
86
+ def collect_outer_scope_names(self, scope_body: Iterable[ast.stmt]) -> set[str]:
87
+ outer_scope_names: typing.Final[set[str]] = set()
88
+ for one_scope_node in iter_scope_child_nodes(scope_body):
89
+ if isinstance(one_scope_node, (ast.Global, ast.Nonlocal)):
90
+ outer_scope_names.update(one_scope_node.names)
91
+ return outer_scope_names
92
+
93
+ def validate_assignment(
94
+ self, ast_node: ast.Assign, *, skipped_names: set[str], mutable_flag_by_name: dict[str, bool]
95
+ ) -> None:
96
+ for one_target in ast_node.targets:
97
+ for one_assigned_name in extract_assigned_names(one_target):
98
+ if one_assigned_name in skipped_names:
99
+ continue
100
+ if mutable_flag_by_name.get(one_assigned_name) is False:
101
+ self.append_violation(ast_node)
102
+ else:
103
+ mutable_flag_by_name.setdefault(one_assigned_name, False)
104
+
105
+ def validate_annotated_assignment(
106
+ self, ast_node: ast.AnnAssign, *, skipped_names: set[str], mutable_flag_by_name: dict[str, bool]
107
+ ) -> None:
108
+ if not isinstance(ast_node.target, ast.Name):
109
+ return
110
+ assigned_name: typing.Final = ast_node.target.id
111
+ if assigned_name in skipped_names:
112
+ return
113
+ if mutable_flag_by_name.get(assigned_name) is False:
114
+ self.append_violation(ast_node)
115
+ return
116
+ is_mutable: typing.Final = check_is_mutable_annotation(ast_node.annotation)
117
+ if ast_node.value is None:
118
+ if is_mutable:
119
+ mutable_flag_by_name[assigned_name] = True
120
+ else:
121
+ mutable_flag_by_name.setdefault(assigned_name, is_mutable)
122
+
123
+ def append_violation(self, ast_node: ast.stmt) -> None:
124
+ self.violations.append(
125
+ Violation(
126
+ line_number=ast_node.lineno,
127
+ column_number=ast_node.col_offset,
128
+ violation_code=ViolationCodes.IMMUTABLE_VARIABLE,
129
+ )
130
+ )
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
  import ast
3
+ import dataclasses
3
4
  import typing
4
5
 
5
6
  from community_of_python_flake8_plugin.constants import MAPPING_PROXY_TYPES
@@ -37,38 +38,33 @@ def is_dict_type_annotation(annotation: ast.expr | None) -> bool:
37
38
 
38
39
  Returns False for TypedDict and other non-dict annotations.
39
40
  """
40
- is_dict_annotation = False
41
-
42
- if annotation is not None:
43
- # Handle simple name annotations like 'dict'
44
- if isinstance(annotation, ast.Name):
45
- is_dict_annotation = annotation.id == "dict"
46
- # Handle attribute annotations like 'typing.Final'
47
- elif isinstance(annotation, ast.Attribute):
48
- is_dict_annotation = annotation.attr == "dict"
49
- # Handle subscript annotations like 'dict[str, int]' or 'Final[dict]'
50
- elif isinstance(annotation, ast.Subscript):
51
- base_name: typing.Final = _get_base_name(annotation.value)
52
- if base_name:
53
- # Check for Final[...] annotations
54
- if base_name == "Final":
55
- # Extract the inner type from Final[inner_type]
56
- inner_type = annotation.slice
57
- # Handle Python 3.8 vs 3.9+ differences
58
- if hasattr(inner_type, "value"): # Python 3.8
59
- inner_type = inner_type.value
60
- is_dict_annotation = is_dict_type_annotation(inner_type)
61
- # Check for dict[...] annotations
62
- elif base_name == "dict":
63
- is_dict_annotation = True
64
-
65
- return is_dict_annotation
41
+ # Handle simple name annotations like 'dict'
42
+ if isinstance(annotation, ast.Name):
43
+ return annotation.id == "dict"
44
+ # Handle attribute annotations like 'typing.Final'
45
+ if isinstance(annotation, ast.Attribute):
46
+ return annotation.attr == "dict"
47
+ # Handle subscript annotations like 'dict[str, int]' or 'Final[dict]'
48
+ if isinstance(annotation, ast.Subscript):
49
+ base_name: typing.Final = _get_base_name(annotation.value)
50
+ # Extract the inner type from Final[inner_type]
51
+ if base_name == "Final":
52
+ return is_dict_type_annotation(annotation.slice)
53
+ return base_name == "dict"
54
+ return False
55
+
56
+
57
+ def _get_assignment_targets(ast_node: ast.Assign | ast.AnnAssign) -> list[ast.expr]:
58
+ if isinstance(ast_node, ast.Assign):
59
+ return ast_node.targets
60
+ return [ast_node.target] if ast_node.value is not None else []
66
61
 
67
62
 
68
63
  @typing.final
64
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
69
65
  class MappingProxyCheck(ast.NodeVisitor):
70
- def __init__(self, syntax_tree: ast.AST) -> None: # noqa: ARG002
71
- self.violations: list[Violation] = []
66
+ syntax_tree: ast.AST
67
+ violations: list[Violation] = dataclasses.field(default_factory=list)
72
68
 
73
69
  def visit_Module(self, ast_node: ast.Module) -> None:
74
70
  for one_statement in ast_node.body:
@@ -86,15 +82,8 @@ class MappingProxyCheck(ast.NodeVisitor):
86
82
  return
87
83
 
88
84
  # Check for dictionary literals assigned to module-level variables
89
- assigned_value: ast.expr | None
90
- assignment_targets: list[ast.expr]
91
-
92
- if isinstance(ast_node, ast.Assign):
93
- assigned_value = ast_node.value
94
- assignment_targets = ast_node.targets
95
- else: # ast.AnnAssign
96
- assigned_value = ast_node.value
97
- assignment_targets = [ast_node.target] if ast_node.value is not None else []
85
+ assigned_value: typing.Final = ast_node.value
86
+ assignment_targets: typing.Final = _get_assignment_targets(ast_node)
98
87
 
99
88
  # Only check module-level assignments (no parent function/class)
100
89
  if assigned_value is not None and isinstance(assigned_value, ast.Dict) and assignment_targets:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
  import ast
3
+ import dataclasses
3
4
  import sys
4
5
  import typing
5
6
  from importlib import util as importlib_util
@@ -21,9 +22,10 @@ def check_is_stdlib_package(module_name: str) -> bool:
21
22
 
22
23
 
23
24
  @typing.final
25
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
24
26
  class COP002StdlibImportCheck(ast.NodeVisitor):
25
- def __init__(self, syntax_tree: ast.AST) -> None: # noqa: ARG002
26
- self.violations: list[Violation] = []
27
+ syntax_tree: ast.AST
28
+ violations: list[Violation] = dataclasses.field(default_factory=list)
27
29
 
28
30
  def visit_ImportFrom(self, ast_node: ast.ImportFrom) -> None:
29
31
  if ast_node.module and ast_node.level == 0 and ast_node.module not in ALLOWED_STDLIB_FROM_IMPORTS:
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
  import ast
3
+ import dataclasses
3
4
  import typing
4
5
 
5
6
  from community_of_python_flake8_plugin.constants import FINAL_CLASS_EXCLUDED_BASES, MIN_NAME_LENGTH
@@ -49,10 +50,10 @@ def check_is_fixture_decorator(decorator: ast.expr) -> bool:
49
50
 
50
51
 
51
52
  @typing.final
53
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
52
54
  class COP004NameLengthCheck(ast.NodeVisitor):
53
- def __init__(self, tree: ast.AST) -> None: # noqa: COP006
54
- self.violations: list[Violation] = []
55
- self.syntax_tree: typing.Final[ast.AST] = tree
55
+ syntax_tree: ast.AST
56
+ violations: list[Violation] = dataclasses.field(default_factory=list)
56
57
 
57
58
  def visit_AnnAssign(self, ast_node: ast.AnnAssign) -> None:
58
59
  if isinstance(ast_node.target, ast.Name):
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
  import ast
3
+ import dataclasses
3
4
  import typing
4
5
 
5
6
  from community_of_python_flake8_plugin.constants import SCALAR_ANNOTATIONS
@@ -37,10 +38,10 @@ def check_is_scalar_annotation(annotation_node: ast.AST) -> bool:
37
38
 
38
39
 
39
40
  @typing.final
41
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
40
42
  class ScalarAnnotationCheck(ast.NodeVisitor):
41
- def __init__(self, syntax_tree: ast.AST) -> None: # noqa: COP006
42
- self.violations: list[Violation] = []
43
- self.syntax_tree: typing.Final[ast.AST] = syntax_tree
43
+ syntax_tree: ast.AST
44
+ violations: list[Violation] = dataclasses.field(default_factory=list)
44
45
 
45
46
  def visit_AnnAssign(self, ast_node: ast.AnnAssign) -> None:
46
47
  if isinstance(ast_node.target, ast.Name) and (
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
  import ast
3
+ import dataclasses
3
4
  import typing
4
5
  from collections import defaultdict
5
6
 
@@ -93,9 +94,10 @@ def is_used_in_next_line(assign_node: ast.Assign | ast.AnnAssign, usage_nodes: l
93
94
 
94
95
 
95
96
  @typing.final
97
+ @dataclasses.dataclass(kw_only=True, slots=True, frozen=True)
96
98
  class TempVarCheck(ast.NodeVisitor):
97
- def __init__(self, syntax_tree: ast.AST) -> None: # noqa: ARG002
98
- self.violations: list[Violation] = []
99
+ syntax_tree: ast.AST
100
+ violations: list[Violation] = dataclasses.field(default_factory=list)
99
101
 
100
102
  def visit_FunctionDef(self, ast_node: ast.FunctionDef) -> None:
101
103
  self._check_temporary_variables(ast_node)
@@ -18,7 +18,7 @@ if typing.TYPE_CHECKING:
18
18
  class PluginCheckProtocol(typing.Protocol):
19
19
  violations: list[Violation]
20
20
 
21
- def __init__(self, tree: ast.AST) -> None: ... # noqa: COP006
21
+ def __init__(self, *, syntax_tree: ast.AST) -> None: ...
22
22
  def visit(self, node: ast.AST) -> None: ... # noqa: COP007,COP006,COP012
23
23
 
24
24
 
@@ -48,7 +48,7 @@ class CommunityOfPythonFlake8Plugin:
48
48
  for one_attribute_name in dir(imported_module):
49
49
  attribute = getattr(imported_module, one_attribute_name)
50
50
  if isinstance(attribute, type) and one_attribute_name.endswith("Check") and hasattr(attribute, "visit"):
51
- check_instance = attribute(self.ast_syntax_tree)
51
+ check_instance = attribute(syntax_tree=self.ast_syntax_tree)
52
52
  check_instance.visit(self.ast_syntax_tree)
53
53
  checks_collection.append(check_instance)
54
54
  return checks_collection
@@ -59,3 +59,12 @@ class ViolationCodes:
59
59
  FUNCTION_KEYWORD_ONLY_ARGS = ViolationCodeItem(
60
60
  code="COP016", description="Add * or / when defining more than two regular arguments"
61
61
  )
62
+
63
+ # Variable mutability violations
64
+ IMMUTABLE_VARIABLE = ViolationCodeItem(
65
+ code="COP017",
66
+ description=(
67
+ "Avoid reassigning variables. If reassignment is intended, annotate the first binding: "
68
+ "`from cop_extensions import Mutable` and `name: Mutable[T] = ...`"
69
+ ),
70
+ )
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+ import typing
3
+
4
+
5
+ __all__ = ["Mutable"]
6
+
7
+ _ValueType = typing.TypeVar("_ValueType")
8
+
9
+ if typing.TYPE_CHECKING:
10
+ # For type checkers Mutable[T] is just T with an extra marker; bare Mutable is also accepted.
11
+ Mutable: typing.TypeAlias = typing.Annotated[_ValueType, "mutable"] # noqa: COP005
12
+ else:
13
+
14
+ @typing.final
15
+ class _MutableMarker:
16
+ def __getitem__(self, item_value: _ValueType) -> _ValueType:
17
+ return item_value
18
+
19
+ Mutable = _MutableMarker() # noqa: COP005,COP017