Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -15,6 +18,7 @@ addopts = "--cov"

[dependency-groups]
dev = [
"flake8",
"mypy",
"ruff",
"auto-typing-final",
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
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


@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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions src/community_of_python_flake8_plugin/checks/final_class.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations
import ast
import dataclasses
import typing

from community_of_python_flake8_plugin.violation_codes import ViolationCodes
Expand All @@ -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')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations
import ast
import dataclasses
import typing

from community_of_python_flake8_plugin.violation_codes import ViolationCodes
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions src/community_of_python_flake8_plugin/checks/function_verb.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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))
Expand Down
130 changes: 130 additions & 0 deletions src/community_of_python_flake8_plugin/checks/immutable_variable.py
Original file line number Diff line number Diff line change
@@ -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,
)
)
Loading
Loading