diff --git a/README.md b/README.md index 3c556e6..5e0c8cd 100644 --- a/README.md +++ b/README.md @@ -22,19 +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 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 the `Mutable` annotation on the first binding: + +```python +from cop_extensions import Mutable + +counter_value: Mutable[int] = 0 +counter_value = compute_next(counter_value) # OK + +total_value = 0 +total_value = compute_next(total_value) # COP017 +``` + +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 -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 `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 ``` ## Usage diff --git a/pyproject.toml b/pyproject.toml index d4ee88f..ec544f6 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", @@ -27,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/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 new file mode 100644 index 0000000..099a901 --- /dev/null +++ b/src/community_of_python_flake8_plugin/checks/immutable_variable.py @@ -0,0 +1,130 @@ +from __future__ import annotations +import ast +import dataclasses +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, Sequence + + +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 + 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: Iterable[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 +@dataclasses.dataclass(kw_only=True, slots=True, frozen=True) +class COP017ImmutableVariableCheck(ast.NodeVisitor): + 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) + 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: 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): + if isinstance(one_scope_node, ast.Assign): + 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, mutable_flag_by_name=mutable_flag_by_name + ) + + 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)): + outer_scope_names.update(one_scope_node.names) + return outer_scope_names + + 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 mutable_flag_by_name.get(one_assigned_name) is False: + self.append_violation(ast_node) + else: + mutable_flag_by_name.setdefault(one_assigned_name, False) + + def validate_annotated_assignment( + 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 mutable_flag_by_name.get(assigned_name) is False: + self.append_violation(ast_node) + 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( + 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/checks/mapping_proxy.py b/src/community_of_python_flake8_plugin/checks/mapping_proxy.py index 602cd78..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 @@ -37,38 +38,33 @@ 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 +@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: @@ -86,15 +82,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: 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/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 diff --git a/src/community_of_python_flake8_plugin/violation_codes.py b/src/community_of_python_flake8_plugin/violation_codes.py index 91af7b3..1705290 100644 --- a/src/community_of_python_flake8_plugin/violation_codes.py +++ b/src/community_of_python_flake8_plugin/violation_codes.py @@ -59,3 +59,12 @@ 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. If reassignment is intended, annotate the first binding: " + "`from cop_extensions import Mutable` and `name: Mutable[T] = ...`" + ), + ) diff --git a/src/cop_extensions/__init__.py b/src/cop_extensions/__init__.py new file mode 100644 index 0000000..8985722 --- /dev/null +++ b/src/cop_extensions/__init__.py @@ -0,0 +1,19 @@ +from __future__ import annotations +import typing + + +__all__ = ["Mutable"] + +_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: _ValueType) -> _ValueType: + return item_value + + Mutable = _MutableMarker() # noqa: COP005,COP017 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 dc45ea5..0b4269b 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -3,6 +3,7 @@ import pytest +import cop_extensions from community_of_python_flake8_plugin.plugin import CommunityOfPythonFlake8Plugin @@ -777,3 +778,100 @@ 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 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 + ("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"]), + # 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" + " 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) + + +def test_mutable_marker_runtime() -> None: + assert cop_extensions.Mutable is not None + assert cop_extensions.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" },