grind-t-lint-rules 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: grind-t-lint-rules
3
+ Version: 0.1.0
4
+ Summary: Custom lint rules for Python projects
5
+ Author: grind-t
6
+ Author-email: grind-t <grindt.gd@gmail.com>
7
+ License-Expression: ISC
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Software Development :: Quality Assurance
10
+ Requires-Python: >=3.14
11
+ Project-URL: Homepage, https://github.com/grind-t/lint-rules-py
12
+ Project-URL: Issues, https://github.com/grind-t/lint-rules-py/issues
@@ -0,0 +1,130 @@
1
+ [project]
2
+ name = "grind-t-lint-rules"
3
+ version = "0.1.0"
4
+ description = "Custom lint rules for Python projects"
5
+ license = "ISC"
6
+ requires-python = ">=3.14"
7
+ classifiers = [
8
+ "Programming Language :: Python :: 3",
9
+ "Topic :: Software Development :: Quality Assurance",
10
+ ]
11
+ dependencies = []
12
+
13
+ [[project.authors]]
14
+ name = "grind-t"
15
+ email = "grindt.gd@gmail.com"
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/grind-t/lint-rules-py"
19
+ Issues = "https://github.com/grind-t/lint-rules-py/issues"
20
+
21
+ [project.scripts]
22
+ lint-rules = "lint_rules._wiring.cli:main"
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.12.9,<0.13.0"]
26
+ build-backend = "uv_build"
27
+
28
+ [tool.uv.build-backend]
29
+ module-name = "lint_rules"
30
+
31
+ [tool.ruff.lint]
32
+ select = [
33
+ "E",
34
+ "F",
35
+ "I",
36
+ "UP",
37
+ "B",
38
+ "SIM",
39
+ "RUF",
40
+ "PT",
41
+ "N",
42
+ "C4",
43
+ "PIE",
44
+ "PERF",
45
+ "FURB",
46
+ "PTH",
47
+ "T20",
48
+ "ANN",
49
+ "ASYNC",
50
+ "DTZ",
51
+ ]
52
+ ignore = ["RUF001"]
53
+
54
+ [tool.ruff.lint.per-file-ignores]
55
+ "tests/**" = ["ANN"]
56
+ "benchmarks/**" = ["ANN"]
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
60
+ addopts = [
61
+ "--import-mode=importlib",
62
+ "--strict-markers",
63
+ "--strict-config",
64
+ "-ra",
65
+ ]
66
+ xfail_strict = true
67
+ filterwarnings = ["error"]
68
+
69
+ [tool.poe.tasks]
70
+ fmt = "ruff format"
71
+ typecheck = "ty check"
72
+ test-unit = "pytest tests/core tests/usecases"
73
+ test-integration = "pytest tests --ignore=tests/core --ignore=tests/usecases"
74
+ test = "pytest"
75
+ bench = "pytest benchmarks --codspeed"
76
+ build = "uv build"
77
+ check = [
78
+ "lint",
79
+ "typecheck",
80
+ "test-unit",
81
+ ]
82
+
83
+ [[tool.poe.tasks.lint]]
84
+ cmd = "ruff check"
85
+
86
+ [[tool.poe.tasks.lint]]
87
+ cmd = "lint-imports"
88
+
89
+ [[tool.poe.tasks.lint]]
90
+ cmd = "lint-rules src"
91
+
92
+ [tool.importlinter]
93
+ root_package = "lint_rules"
94
+ include_external_packages = true
95
+
96
+ [[tool.importlinter.contracts]]
97
+ name = "Layers: dependencies point inward only"
98
+ type = "layers"
99
+ layers = [
100
+ "lint_rules._wiring",
101
+ "lint_rules._adapters | lint_rules._usecases",
102
+ "lint_rules._ports",
103
+ "lint_rules._core",
104
+ ]
105
+
106
+ [[tool.importlinter.contracts]]
107
+ name = "Core, ports and use cases do no I/O"
108
+ type = "forbidden"
109
+ source_modules = [
110
+ "lint_rules._core",
111
+ "lint_rules._ports",
112
+ "lint_rules._usecases",
113
+ ]
114
+ forbidden_modules = [
115
+ "os",
116
+ "io",
117
+ "pathlib",
118
+ "socket",
119
+ "subprocess",
120
+ ]
121
+
122
+ [dependency-groups]
123
+ dev = [
124
+ "import-linter",
125
+ "poethepoet",
126
+ "pytest",
127
+ "pytest-codspeed",
128
+ "ruff",
129
+ "ty",
130
+ ]
@@ -0,0 +1,92 @@
1
+ [project]
2
+ name = "grind-t-lint-rules"
3
+ version = "0.1.0"
4
+ description = "Custom lint rules for Python projects"
5
+ license = "ISC"
6
+ authors = [
7
+ { name = "grind-t", email = "grindt.gd@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "Topic :: Software Development :: Quality Assurance",
13
+ ]
14
+ dependencies = []
15
+
16
+ [project.urls]
17
+ Homepage = "https://github.com/grind-t/lint-rules-py"
18
+ Issues = "https://github.com/grind-t/lint-rules-py/issues"
19
+
20
+ [project.scripts]
21
+ lint-rules = "lint_rules._wiring.cli:main"
22
+
23
+ [build-system]
24
+ requires = ["uv_build>=0.12.9,<0.13.0"]
25
+ build-backend = "uv_build"
26
+
27
+ [tool.uv.build-backend]
28
+ module-name = "lint_rules"
29
+
30
+ [tool.ruff.lint]
31
+ select = [
32
+ "E", "F", "I", "UP", "B", "SIM", "RUF", "PT", "N",
33
+ "C4", "PIE", "PERF", "FURB", "PTH", "T20", "ANN", "ASYNC", "DTZ",
34
+ ]
35
+ ignore = ["RUF001"] # allow Cyrillic in strings
36
+
37
+ [tool.ruff.lint.per-file-ignores]
38
+ "tests/**" = ["ANN"]
39
+ "benchmarks/**" = ["ANN"]
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
43
+ addopts = ["--import-mode=importlib", "--strict-markers", "--strict-config", "-ra"]
44
+ xfail_strict = true
45
+ filterwarnings = ["error"]
46
+
47
+ [tool.poe.tasks]
48
+ fmt = "ruff format"
49
+ lint = [
50
+ { cmd = "ruff check" },
51
+ { cmd = "lint-imports" },
52
+ { cmd = "lint-rules src" },
53
+ ]
54
+ typecheck = "ty check"
55
+ test-unit = "pytest tests/core tests/usecases"
56
+ test-integration = "pytest tests --ignore=tests/core --ignore=tests/usecases"
57
+ test = "pytest"
58
+ bench = "pytest benchmarks --codspeed"
59
+ build = "uv build"
60
+ check = ["lint", "typecheck", "test-unit"]
61
+
62
+ [dependency-groups]
63
+ dev = [
64
+ "import-linter",
65
+ "poethepoet",
66
+ "pytest",
67
+ "pytest-codspeed",
68
+ "ruff",
69
+ "ty",
70
+ ]
71
+
72
+ # ---------- Architecture rules enforced by lint-imports ----------
73
+ [tool.importlinter]
74
+ root_package = "lint_rules"
75
+ include_external_packages = true
76
+
77
+ [[tool.importlinter.contracts]]
78
+ name = "Layers: dependencies point inward only"
79
+ type = "layers"
80
+ layers = [
81
+ "lint_rules._wiring",
82
+ "lint_rules._adapters | lint_rules._usecases",
83
+ "lint_rules._ports",
84
+ "lint_rules._core",
85
+ ]
86
+
87
+ [[tool.importlinter.contracts]]
88
+ name = "Core, ports and use cases do no I/O"
89
+ type = "forbidden"
90
+ source_modules = ["lint_rules._core", "lint_rules._ports", "lint_rules._usecases"]
91
+ # also add adapters' third-party dependencies here (httpx, etc.)
92
+ forbidden_modules = ["os", "io", "pathlib", "socket", "subprocess"]
@@ -0,0 +1,4 @@
1
+ from lint_rules._core.violation import Violation
2
+ from lint_rules._wiring.cli import main
3
+
4
+ __all__ = ["Violation", "main"]
@@ -0,0 +1,9 @@
1
+ from collections.abc import Iterable, Iterator
2
+ from pathlib import Path
3
+
4
+
5
+ def read_fs_source_files(roots: Iterable[str]) -> Iterator[tuple[str, str]]:
6
+ """Python files from the local filesystem."""
7
+ for root in roots:
8
+ for path in sorted(Path(root).rglob("*.py")):
9
+ yield str(path), path.read_text(encoding="utf-8")
@@ -0,0 +1,12 @@
1
+ import ast
2
+
3
+
4
+ def base_name(base: ast.expr) -> str:
5
+ """Last name component of a base class expression, e.g. ``Protocol[T]``."""
6
+ if isinstance(base, ast.Subscript):
7
+ base = base.value
8
+ if isinstance(base, ast.Name):
9
+ return base.id
10
+ if isinstance(base, ast.Attribute):
11
+ return base.attr
12
+ return ""
@@ -0,0 +1,27 @@
1
+ import ast
2
+
3
+ from lint_rules._core.bases import base_name
4
+
5
+ ENUM_BASES = {"Enum", "IntEnum", "StrEnum", "Flag", "IntFlag"}
6
+ EXCEPTION_SUFFIXES = ("Error", "Exception", "Warning")
7
+
8
+
9
+ def _is_exempt(cls: ast.ClassDef, exempt: set[str]) -> bool:
10
+ names = [base_name(base) for base in cls.bases]
11
+ return any(
12
+ n in ENUM_BASES or n in exempt or n.endswith(EXCEPTION_SUFFIXES) for n in names
13
+ )
14
+
15
+
16
+ def public_classes(tree: ast.Module) -> list[str]:
17
+ """Public top-level classes of a module, excluding exceptions and enums."""
18
+ exempt: set[str] = set()
19
+ found: list[str] = []
20
+ for node in tree.body:
21
+ if not isinstance(node, ast.ClassDef):
22
+ continue
23
+ if _is_exempt(node, exempt):
24
+ exempt.add(node.name)
25
+ elif not node.name.startswith("_"):
26
+ found.append(node.name)
27
+ return found
@@ -0,0 +1,67 @@
1
+ import ast
2
+ from collections.abc import Iterator
3
+
4
+ from lint_rules._core.bases import base_name
5
+
6
+ FUNCTION_TYPES = (ast.FunctionDef, ast.AsyncFunctionDef)
7
+ BLOCK_FIELDS = ("body", "orelse", "finalbody", "handlers", "cases")
8
+ BLOCK_TYPES = (ast.stmt, ast.excepthandler, ast.match_case)
9
+
10
+
11
+ def _statements(tree: ast.Module) -> Iterator[ast.AST]:
12
+ """Every statement block of a module, without descending into expressions.
13
+
14
+ Classes can only appear among statements, so this finds the same classes
15
+ as ``ast.walk`` while skipping the bulk of the tree.
16
+ """
17
+ stack: list[ast.AST] = [tree]
18
+ while stack:
19
+ node = stack.pop()
20
+ yield node
21
+ for field in BLOCK_FIELDS:
22
+ block = getattr(node, field, None)
23
+ if isinstance(block, list):
24
+ stack.extend(child for child in block if isinstance(child, BLOCK_TYPES))
25
+
26
+
27
+ def _is_filler(stmt: ast.stmt) -> bool:
28
+ """Docstrings, ``...`` and ``pass``: statements that add nothing to a class."""
29
+ if isinstance(stmt, ast.Pass):
30
+ return True
31
+ return isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant)
32
+
33
+
34
+ def _single_method(cls: ast.ClassDef) -> str | None:
35
+ """Name of the only method of a stateless class."""
36
+ if cls.decorator_list or cls.keywords:
37
+ return None
38
+ bases = {base_name(base) for base in cls.bases} - {"object"}
39
+ if bases - {"Protocol"}:
40
+ return None
41
+ methods: list[str] = []
42
+ for stmt in cls.body:
43
+ if isinstance(stmt, FUNCTION_TYPES):
44
+ methods.append(stmt.name)
45
+ elif not _is_filler(stmt):
46
+ return None
47
+ if len(methods) != 1 or methods == ["__init__"]:
48
+ return None
49
+ if "Protocol" in bases and methods == ["__call__"]:
50
+ return None
51
+ return methods[0]
52
+
53
+
54
+ def single_method_classes(tree: ast.Module) -> list[tuple[str, str, int]]:
55
+ """``(class, method, line)`` for every class that should be a function.
56
+
57
+ A class qualifies when its body is one method other than ``__init__`` and
58
+ nothing else: no decorators, no base classes other than ``Protocol``,
59
+ no class attributes. A ``Protocol`` whose only method is ``__call__`` is
60
+ the recommended replacement and is not reported.
61
+ """
62
+ found = [
63
+ (node.name, method, node.lineno)
64
+ for node in _statements(tree)
65
+ if isinstance(node, ast.ClassDef) and (method := _single_method(node))
66
+ ]
67
+ return sorted(found, key=lambda item: item[2])
@@ -0,0 +1,10 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class Violation:
6
+ path: str
7
+ message: str
8
+
9
+ def __str__(self) -> str:
10
+ return f"{self.path}: {self.message}"
@@ -0,0 +1,8 @@
1
+ from collections.abc import Iterable
2
+ from typing import Protocol
3
+
4
+
5
+ class ReadSourceFiles(Protocol):
6
+ def __call__(self, roots: Iterable[str]) -> Iterable[tuple[str, str]]:
7
+ """Yield ``(path, source)`` for every Python file under ``roots``."""
8
+ ...
@@ -0,0 +1,19 @@
1
+ import ast
2
+
3
+ from lint_rules._core.public_classes import public_classes
4
+ from lint_rules._core.violation import Violation
5
+
6
+ MARKER = "# allow-multiple-public-classes"
7
+
8
+
9
+ def check_public_classes(path: str, source: str, tree: ast.Module) -> list[Violation]:
10
+ if MARKER in source:
11
+ return []
12
+ classes = public_classes(tree)
13
+ if len(classes) <= 1:
14
+ return []
15
+ message = (
16
+ f"{len(classes)} public classes ({', '.join(classes)}); "
17
+ "split them into separate modules"
18
+ )
19
+ return [Violation(path, message)]
@@ -0,0 +1,21 @@
1
+ import ast
2
+
3
+ from lint_rules._core.single_method_classes import single_method_classes
4
+ from lint_rules._core.violation import Violation
5
+
6
+ MARKER = "# allow-single-method-classes"
7
+
8
+
9
+ def check_single_method_classes(
10
+ path: str, source: str, tree: ast.Module
11
+ ) -> list[Violation]:
12
+ if MARKER in source:
13
+ return []
14
+ return [
15
+ Violation(
16
+ path,
17
+ f"line {line}: class {cls} has a single method {method}; "
18
+ "use a function, a Callable alias or a Protocol with __call__",
19
+ )
20
+ for cls, method, line in single_method_classes(tree)
21
+ ]
@@ -0,0 +1,25 @@
1
+ import ast
2
+ from collections.abc import Callable, Iterable
3
+
4
+ from lint_rules._core.violation import Violation
5
+ from lint_rules._ports.source_files import ReadSourceFiles
6
+ from lint_rules._usecases.check_public_classes import check_public_classes
7
+ from lint_rules._usecases.check_single_method_classes import (
8
+ check_single_method_classes,
9
+ )
10
+
11
+ type Check = Callable[[str, str, ast.Module], list[Violation]]
12
+
13
+ CHECKS: tuple[Check, ...] = (check_public_classes, check_single_method_classes)
14
+
15
+
16
+ def check_source_files(
17
+ read_files: ReadSourceFiles, roots: Iterable[str]
18
+ ) -> list[Violation]:
19
+ """Run every check on each file, parsing it only once."""
20
+ violations = []
21
+ for path, source in read_files(roots):
22
+ tree = ast.parse(source)
23
+ for check in CHECKS:
24
+ violations.extend(check(path, source, tree))
25
+ return violations
@@ -0,0 +1,12 @@
1
+ import sys
2
+
3
+ from lint_rules._adapters.fs_source_files import read_fs_source_files
4
+ from lint_rules._usecases.check_source_files import check_source_files
5
+
6
+
7
+ def main(argv: list[str] | None = None) -> int:
8
+ roots = (sys.argv[1:] if argv is None else argv) or ["src"]
9
+ violations = check_source_files(read_fs_source_files, roots)
10
+ for violation in violations:
11
+ sys.stderr.write(f"{violation}\n")
12
+ return int(bool(violations))