From 6bf3847111425bc1781ecf9aaeb36e8f015e1813 Mon Sep 17 00:00:00 2001 From: Anton Gavrilov Date: Thu, 9 Jul 2026 11:22:13 +0300 Subject: [PATCH 1/5] =?UTF-8?q?Adds=20COP017=20=E2=80=94=20immutable=20var?= =?UTF-8?q?iables=20by=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As a replacement for auto-typing-final --- README.md | 15 +++ .../checks/immutable_variable.py | 107 ++++++++++++++++++ .../violation_codes.py | 3 + tests/test_plugin.py | 72 ++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 src/community_of_python_flake8_plugin/checks/immutable_variable.py diff --git a/README.md b/README.md index 3c556e6..ebd29e8 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,21 @@ This plugin implements the following code style checks: - **COP014**: Use dataclasses with `kw_only=True`, `slots=True`, `frozen=True` - **COP015**: For-loop variables must be prefixed with `one_` - **COP016**: Add `*` or `/` when defining more than two regular arguments +- **COP017**: Avoid reassigning variables + +### COP017: immutable variables by default + +Variables are treated as immutable: reassigning a name with `=` in the same scope is a violation. When reassignment is intended, allow it explicitly with `# noqa: COP017` on the reassignment line: + +```python +counter_value = 0 +counter_value = compute_next(counter_value) # noqa: COP017 + +total_value = 0 +total_value = compute_next(total_value) # COP017 +``` + +Augmented assignments (`+=`), for-loop variables, attribute and subscript targets, and names declared `global`/`nonlocal` are not checked. ## Installation diff --git a/src/community_of_python_flake8_plugin/checks/immutable_variable.py b/src/community_of_python_flake8_plugin/checks/immutable_variable.py new file mode 100644 index 0000000..c304440 --- /dev/null +++ b/src/community_of_python_flake8_plugin/checks/immutable_variable.py @@ -0,0 +1,107 @@ +from __future__ import annotations +import ast +import typing + +from community_of_python_flake8_plugin.violation_codes import ViolationCodes +from community_of_python_flake8_plugin.violations import Violation + + +if typing.TYPE_CHECKING: + from collections.abc import Iterable + + +NESTED_SCOPE_NODE_TYPES: typing.Final = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda) + + +def extract_assigned_names(target_node: ast.expr) -> Iterable[str]: + if isinstance(target_node, ast.Name): + yield target_node.id + elif isinstance(target_node, (ast.Tuple, ast.List)): + for one_element in target_node.elts: + yield from extract_assigned_names(one_element) + elif isinstance(target_node, ast.Starred): + yield from extract_assigned_names(target_node.value) + + +def iter_scope_child_nodes(scope_body: list[ast.stmt]) -> Iterable[ast.AST]: + for one_statement in scope_body: + yield from iter_same_scope_nodes(one_statement) + + +def iter_same_scope_nodes(ast_node: ast.AST) -> Iterable[ast.AST]: + yield ast_node + if isinstance(ast_node, NESTED_SCOPE_NODE_TYPES): + return + for one_child_node in ast.iter_child_nodes(ast_node): + yield from iter_same_scope_nodes(one_child_node) + + +@typing.final +class COP017ImmutableVariableCheck(ast.NodeVisitor): + def __init__(self, syntax_tree: ast.AST) -> None: + self.violations: list[Violation] = [] + self.syntax_tree: typing.Final[ast.AST] = syntax_tree + + def visit_Module(self, ast_node: ast.Module) -> None: + self.validate_scope(ast_node.body) + self.generic_visit(ast_node) + + def visit_FunctionDef(self, ast_node: ast.FunctionDef) -> None: + self.validate_scope(ast_node.body) + self.generic_visit(ast_node) + + def visit_AsyncFunctionDef(self, ast_node: ast.AsyncFunctionDef) -> None: + self.validate_scope(ast_node.body) + self.generic_visit(ast_node) + + def visit_ClassDef(self, ast_node: ast.ClassDef) -> None: + self.validate_scope(ast_node.body) + self.generic_visit(ast_node) + + def validate_scope(self, scope_body: list[ast.stmt]) -> None: + skipped_names: typing.Final = self.collect_outer_scope_names(scope_body) + bound_names: typing.Final[set[str]] = set() + for one_scope_node in iter_scope_child_nodes(scope_body): + if isinstance(one_scope_node, ast.Assign): + self.validate_assignment(one_scope_node, skipped_names, bound_names) + elif isinstance(one_scope_node, ast.AnnAssign): + self.validate_annotated_assignment(one_scope_node, skipped_names, bound_names) + + def collect_outer_scope_names(self, scope_body: list[ast.stmt]) -> set[str]: + outer_scope_names: typing.Final[set[str]] = set() + for one_scope_node in iter_scope_child_nodes(scope_body): + if isinstance(one_scope_node, (ast.Global, ast.Nonlocal)): + outer_scope_names.update(one_scope_node.names) + return outer_scope_names + + def validate_assignment(self, ast_node: ast.Assign, skipped_names: set[str], bound_names: set[str]) -> None: + for one_target in ast_node.targets: + for one_assigned_name in extract_assigned_names(one_target): + if one_assigned_name in skipped_names: + continue + if one_assigned_name in bound_names: + self.append_violation(ast_node) + else: + bound_names.add(one_assigned_name) + + def validate_annotated_assignment( + self, ast_node: ast.AnnAssign, skipped_names: set[str], bound_names: set[str] + ) -> None: + if not isinstance(ast_node.target, ast.Name): + return + assigned_name: typing.Final = ast_node.target.id + if assigned_name in skipped_names: + return + if assigned_name in bound_names: + self.append_violation(ast_node) + elif ast_node.value is not None: + bound_names.add(assigned_name) + + def append_violation(self, ast_node: ast.stmt) -> None: + self.violations.append( + Violation( + line_number=ast_node.lineno, + column_number=ast_node.col_offset, + violation_code=ViolationCodes.IMMUTABLE_VARIABLE, + ) + ) diff --git a/src/community_of_python_flake8_plugin/violation_codes.py b/src/community_of_python_flake8_plugin/violation_codes.py index 91af7b3..9dab16d 100644 --- a/src/community_of_python_flake8_plugin/violation_codes.py +++ b/src/community_of_python_flake8_plugin/violation_codes.py @@ -59,3 +59,6 @@ class ViolationCodes: FUNCTION_KEYWORD_ONLY_ARGS = ViolationCodeItem( code="COP016", description="Add * or / when defining more than two regular arguments" ) + + # Variable mutability violations + IMMUTABLE_VARIABLE = ViolationCodeItem(code="COP017", description="Avoid reassigning variables") diff --git a/tests/test_plugin.py b/tests/test_plugin.py index dc45ea5..55c2663 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -777,3 +777,75 @@ def test_combined_validations(input_source: str, expected_output: list[str]) -> for one_violation_item in CommunityOfPythonFlake8Plugin(ast.parse(input_source)).run() ] # noqa: COP011 ) == sorted(expected_output) + + +@pytest.mark.parametrize( + ("input_source", "expected_output"), + [ + # Reassignment of an already bound name + ("counter_value = compute_value()\ncounter_value = compute_other()", ["COP017"]), + # Every extra reassignment is reported separately + ( + "counter_value = compute_value()\ncounter_value = compute_other()\ncounter_value = compute_third()", + ["COP017", "COP017"], + ), + # Bare annotation without value does not lock the name (declare-then-assign) + ("counter_value: int\ncounter_value = compute_value()", []), + # Augmented assignment is not a reassignment + ("counter_value = compute_value()\ncounter_value += compute_other()", []), + # For-loop variable does not lock the name + ("for one_record in fetch_records():\n pass\none_record = compute_value()", []), + # Assignment in a nested function is a separate scope + ( + "counter_value = compute_value()\n" + "def update_counter() -> None:\n" + " counter_value = compute_other()\n" + " apply_value(counter_value)\n" + " apply_value(counter_value)", + [], + ), + # Names declared global are skipped + ( + "counter_value = compute_value()\n" + "def update_counter() -> None:\n" + " global counter_value\n" + " counter_value = compute_other()", + [], + ), + # Tuple unpacking locks each name + ( + "first_value, second_value = fetch_pair()\nfirst_value = compute_value()", + ["COP017"], + ), + # Attribute targets are not name rebindings + ( + "def update_counter(self) -> None:\n" + " self.counter_value = compute_value()\n" + " self.counter_value = compute_other()", + [], + ), + # Reassignment via annotated assignment + ("counter_value = compute_value()\ncounter_value: int = compute_other()", ["COP017"]), + # Reassignments in different functions are separate scopes + ( + "def update_counter() -> None:\n" + " counter_value = compute_value()\n" + " apply_value(counter_value)\n" + " apply_value(counter_value)\n" + "def update_another() -> None:\n" + " counter_value = compute_other()\n" + " apply_value(counter_value)\n" + " apply_value(counter_value)", + [], + ), + # Distinct names assigned once each + ("counter_value = compute_value()\nanother_value = compute_other()", []), + ], +) +def test_immutable_variable_validations(input_source: str, expected_output: list[str]) -> None: + assert sorted( + [ + one_violation_item[2].split(" ")[0] + for one_violation_item in CommunityOfPythonFlake8Plugin(ast.parse(input_source)).run() + ] # noqa: COP011 + ) == sorted(expected_output) From 45360e6ba381a14aac4bd566aa11381740359c2c Mon Sep 17 00:00:00 2001 From: Anton Gavrilov Date: Thu, 9 Jul 2026 14:18:57 +0300 Subject: [PATCH 2/5] Fix COP016/COP017 self-lint violations in plugin code Rewrite mapping_proxy helpers with early returns instead of mutable accumulators (also drops dead Python 3.8 ast.Index handling; the package requires Python >=3.10) and make COP017 check helper arguments keyword-only. Co-Authored-By: Claude Fable 5 --- .../checks/immutable_variable.py | 8 +-- .../checks/mapping_proxy.py | 57 +++++++------------ 2 files changed, 26 insertions(+), 39 deletions(-) diff --git a/src/community_of_python_flake8_plugin/checks/immutable_variable.py b/src/community_of_python_flake8_plugin/checks/immutable_variable.py index c304440..d50bf05 100644 --- a/src/community_of_python_flake8_plugin/checks/immutable_variable.py +++ b/src/community_of_python_flake8_plugin/checks/immutable_variable.py @@ -63,9 +63,9 @@ def validate_scope(self, scope_body: list[ast.stmt]) -> None: bound_names: typing.Final[set[str]] = set() for one_scope_node in iter_scope_child_nodes(scope_body): if isinstance(one_scope_node, ast.Assign): - self.validate_assignment(one_scope_node, skipped_names, bound_names) + self.validate_assignment(one_scope_node, skipped_names=skipped_names, bound_names=bound_names) elif isinstance(one_scope_node, ast.AnnAssign): - self.validate_annotated_assignment(one_scope_node, skipped_names, bound_names) + self.validate_annotated_assignment(one_scope_node, skipped_names=skipped_names, bound_names=bound_names) def collect_outer_scope_names(self, scope_body: list[ast.stmt]) -> set[str]: outer_scope_names: typing.Final[set[str]] = set() @@ -74,7 +74,7 @@ def collect_outer_scope_names(self, scope_body: list[ast.stmt]) -> set[str]: outer_scope_names.update(one_scope_node.names) return outer_scope_names - def validate_assignment(self, ast_node: ast.Assign, skipped_names: set[str], bound_names: set[str]) -> None: + def validate_assignment(self, ast_node: ast.Assign, *, skipped_names: set[str], bound_names: set[str]) -> None: for one_target in ast_node.targets: for one_assigned_name in extract_assigned_names(one_target): if one_assigned_name in skipped_names: @@ -85,7 +85,7 @@ def validate_assignment(self, ast_node: ast.Assign, skipped_names: set[str], bou bound_names.add(one_assigned_name) def validate_annotated_assignment( - self, ast_node: ast.AnnAssign, skipped_names: set[str], bound_names: set[str] + self, ast_node: ast.AnnAssign, *, skipped_names: set[str], bound_names: set[str] ) -> None: if not isinstance(ast_node.target, ast.Name): return diff --git a/src/community_of_python_flake8_plugin/checks/mapping_proxy.py b/src/community_of_python_flake8_plugin/checks/mapping_proxy.py index 602cd78..df75da2 100644 --- a/src/community_of_python_flake8_plugin/checks/mapping_proxy.py +++ b/src/community_of_python_flake8_plugin/checks/mapping_proxy.py @@ -37,32 +37,26 @@ def is_dict_type_annotation(annotation: ast.expr | None) -> bool: Returns False for TypedDict and other non-dict annotations. """ - is_dict_annotation = False - - if annotation is not None: - # Handle simple name annotations like 'dict' - if isinstance(annotation, ast.Name): - is_dict_annotation = annotation.id == "dict" - # Handle attribute annotations like 'typing.Final' - elif isinstance(annotation, ast.Attribute): - is_dict_annotation = annotation.attr == "dict" - # Handle subscript annotations like 'dict[str, int]' or 'Final[dict]' - elif isinstance(annotation, ast.Subscript): - base_name: typing.Final = _get_base_name(annotation.value) - if base_name: - # Check for Final[...] annotations - if base_name == "Final": - # Extract the inner type from Final[inner_type] - inner_type = annotation.slice - # Handle Python 3.8 vs 3.9+ differences - if hasattr(inner_type, "value"): # Python 3.8 - inner_type = inner_type.value - is_dict_annotation = is_dict_type_annotation(inner_type) - # Check for dict[...] annotations - elif base_name == "dict": - is_dict_annotation = True - - return is_dict_annotation + # Handle simple name annotations like 'dict' + if isinstance(annotation, ast.Name): + return annotation.id == "dict" + # Handle attribute annotations like 'typing.Final' + if isinstance(annotation, ast.Attribute): + return annotation.attr == "dict" + # Handle subscript annotations like 'dict[str, int]' or 'Final[dict]' + if isinstance(annotation, ast.Subscript): + base_name: typing.Final = _get_base_name(annotation.value) + # Extract the inner type from Final[inner_type] + if base_name == "Final": + return is_dict_type_annotation(annotation.slice) + return base_name == "dict" + return False + + +def _get_assignment_targets(ast_node: ast.Assign | ast.AnnAssign) -> list[ast.expr]: + if isinstance(ast_node, ast.Assign): + return ast_node.targets + return [ast_node.target] if ast_node.value is not None else [] @typing.final @@ -86,15 +80,8 @@ def _check_mapping_assignment(self, ast_node: ast.Assign | ast.AnnAssign) -> Non return # Check for dictionary literals assigned to module-level variables - assigned_value: ast.expr | None - assignment_targets: list[ast.expr] - - if isinstance(ast_node, ast.Assign): - assigned_value = ast_node.value - assignment_targets = ast_node.targets - else: # ast.AnnAssign - assigned_value = ast_node.value - assignment_targets = [ast_node.target] if ast_node.value is not None else [] + assigned_value: typing.Final = ast_node.value + assignment_targets: typing.Final = _get_assignment_targets(ast_node) # Only check module-level assignments (no parent function/class) if assigned_value is not None and isinstance(assigned_value, ast.Dict) and assignment_targets: From a35758c578f2ee18a8eb52e3c29483c3b73f0da0 Mon Sep 17 00:00:00 2001 From: Anton Gavrilov Date: Thu, 9 Jul 2026 15:03:11 +0300 Subject: [PATCH 3/5] Bring back Mutable marker, make flake8 an optional dependency The COP017 escape hatch is the Mutable annotation again (runtime marker exported from the package). The package now has no runtime dependencies, so importing Mutable in production code does not pull in flake8; linting installs use the [flake8] extra. The violation message includes the import instruction so code agents can fix it without extra context. Co-Authored-By: Claude Fable 5 --- README.md | 24 +++++++---- pyproject.toml | 6 ++- .../__init__.py | 3 +- .../checks/immutable_variable.py | 42 ++++++++++++++----- .../mutable.py | 17 ++++++++ .../violation_codes.py | 8 +++- tests/test_plugin.py | 28 ++++++++++++- uv.lock | 9 +++- 8 files changed, 113 insertions(+), 24 deletions(-) create mode 100644 src/community_of_python_flake8_plugin/mutable.py diff --git a/README.md b/README.md index ebd29e8..b0531e2 100644 --- a/README.md +++ b/README.md @@ -22,34 +22,42 @@ This plugin implements the following code style checks: - **COP014**: Use dataclasses with `kw_only=True`, `slots=True`, `frozen=True` - **COP015**: For-loop variables must be prefixed with `one_` - **COP016**: Add `*` or `/` when defining more than two regular arguments -- **COP017**: Avoid reassigning variables +- **COP017**: Avoid reassigning variables not annotated with `Mutable` ### COP017: immutable variables by default -Variables are treated as immutable: reassigning a name with `=` in the same scope is a violation. When reassignment is intended, allow it explicitly with `# noqa: COP017` on the reassignment line: +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: ```python -counter_value = 0 -counter_value = compute_next(counter_value) # noqa: COP017 +from community_of_python_flake8_plugin import Mutable + +counter_value: Mutable[int] = 0 +counter_value = compute_next(counter_value) # OK total_value = 0 total_value = compute_next(total_value) # COP017 ``` -Augmented assignments (`+=`), for-loop variables, attribute and subscript targets, and names declared `global`/`nonlocal` are not checked. +The package 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. ## Installation -Install the plugin using `uv` (recommended): +Install the plugin using `uv` (recommended). For linting, use the `flake8` extra: ```bash -uv add --dev community-of-python-flake8-plugin +uv add --dev "community-of-python-flake8-plugin[flake8]" ``` Or install via pip: ```bash -pip install community-of-python-flake8-plugin +pip install "community-of-python-flake8-plugin[flake8]" +``` + +To use the `Mutable` marker in production code, add the package without the extra — it has no runtime dependencies: + +```bash +uv add community-of-python-flake8-plugin ``` ## Usage diff --git a/pyproject.toml b/pyproject.toml index d4ee88f..520d401 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,10 @@ description = "Community of Python flake8 plugin" readme = "README.md" authors = [{ name = "Lev Vereshchagin", email = "mail@vrslev.com" }] requires-python = ">=3.10" -dependencies = ["flake8"] +dependencies = [] + +[project.optional-dependencies] +flake8 = ["flake8"] [project.entry-points."flake8.extension"] COP = "community_of_python_flake8_plugin.plugin:CommunityOfPythonFlake8Plugin" @@ -15,6 +18,7 @@ addopts = "--cov" [dependency-groups] dev = [ + "flake8", "mypy", "ruff", "auto-typing-final", diff --git a/src/community_of_python_flake8_plugin/__init__.py b/src/community_of_python_flake8_plugin/__init__.py index b485b55..aa9679a 100644 --- a/src/community_of_python_flake8_plugin/__init__.py +++ b/src/community_of_python_flake8_plugin/__init__.py @@ -1,4 +1,5 @@ +from community_of_python_flake8_plugin.mutable import Mutable from community_of_python_flake8_plugin.plugin import CommunityOfPythonFlake8Plugin -__all__ = ["CommunityOfPythonFlake8Plugin"] +__all__ = ["CommunityOfPythonFlake8Plugin", "Mutable"] diff --git a/src/community_of_python_flake8_plugin/checks/immutable_variable.py b/src/community_of_python_flake8_plugin/checks/immutable_variable.py index d50bf05..488098b 100644 --- a/src/community_of_python_flake8_plugin/checks/immutable_variable.py +++ b/src/community_of_python_flake8_plugin/checks/immutable_variable.py @@ -10,9 +10,20 @@ from collections.abc import Iterable +MUTABLE_ANNOTATION_NAME: typing.Final = "Mutable" NESTED_SCOPE_NODE_TYPES: typing.Final = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda) +def check_is_mutable_annotation(annotation_node: ast.AST) -> bool: + if isinstance(annotation_node, ast.Name): + return annotation_node.id == MUTABLE_ANNOTATION_NAME + if isinstance(annotation_node, ast.Attribute): + return annotation_node.attr == MUTABLE_ANNOTATION_NAME + if isinstance(annotation_node, ast.Subscript): + return check_is_mutable_annotation(annotation_node.value) + return False + + def extract_assigned_names(target_node: ast.expr) -> Iterable[str]: if isinstance(target_node, ast.Name): yield target_node.id @@ -60,12 +71,16 @@ def visit_ClassDef(self, ast_node: ast.ClassDef) -> None: def validate_scope(self, scope_body: list[ast.stmt]) -> None: skipped_names: typing.Final = self.collect_outer_scope_names(scope_body) - bound_names: typing.Final[set[str]] = set() + mutable_flag_by_name: typing.Final[dict[str, bool]] = {} for one_scope_node in iter_scope_child_nodes(scope_body): if isinstance(one_scope_node, ast.Assign): - self.validate_assignment(one_scope_node, skipped_names=skipped_names, bound_names=bound_names) + self.validate_assignment( + one_scope_node, skipped_names=skipped_names, mutable_flag_by_name=mutable_flag_by_name + ) elif isinstance(one_scope_node, ast.AnnAssign): - self.validate_annotated_assignment(one_scope_node, skipped_names=skipped_names, bound_names=bound_names) + self.validate_annotated_assignment( + one_scope_node, skipped_names=skipped_names, mutable_flag_by_name=mutable_flag_by_name + ) def collect_outer_scope_names(self, scope_body: list[ast.stmt]) -> set[str]: outer_scope_names: typing.Final[set[str]] = set() @@ -74,28 +89,35 @@ def collect_outer_scope_names(self, scope_body: list[ast.stmt]) -> set[str]: outer_scope_names.update(one_scope_node.names) return outer_scope_names - def validate_assignment(self, ast_node: ast.Assign, *, skipped_names: set[str], bound_names: set[str]) -> None: + def validate_assignment( + self, ast_node: ast.Assign, *, skipped_names: set[str], mutable_flag_by_name: dict[str, bool] + ) -> None: for one_target in ast_node.targets: for one_assigned_name in extract_assigned_names(one_target): if one_assigned_name in skipped_names: continue - if one_assigned_name in bound_names: + if mutable_flag_by_name.get(one_assigned_name) is False: self.append_violation(ast_node) else: - bound_names.add(one_assigned_name) + mutable_flag_by_name.setdefault(one_assigned_name, False) def validate_annotated_assignment( - self, ast_node: ast.AnnAssign, *, skipped_names: set[str], bound_names: set[str] + self, ast_node: ast.AnnAssign, *, skipped_names: set[str], mutable_flag_by_name: dict[str, bool] ) -> None: if not isinstance(ast_node.target, ast.Name): return assigned_name: typing.Final = ast_node.target.id if assigned_name in skipped_names: return - if assigned_name in bound_names: + if mutable_flag_by_name.get(assigned_name) is False: self.append_violation(ast_node) - elif ast_node.value is not None: - bound_names.add(assigned_name) + return + is_mutable: typing.Final = check_is_mutable_annotation(ast_node.annotation) + if ast_node.value is None: + if is_mutable: + mutable_flag_by_name[assigned_name] = True + else: + mutable_flag_by_name.setdefault(assigned_name, is_mutable) def append_violation(self, ast_node: ast.stmt) -> None: self.violations.append( diff --git a/src/community_of_python_flake8_plugin/mutable.py b/src/community_of_python_flake8_plugin/mutable.py new file mode 100644 index 0000000..ed18dab --- /dev/null +++ b/src/community_of_python_flake8_plugin/mutable.py @@ -0,0 +1,17 @@ +from __future__ import annotations +import typing + + +_ValueType = typing.TypeVar("_ValueType") + +if typing.TYPE_CHECKING: + # For type checkers Mutable[T] is just T with an extra marker; bare Mutable is also accepted. + Mutable: typing.TypeAlias = typing.Annotated[_ValueType, "mutable"] # noqa: COP005 +else: + + @typing.final + class _MutableMarker: + def __getitem__(self, item_value: object) -> object: + return item_value + + Mutable = _MutableMarker() # noqa: COP005,COP017 diff --git a/src/community_of_python_flake8_plugin/violation_codes.py b/src/community_of_python_flake8_plugin/violation_codes.py index 9dab16d..556446a 100644 --- a/src/community_of_python_flake8_plugin/violation_codes.py +++ b/src/community_of_python_flake8_plugin/violation_codes.py @@ -61,4 +61,10 @@ class ViolationCodes: ) # Variable mutability violations - IMMUTABLE_VARIABLE = ViolationCodeItem(code="COP017", description="Avoid reassigning variables") + IMMUTABLE_VARIABLE = ViolationCodeItem( + code="COP017", + description=( + "Avoid reassigning variables. If reassignment is intended, annotate the first binding: " + "`from community_of_python_flake8_plugin import Mutable` and `name: Mutable[T] = ...`" + ), + ) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 55c2663..a92cfb6 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -3,6 +3,7 @@ import pytest +import community_of_python_flake8_plugin from community_of_python_flake8_plugin.plugin import CommunityOfPythonFlake8Plugin @@ -782,13 +783,31 @@ def test_combined_validations(input_source: str, expected_output: list[str]) -> @pytest.mark.parametrize( ("input_source", "expected_output"), [ - # Reassignment of an already bound name + # Reassignment without Mutable annotation ("counter_value = compute_value()\ncounter_value = compute_other()", ["COP017"]), # Every extra reassignment is reported separately ( "counter_value = compute_value()\ncounter_value = compute_other()\ncounter_value = compute_third()", ["COP017", "COP017"], ), + # Bare Mutable annotation allows reassignment + ("counter_value: Mutable = compute_value()\ncounter_value = compute_other()", []), + # Subscripted Mutable annotation allows reassignment + ("counter_value: Mutable[int] = compute_value()\ncounter_value = compute_other()", []), + # Attribute access form of Mutable + ("counter_value: typing_extensions.Mutable[int] = compute_value()\ncounter_value = compute_other()", []), + # Bare Mutable declaration without value allows later reassignments + ( + "counter_value: Mutable\ncounter_value = compute_value()\ncounter_value = compute_other()", + [], + ), + # Multiple reassignments of a Mutable variable + ( + "counter_value: Mutable = compute_value()\n" + "counter_value = compute_other()\n" + "counter_value = compute_third()", + [], + ), # Bare annotation without value does not lock the name (declare-then-assign) ("counter_value: int\ncounter_value = compute_value()", []), # Augmented assignment is not a reassignment @@ -826,6 +845,8 @@ def test_combined_validations(input_source: str, expected_output: list[str]) -> ), # Reassignment via annotated assignment ("counter_value = compute_value()\ncounter_value: int = compute_other()", ["COP017"]), + # Mutable annotation on a later assignment does not excuse the reassignment + ("counter_value = compute_value()\ncounter_value: Mutable[int] = compute_other()", ["COP017"]), # Reassignments in different functions are separate scopes ( "def update_counter() -> None:\n" @@ -849,3 +870,8 @@ def test_immutable_variable_validations(input_source: str, expected_output: list for one_violation_item in CommunityOfPythonFlake8Plugin(ast.parse(input_source)).run() ] # noqa: COP011 ) == sorted(expected_output) + + +def test_mutable_marker_runtime() -> None: + assert community_of_python_flake8_plugin.Mutable is not None + assert community_of_python_flake8_plugin.Mutable[int] is int diff --git a/uv.lock b/uv.lock index 13924e3..673c4d0 100644 --- a/uv.lock +++ b/uv.lock @@ -87,13 +87,16 @@ wheels = [ name = "community-of-python-flake8-plugin" version = "0.1.27" source = { editable = "." } -dependencies = [ + +[package.optional-dependencies] +flake8 = [ { name = "flake8" }, ] [package.dev-dependencies] dev = [ { name = "auto-typing-final" }, + { name = "flake8" }, { name = "flake8-pyproject" }, { name = "mypy" }, { name = "pytest" }, @@ -102,11 +105,13 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "flake8" }] +requires-dist = [{ name = "flake8", marker = "extra == 'flake8'" }] +provides-extras = ["flake8"] [package.metadata.requires-dev] dev = [ { name = "auto-typing-final" }, + { name = "flake8" }, { name = "flake8-pyproject" }, { name = "mypy" }, { name = "pytest" }, From f9553ab070b313746c716e630b08066333140adb Mon Sep 17 00:00:00 2001 From: Anton Gavrilov Date: Thu, 9 Jul 2026 15:10:00 +0300 Subject: [PATCH 4/5] Address review: dataclass checks, behavior-based typing, no object annotations Convert all check classes to dataclasses (kw_only=True, slots=True, frozen=True per COP014) with a uniform syntax_tree field, switching plugin discovery to keyword instantiation. Type COP017 scope helpers by behavior (Iterable/Sequence instead of list) and replace object with a TypeVar in the Mutable marker. Co-Authored-By: Claude Fable 5 --- .../checks/async_get_prefix.py | 6 ++++-- .../checks/dataclass_config.py | 6 ++++-- .../checks/final_class.py | 7 ++++--- .../checks/for_loop_one_prefix.py | 7 ++++--- .../checks/function_keyword_only_args.py | 7 ++++--- .../checks/function_verb.py | 7 ++++--- .../checks/immutable_variable.py | 15 ++++++++------- .../checks/mapping_proxy.py | 6 ++++-- .../checks/module_import_stdlib.py | 6 ++++-- .../checks/name_length.py | 7 ++++--- .../checks/scalar_annotation.py | 7 ++++--- .../checks/temp_var.py | 6 ++++-- src/community_of_python_flake8_plugin/mutable.py | 2 +- src/community_of_python_flake8_plugin/plugin.py | 4 ++-- 14 files changed, 55 insertions(+), 38 deletions(-) diff --git a/src/community_of_python_flake8_plugin/checks/async_get_prefix.py b/src/community_of_python_flake8_plugin/checks/async_get_prefix.py index 6045edd..9d422c5 100644 --- a/src/community_of_python_flake8_plugin/checks/async_get_prefix.py +++ b/src/community_of_python_flake8_plugin/checks/async_get_prefix.py @@ -1,5 +1,6 @@ from __future__ import annotations import ast +import dataclasses import typing from community_of_python_flake8_plugin.violation_codes import ViolationCodes @@ -7,9 +8,10 @@ @typing.final +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class AsyncGetPrefixCheck(ast.NodeVisitor): - def __init__(self, syntax_tree: ast.AST) -> None: # noqa: ARG002 - self.violations: list[Violation] = [] + syntax_tree: ast.AST + violations: list[Violation] = dataclasses.field(default_factory=list) def visit_AsyncFunctionDef(self, ast_node: ast.AsyncFunctionDef) -> None: # Always flag async functions with get_ prefix diff --git a/src/community_of_python_flake8_plugin/checks/dataclass_config.py b/src/community_of_python_flake8_plugin/checks/dataclass_config.py index 3570f94..10b159c 100644 --- a/src/community_of_python_flake8_plugin/checks/dataclass_config.py +++ b/src/community_of_python_flake8_plugin/checks/dataclass_config.py @@ -1,5 +1,6 @@ from __future__ import annotations import ast +import dataclasses import typing 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: @typing.final +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class DataclassConfigCheck(ast.NodeVisitor): - def __init__(self, syntax_tree: ast.AST) -> None: # noqa: ARG002 - self.violations: list[Violation] = [] + syntax_tree: ast.AST + violations: list[Violation] = dataclasses.field(default_factory=list) def visit_ClassDef(self, ast_node: ast.ClassDef) -> None: # Skip whitelisted classes and classes that inherit from Exception or other special classes diff --git a/src/community_of_python_flake8_plugin/checks/final_class.py b/src/community_of_python_flake8_plugin/checks/final_class.py index 5842e9b..128eab7 100644 --- a/src/community_of_python_flake8_plugin/checks/final_class.py +++ b/src/community_of_python_flake8_plugin/checks/final_class.py @@ -1,5 +1,6 @@ from __future__ import annotations import ast +import dataclasses import typing 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 @typing.final +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class FinalClassCheck(ast.NodeVisitor): - def __init__(self, syntax_tree: ast.AST) -> None: - self.syntax_tree = syntax_tree - self.violations: list[Violation] = [] + syntax_tree: ast.AST + violations: list[Violation] = dataclasses.field(default_factory=list) def visit_ClassDef(self, ast_node: ast.ClassDef) -> None: self._check_final_decorator(ast_node) diff --git a/src/community_of_python_flake8_plugin/checks/for_loop_one_prefix.py b/src/community_of_python_flake8_plugin/checks/for_loop_one_prefix.py index 91ce668..2f7ce07 100644 --- a/src/community_of_python_flake8_plugin/checks/for_loop_one_prefix.py +++ b/src/community_of_python_flake8_plugin/checks/for_loop_one_prefix.py @@ -1,5 +1,6 @@ from __future__ import annotations import ast +import dataclasses import typing from community_of_python_flake8_plugin.violation_codes import ViolationCodes @@ -13,10 +14,10 @@ def _is_ignored_target(target_node: ast.expr) -> bool: @typing.final +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class COP015ForLoopOnePrefixCheck(ast.NodeVisitor): - def __init__(self, syntax_tree: ast.AST) -> None: - self.violations: list[Violation] = [] - self.syntax_tree: typing.Final[ast.AST] = syntax_tree + syntax_tree: ast.AST + violations: list[Violation] = dataclasses.field(default_factory=list) def visit_ListComp(self, ast_node: ast.ListComp) -> None: # Validate targets in generators (the 'v' in 'for v in lst') diff --git a/src/community_of_python_flake8_plugin/checks/function_keyword_only_args.py b/src/community_of_python_flake8_plugin/checks/function_keyword_only_args.py index 3bf7ec5..5818374 100644 --- a/src/community_of_python_flake8_plugin/checks/function_keyword_only_args.py +++ b/src/community_of_python_flake8_plugin/checks/function_keyword_only_args.py @@ -1,5 +1,6 @@ from __future__ import annotations import ast +import dataclasses import typing 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 @typing.final +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class COP016FunctionKeywordOnlyArgsCheck(ast.NodeVisitor): - def __init__(self, syntax_tree: ast.AST) -> None: - self.violations: list[Violation] = [] - self.syntax_tree: typing.Final[ast.AST] = syntax_tree + syntax_tree: ast.AST + violations: list[Violation] = dataclasses.field(default_factory=list) def visit_FunctionDef(self, ast_node: ast.FunctionDef) -> None: self.validate_function_definition(ast_node) diff --git a/src/community_of_python_flake8_plugin/checks/function_verb.py b/src/community_of_python_flake8_plugin/checks/function_verb.py index 3e35442..ef5d125 100644 --- a/src/community_of_python_flake8_plugin/checks/function_verb.py +++ b/src/community_of_python_flake8_plugin/checks/function_verb.py @@ -1,5 +1,6 @@ from __future__ import annotations import ast +import dataclasses import typing 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: @typing.final +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class FunctionVerbCheck(ast.NodeVisitor): - def __init__(self, syntax_tree: ast.AST) -> None: - self.violations: list[Violation] = [] - self.syntax_tree: typing.Final[ast.AST] = syntax_tree + syntax_tree: ast.AST + violations: list[Violation] = dataclasses.field(default_factory=list) def visit_FunctionDef(self, ast_node: ast.FunctionDef) -> None: self.validate_function_name(ast_node, find_parent_class_definition(self.syntax_tree, ast_node)) diff --git a/src/community_of_python_flake8_plugin/checks/immutable_variable.py b/src/community_of_python_flake8_plugin/checks/immutable_variable.py index 488098b..099a901 100644 --- a/src/community_of_python_flake8_plugin/checks/immutable_variable.py +++ b/src/community_of_python_flake8_plugin/checks/immutable_variable.py @@ -1,5 +1,6 @@ from __future__ import annotations import ast +import dataclasses import typing from community_of_python_flake8_plugin.violation_codes import ViolationCodes @@ -7,7 +8,7 @@ if typing.TYPE_CHECKING: - from collections.abc import Iterable + from collections.abc import Iterable, Sequence MUTABLE_ANNOTATION_NAME: typing.Final = "Mutable" @@ -34,7 +35,7 @@ def extract_assigned_names(target_node: ast.expr) -> Iterable[str]: yield from extract_assigned_names(target_node.value) -def iter_scope_child_nodes(scope_body: list[ast.stmt]) -> Iterable[ast.AST]: +def iter_scope_child_nodes(scope_body: Iterable[ast.stmt]) -> Iterable[ast.AST]: for one_statement in scope_body: yield from iter_same_scope_nodes(one_statement) @@ -48,10 +49,10 @@ def iter_same_scope_nodes(ast_node: ast.AST) -> Iterable[ast.AST]: @typing.final +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class COP017ImmutableVariableCheck(ast.NodeVisitor): - def __init__(self, syntax_tree: ast.AST) -> None: - self.violations: list[Violation] = [] - self.syntax_tree: typing.Final[ast.AST] = syntax_tree + syntax_tree: ast.AST + violations: list[Violation] = dataclasses.field(default_factory=list) def visit_Module(self, ast_node: ast.Module) -> None: self.validate_scope(ast_node.body) @@ -69,7 +70,7 @@ def visit_ClassDef(self, ast_node: ast.ClassDef) -> None: self.validate_scope(ast_node.body) self.generic_visit(ast_node) - def validate_scope(self, scope_body: list[ast.stmt]) -> None: + def validate_scope(self, scope_body: Sequence[ast.stmt]) -> None: skipped_names: typing.Final = self.collect_outer_scope_names(scope_body) mutable_flag_by_name: typing.Final[dict[str, bool]] = {} for one_scope_node in iter_scope_child_nodes(scope_body): @@ -82,7 +83,7 @@ def validate_scope(self, scope_body: list[ast.stmt]) -> None: one_scope_node, skipped_names=skipped_names, mutable_flag_by_name=mutable_flag_by_name ) - def collect_outer_scope_names(self, scope_body: list[ast.stmt]) -> set[str]: + def collect_outer_scope_names(self, scope_body: Iterable[ast.stmt]) -> set[str]: outer_scope_names: typing.Final[set[str]] = set() for one_scope_node in iter_scope_child_nodes(scope_body): if isinstance(one_scope_node, (ast.Global, ast.Nonlocal)): diff --git a/src/community_of_python_flake8_plugin/checks/mapping_proxy.py b/src/community_of_python_flake8_plugin/checks/mapping_proxy.py index df75da2..d4d4acf 100644 --- a/src/community_of_python_flake8_plugin/checks/mapping_proxy.py +++ b/src/community_of_python_flake8_plugin/checks/mapping_proxy.py @@ -1,5 +1,6 @@ from __future__ import annotations import ast +import dataclasses import typing from community_of_python_flake8_plugin.constants import MAPPING_PROXY_TYPES @@ -60,9 +61,10 @@ def _get_assignment_targets(ast_node: ast.Assign | ast.AnnAssign) -> list[ast.ex @typing.final +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class MappingProxyCheck(ast.NodeVisitor): - def __init__(self, syntax_tree: ast.AST) -> None: # noqa: ARG002 - self.violations: list[Violation] = [] + syntax_tree: ast.AST + violations: list[Violation] = dataclasses.field(default_factory=list) def visit_Module(self, ast_node: ast.Module) -> None: for one_statement in ast_node.body: diff --git a/src/community_of_python_flake8_plugin/checks/module_import_stdlib.py b/src/community_of_python_flake8_plugin/checks/module_import_stdlib.py index 0b0e849..bf667aa 100644 --- a/src/community_of_python_flake8_plugin/checks/module_import_stdlib.py +++ b/src/community_of_python_flake8_plugin/checks/module_import_stdlib.py @@ -1,5 +1,6 @@ from __future__ import annotations import ast +import dataclasses import sys import typing from importlib import util as importlib_util @@ -21,9 +22,10 @@ def check_is_stdlib_package(module_name: str) -> bool: @typing.final +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class COP002StdlibImportCheck(ast.NodeVisitor): - def __init__(self, syntax_tree: ast.AST) -> None: # noqa: ARG002 - self.violations: list[Violation] = [] + syntax_tree: ast.AST + violations: list[Violation] = dataclasses.field(default_factory=list) def visit_ImportFrom(self, ast_node: ast.ImportFrom) -> None: if ast_node.module and ast_node.level == 0 and ast_node.module not in ALLOWED_STDLIB_FROM_IMPORTS: diff --git a/src/community_of_python_flake8_plugin/checks/name_length.py b/src/community_of_python_flake8_plugin/checks/name_length.py index efa97d0..f1746b1 100644 --- a/src/community_of_python_flake8_plugin/checks/name_length.py +++ b/src/community_of_python_flake8_plugin/checks/name_length.py @@ -1,5 +1,6 @@ from __future__ import annotations import ast +import dataclasses import typing 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: @typing.final +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class COP004NameLengthCheck(ast.NodeVisitor): - def __init__(self, tree: ast.AST) -> None: # noqa: COP006 - self.violations: list[Violation] = [] - self.syntax_tree: typing.Final[ast.AST] = tree + syntax_tree: ast.AST + violations: list[Violation] = dataclasses.field(default_factory=list) def visit_AnnAssign(self, ast_node: ast.AnnAssign) -> None: if isinstance(ast_node.target, ast.Name): diff --git a/src/community_of_python_flake8_plugin/checks/scalar_annotation.py b/src/community_of_python_flake8_plugin/checks/scalar_annotation.py index 480d2cb..43d94f8 100644 --- a/src/community_of_python_flake8_plugin/checks/scalar_annotation.py +++ b/src/community_of_python_flake8_plugin/checks/scalar_annotation.py @@ -1,5 +1,6 @@ from __future__ import annotations import ast +import dataclasses import typing from community_of_python_flake8_plugin.constants import SCALAR_ANNOTATIONS @@ -37,10 +38,10 @@ def check_is_scalar_annotation(annotation_node: ast.AST) -> bool: @typing.final +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class ScalarAnnotationCheck(ast.NodeVisitor): - def __init__(self, syntax_tree: ast.AST) -> None: # noqa: COP006 - self.violations: list[Violation] = [] - self.syntax_tree: typing.Final[ast.AST] = syntax_tree + syntax_tree: ast.AST + violations: list[Violation] = dataclasses.field(default_factory=list) def visit_AnnAssign(self, ast_node: ast.AnnAssign) -> None: if isinstance(ast_node.target, ast.Name) and ( diff --git a/src/community_of_python_flake8_plugin/checks/temp_var.py b/src/community_of_python_flake8_plugin/checks/temp_var.py index 5551936..8125aea 100644 --- a/src/community_of_python_flake8_plugin/checks/temp_var.py +++ b/src/community_of_python_flake8_plugin/checks/temp_var.py @@ -1,5 +1,6 @@ from __future__ import annotations import ast +import dataclasses import typing from collections import defaultdict @@ -93,9 +94,10 @@ def is_used_in_next_line(assign_node: ast.Assign | ast.AnnAssign, usage_nodes: l @typing.final +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) class TempVarCheck(ast.NodeVisitor): - def __init__(self, syntax_tree: ast.AST) -> None: # noqa: ARG002 - self.violations: list[Violation] = [] + syntax_tree: ast.AST + violations: list[Violation] = dataclasses.field(default_factory=list) def visit_FunctionDef(self, ast_node: ast.FunctionDef) -> None: self._check_temporary_variables(ast_node) diff --git a/src/community_of_python_flake8_plugin/mutable.py b/src/community_of_python_flake8_plugin/mutable.py index ed18dab..eaf57dd 100644 --- a/src/community_of_python_flake8_plugin/mutable.py +++ b/src/community_of_python_flake8_plugin/mutable.py @@ -11,7 +11,7 @@ @typing.final class _MutableMarker: - def __getitem__(self, item_value: object) -> object: + def __getitem__(self, item_value: _ValueType) -> _ValueType: return item_value Mutable = _MutableMarker() # noqa: COP005,COP017 diff --git a/src/community_of_python_flake8_plugin/plugin.py b/src/community_of_python_flake8_plugin/plugin.py index 2d2c0b6..ec366bc 100644 --- a/src/community_of_python_flake8_plugin/plugin.py +++ b/src/community_of_python_flake8_plugin/plugin.py @@ -18,7 +18,7 @@ class PluginCheckProtocol(typing.Protocol): violations: list[Violation] - def __init__(self, tree: ast.AST) -> None: ... # noqa: COP006 + def __init__(self, *, syntax_tree: ast.AST) -> None: ... def visit(self, node: ast.AST) -> None: ... # noqa: COP007,COP006,COP012 @@ -48,7 +48,7 @@ def _collect_checks(self) -> list[PluginCheckProtocol]: for one_attribute_name in dir(imported_module): attribute = getattr(imported_module, one_attribute_name) if isinstance(attribute, type) and one_attribute_name.endswith("Check") and hasattr(attribute, "visit"): - check_instance = attribute(self.ast_syntax_tree) + check_instance = attribute(syntax_tree=self.ast_syntax_tree) check_instance.visit(self.ast_syntax_tree) checks_collection.append(check_instance) return checks_collection From cc118a096cec540bd90ac1cbc9697b0f8a732b14 Mon Sep 17 00:00:00 2001 From: Anton Gavrilov Date: Thu, 9 Jul 2026 23:58:17 +0300 Subject: [PATCH 5/5] Move Mutable marker to a separate cop_extensions package The marker now lives in its own top-level package shipped in the same wheel (uv_build multi-module), so production code imports cop_extensions.Mutable without referencing the linter package. The violation message and docs point to the new import path. Co-Authored-By: Claude Fable 5 --- README.md | 6 +++--- pyproject.toml | 3 +++ src/community_of_python_flake8_plugin/__init__.py | 3 +-- src/community_of_python_flake8_plugin/violation_codes.py | 2 +- .../mutable.py => cop_extensions/__init__.py} | 2 ++ src/cop_extensions/py.typed | 0 tests/test_plugin.py | 6 +++--- 7 files changed, 13 insertions(+), 9 deletions(-) rename src/{community_of_python_flake8_plugin/mutable.py => cop_extensions/__init__.py} (95%) create mode 100644 src/cop_extensions/py.typed diff --git a/README.md b/README.md index b0531e2..5e0c8cd 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ This plugin implements the following code style checks: 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: ```python -from community_of_python_flake8_plugin import Mutable +from cop_extensions import Mutable counter_value: Mutable[int] = 0 counter_value = compute_next(counter_value) # OK @@ -38,7 +38,7 @@ total_value = 0 total_value = compute_next(total_value) # COP017 ``` -The package 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. +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. ## Installation @@ -54,7 +54,7 @@ Or install via pip: pip install "community-of-python-flake8-plugin[flake8]" ``` -To use the `Mutable` marker in production code, add the package without the extra — it has no runtime dependencies: +To use the `cop_extensions.Mutable` marker in production code, add the package without the extra — it has no runtime dependencies: ```bash uv add community-of-python-flake8-plugin diff --git a/pyproject.toml b/pyproject.toml index 520d401..ec544f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,9 @@ dev = [ requires = ["uv_build"] build-backend = "uv_build" +[tool.uv.build-backend] +module-name = ["community_of_python_flake8_plugin", "cop_extensions"] + [tool.ruff] fix = true unsafe-fixes = true diff --git a/src/community_of_python_flake8_plugin/__init__.py b/src/community_of_python_flake8_plugin/__init__.py index aa9679a..b485b55 100644 --- a/src/community_of_python_flake8_plugin/__init__.py +++ b/src/community_of_python_flake8_plugin/__init__.py @@ -1,5 +1,4 @@ -from community_of_python_flake8_plugin.mutable import Mutable from community_of_python_flake8_plugin.plugin import CommunityOfPythonFlake8Plugin -__all__ = ["CommunityOfPythonFlake8Plugin", "Mutable"] +__all__ = ["CommunityOfPythonFlake8Plugin"] diff --git a/src/community_of_python_flake8_plugin/violation_codes.py b/src/community_of_python_flake8_plugin/violation_codes.py index 556446a..1705290 100644 --- a/src/community_of_python_flake8_plugin/violation_codes.py +++ b/src/community_of_python_flake8_plugin/violation_codes.py @@ -65,6 +65,6 @@ class ViolationCodes: code="COP017", description=( "Avoid reassigning variables. If reassignment is intended, annotate the first binding: " - "`from community_of_python_flake8_plugin import Mutable` and `name: Mutable[T] = ...`" + "`from cop_extensions import Mutable` and `name: Mutable[T] = ...`" ), ) diff --git a/src/community_of_python_flake8_plugin/mutable.py b/src/cop_extensions/__init__.py similarity index 95% rename from src/community_of_python_flake8_plugin/mutable.py rename to src/cop_extensions/__init__.py index eaf57dd..8985722 100644 --- a/src/community_of_python_flake8_plugin/mutable.py +++ b/src/cop_extensions/__init__.py @@ -2,6 +2,8 @@ import typing +__all__ = ["Mutable"] + _ValueType = typing.TypeVar("_ValueType") if typing.TYPE_CHECKING: diff --git a/src/cop_extensions/py.typed b/src/cop_extensions/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_plugin.py b/tests/test_plugin.py index a92cfb6..0b4269b 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -3,7 +3,7 @@ import pytest -import community_of_python_flake8_plugin +import cop_extensions from community_of_python_flake8_plugin.plugin import CommunityOfPythonFlake8Plugin @@ -873,5 +873,5 @@ def test_immutable_variable_validations(input_source: str, expected_output: list def test_mutable_marker_runtime() -> None: - assert community_of_python_flake8_plugin.Mutable is not None - assert community_of_python_flake8_plugin.Mutable[int] is int + assert cop_extensions.Mutable is not None + assert cop_extensions.Mutable[int] is int