anti-slop-python 0.1.0__py3-none-any.whl
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.
Potentially problematic release.
This version of anti-slop-python might be problematic. Click here for more details.
- anti_slop_python/__init__.py +6 -0
- anti_slop_python/__main__.py +3 -0
- anti_slop_python/checker.py +53 -0
- anti_slop_python/cli.py +86 -0
- anti_slop_python/diagnostics.py +18 -0
- anti_slop_python/ruff_integration.py +203 -0
- anti_slop_python/ruff_policy.py +398 -0
- anti_slop_python/rules/__init__.py +12 -0
- anti_slop_python/rules/base.py +86 -0
- anti_slop_python/rules/no_any_containers.py +55 -0
- anti_slop_python/rules/no_dynamic_attribute_access.py +32 -0
- anti_slop_python-0.1.0.dist-info/METADATA +349 -0
- anti_slop_python-0.1.0.dist-info/RECORD +16 -0
- anti_slop_python-0.1.0.dist-info/WHEEL +4 -0
- anti_slop_python-0.1.0.dist-info/entry_points.txt +2 -0
- anti_slop_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import tokenize
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from anti_slop_python.diagnostics import Diagnostic
|
|
8
|
+
from anti_slop_python.rules import RULES
|
|
9
|
+
from anti_slop_python.rules.base import RuleContext
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def check_source(source: str, path: str | Path = "<unknown>") -> list[Diagnostic]:
|
|
13
|
+
"""Check source text and return diagnostics in source order."""
|
|
14
|
+
|
|
15
|
+
source_path = Path(path)
|
|
16
|
+
try:
|
|
17
|
+
tree = ast.parse(source, filename=str(source_path))
|
|
18
|
+
except SyntaxError as error:
|
|
19
|
+
return [_syntax_error(source_path, error)]
|
|
20
|
+
|
|
21
|
+
context = RuleContext(path=source_path, tree=tree)
|
|
22
|
+
diagnostics = [diagnostic for rule in RULES for diagnostic in rule.check(context)]
|
|
23
|
+
return sorted(diagnostics)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def check_file(path: str | Path) -> list[Diagnostic]:
|
|
27
|
+
"""Read and check one Python source file."""
|
|
28
|
+
|
|
29
|
+
source_path = Path(path)
|
|
30
|
+
try:
|
|
31
|
+
with tokenize.open(source_path) as source_file:
|
|
32
|
+
source = source_file.read()
|
|
33
|
+
except (OSError, SyntaxError) as error:
|
|
34
|
+
return [
|
|
35
|
+
Diagnostic(
|
|
36
|
+
path=source_path,
|
|
37
|
+
line=1,
|
|
38
|
+
column=1,
|
|
39
|
+
code="IOError",
|
|
40
|
+
message=str(error),
|
|
41
|
+
)
|
|
42
|
+
]
|
|
43
|
+
return check_source(source, source_path)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _syntax_error(path: Path, error: SyntaxError) -> Diagnostic:
|
|
47
|
+
return Diagnostic(
|
|
48
|
+
path=path,
|
|
49
|
+
line=error.lineno or 1,
|
|
50
|
+
column=error.offset or 1,
|
|
51
|
+
code="SyntaxError",
|
|
52
|
+
message=error.msg,
|
|
53
|
+
)
|
anti_slop_python/cli.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Iterable, Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from anti_slop_python.checker import check_file
|
|
10
|
+
from anti_slop_python.ruff_integration import RuffFailure, check_with_ruff
|
|
11
|
+
|
|
12
|
+
_IGNORED_DIRECTORIES = {
|
|
13
|
+
".git",
|
|
14
|
+
".venv",
|
|
15
|
+
"__pycache__",
|
|
16
|
+
"build",
|
|
17
|
+
"dist",
|
|
18
|
+
"venv",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
23
|
+
parser = _parser()
|
|
24
|
+
arguments = parser.parse_args(argv)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
files = list(_python_files(arguments.paths))
|
|
28
|
+
except ValueError as error:
|
|
29
|
+
parser.error(str(error))
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
ruff_result = check_with_ruff(arguments.paths, files)
|
|
33
|
+
except RuffFailure as error:
|
|
34
|
+
print(f"anti-slop-python: {error}", file=sys.stderr)
|
|
35
|
+
return 2
|
|
36
|
+
|
|
37
|
+
for warning in ruff_result.warnings:
|
|
38
|
+
print(warning, file=sys.stderr)
|
|
39
|
+
for notice in ruff_result.notices:
|
|
40
|
+
print(f"anti-slop-python policy notice: {notice}", file=sys.stderr)
|
|
41
|
+
|
|
42
|
+
diagnostics = [diagnostic for path in files for diagnostic in check_file(path)]
|
|
43
|
+
diagnostics.extend(ruff_result.diagnostics)
|
|
44
|
+
for diagnostic in sorted(diagnostics):
|
|
45
|
+
print(diagnostic)
|
|
46
|
+
return 1 if diagnostics else 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parser() -> argparse.ArgumentParser:
|
|
50
|
+
parser = argparse.ArgumentParser(
|
|
51
|
+
prog="anti-slop-python",
|
|
52
|
+
description="Reject Python patterns that weaken architectural evidence.",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"paths",
|
|
56
|
+
nargs="*",
|
|
57
|
+
type=Path,
|
|
58
|
+
default=[Path(".")],
|
|
59
|
+
help="Python files or directories to check (default: current directory)",
|
|
60
|
+
)
|
|
61
|
+
return parser
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _python_files(paths: Iterable[Path]) -> Iterable[Path]:
|
|
65
|
+
seen: set[Path] = set()
|
|
66
|
+
for path in paths:
|
|
67
|
+
if not path.exists():
|
|
68
|
+
raise ValueError(f"path does not exist: {path}")
|
|
69
|
+
candidates = [path] if path.is_file() else _walk_python_files(path)
|
|
70
|
+
for candidate in candidates:
|
|
71
|
+
if candidate.suffix != ".py":
|
|
72
|
+
continue
|
|
73
|
+
identity = candidate.resolve()
|
|
74
|
+
if identity not in seen:
|
|
75
|
+
seen.add(identity)
|
|
76
|
+
yield candidate
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _walk_python_files(root: Path) -> Iterable[Path]:
|
|
80
|
+
for directory, subdirectories, filenames in os.walk(root):
|
|
81
|
+
subdirectories[:] = sorted(
|
|
82
|
+
name for name in subdirectories if name not in _IGNORED_DIRECTORIES
|
|
83
|
+
)
|
|
84
|
+
for filename in sorted(filenames):
|
|
85
|
+
if filename.endswith(".py"):
|
|
86
|
+
yield Path(directory, filename)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, order=True)
|
|
8
|
+
class Diagnostic:
|
|
9
|
+
"""A source location and the check that failed there."""
|
|
10
|
+
|
|
11
|
+
path: Path
|
|
12
|
+
line: int
|
|
13
|
+
column: int
|
|
14
|
+
code: str
|
|
15
|
+
message: str
|
|
16
|
+
|
|
17
|
+
def __str__(self) -> str:
|
|
18
|
+
return f"{self.path}:{self.line}:{self.column} {self.code} {self.message}"
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from anti_slop_python.diagnostics import Diagnostic
|
|
12
|
+
from anti_slop_python.ruff_policy import (
|
|
13
|
+
_RECOMMENDED_RULES,
|
|
14
|
+
RuffFailure,
|
|
15
|
+
RuffSettings,
|
|
16
|
+
configuration_arguments,
|
|
17
|
+
default_arguments,
|
|
18
|
+
parse_settings,
|
|
19
|
+
policy_notices_for_scopes,
|
|
20
|
+
settings_scopes,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class RuffCheckResult:
|
|
26
|
+
"""Ruff diagnostics and advisory policy output for one invocation."""
|
|
27
|
+
|
|
28
|
+
diagnostics: tuple[Diagnostic, ...]
|
|
29
|
+
notices: tuple[str, ...]
|
|
30
|
+
warnings: tuple[str, ...]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def check_with_ruff(
|
|
34
|
+
_paths: Sequence[Path], python_files: Sequence[Path]
|
|
35
|
+
) -> RuffCheckResult:
|
|
36
|
+
"""Run Ruff with anti-slop-python defaults and project-configured overrides."""
|
|
37
|
+
|
|
38
|
+
diagnostics: list[Diagnostic] = []
|
|
39
|
+
warnings: list[str] = []
|
|
40
|
+
noqa_notices: list[str] = []
|
|
41
|
+
scopes = settings_scopes(python_files)
|
|
42
|
+
effective_settings: list[RuffSettings] = []
|
|
43
|
+
for configuration, files in scopes:
|
|
44
|
+
config_arguments = configuration_arguments(configuration)
|
|
45
|
+
included_files = _included_files(files, config_arguments)
|
|
46
|
+
if not included_files:
|
|
47
|
+
continue
|
|
48
|
+
baseline = _resolved_settings(included_files[0], config_arguments)
|
|
49
|
+
arguments = (*config_arguments, *default_arguments(baseline))
|
|
50
|
+
for targets in _path_batches(included_files):
|
|
51
|
+
normal = _ruff_diagnostics(targets, arguments, python_files)
|
|
52
|
+
diagnostics.extend(normal.diagnostics)
|
|
53
|
+
warnings.extend(normal.warnings)
|
|
54
|
+
audit = _ruff_diagnostics(
|
|
55
|
+
targets, ("--ignore-noqa", *arguments), python_files
|
|
56
|
+
)
|
|
57
|
+
noqa_notices.extend(_noqa_notices(normal.diagnostics, audit.diagnostics))
|
|
58
|
+
effective_settings.append(_resolved_settings(included_files[0], arguments))
|
|
59
|
+
|
|
60
|
+
notices = (*policy_notices_for_scopes(effective_settings), *noqa_notices)
|
|
61
|
+
return RuffCheckResult(
|
|
62
|
+
tuple(sorted(set(diagnostics))),
|
|
63
|
+
notices,
|
|
64
|
+
tuple(dict.fromkeys(warnings)),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class _RuffDiagnostics:
|
|
70
|
+
diagnostics: tuple[Diagnostic, ...]
|
|
71
|
+
warnings: tuple[str, ...]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _ruff_diagnostics(
|
|
75
|
+
targets: Sequence[Path],
|
|
76
|
+
arguments: Sequence[str],
|
|
77
|
+
python_files: Sequence[Path],
|
|
78
|
+
) -> _RuffDiagnostics:
|
|
79
|
+
completed = _run_ruff(
|
|
80
|
+
"check",
|
|
81
|
+
"--output-format",
|
|
82
|
+
"json",
|
|
83
|
+
"--no-fix",
|
|
84
|
+
"--no-fix-only",
|
|
85
|
+
"--exit-zero",
|
|
86
|
+
"--force-exclude",
|
|
87
|
+
*arguments,
|
|
88
|
+
"--",
|
|
89
|
+
*(str(path) for path in targets),
|
|
90
|
+
)
|
|
91
|
+
return _RuffDiagnostics(
|
|
92
|
+
diagnostics=_parse_diagnostics(completed.stdout, python_files),
|
|
93
|
+
warnings=tuple(line for line in completed.stderr.splitlines() if line),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _noqa_notices(
|
|
98
|
+
normal: Sequence[Diagnostic], audit: Sequence[Diagnostic]
|
|
99
|
+
) -> tuple[str, ...]:
|
|
100
|
+
normal_keys = {(item.path, item.line, item.column, item.code) for item in normal}
|
|
101
|
+
return tuple(
|
|
102
|
+
f"{item.code} is suppressed by noqa at {item.path}:{item.line}; "
|
|
103
|
+
"recommended for checked files"
|
|
104
|
+
for item in audit
|
|
105
|
+
if item.code in _RECOMMENDED_RULES
|
|
106
|
+
and (item.path, item.line, item.column, item.code) not in normal_keys
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _run_ruff(*arguments: str) -> subprocess.CompletedProcess[str]:
|
|
111
|
+
environment = os.environ.copy()
|
|
112
|
+
for name in ("RUFF_FIX", "RUFF_FIX_ONLY", "RUFF_OUTPUT_FILE", "RUFF_OUTPUT_FORMAT"):
|
|
113
|
+
environment.pop(name, None)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
completed = subprocess.run(
|
|
117
|
+
[sys.executable, "-m", "ruff", *arguments],
|
|
118
|
+
capture_output=True,
|
|
119
|
+
check=False,
|
|
120
|
+
env=environment,
|
|
121
|
+
text=True,
|
|
122
|
+
)
|
|
123
|
+
except OSError as error:
|
|
124
|
+
raise RuffFailure(f"Ruff failed: {error}") from error
|
|
125
|
+
|
|
126
|
+
if completed.returncode != 0:
|
|
127
|
+
detail = completed.stderr.strip() or completed.stdout.strip()
|
|
128
|
+
raise RuffFailure(f"Ruff failed: {detail or 'unknown error'}")
|
|
129
|
+
return completed
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_diagnostics(
|
|
133
|
+
output: str, python_files: Sequence[Path]
|
|
134
|
+
) -> tuple[Diagnostic, ...]:
|
|
135
|
+
try:
|
|
136
|
+
items = json.loads(output)
|
|
137
|
+
except json.JSONDecodeError as error:
|
|
138
|
+
raise RuffFailure(f"Ruff returned invalid JSON: {error.msg}") from error
|
|
139
|
+
if not isinstance(items, list):
|
|
140
|
+
raise RuffFailure("Ruff returned invalid JSON: expected a diagnostic list")
|
|
141
|
+
|
|
142
|
+
display_paths = {path.resolve(): path for path in python_files}
|
|
143
|
+
diagnostics: list[Diagnostic] = []
|
|
144
|
+
for item in items:
|
|
145
|
+
if not isinstance(item, dict):
|
|
146
|
+
continue
|
|
147
|
+
filename = Path(str(item.get("filename", "<unknown>")))
|
|
148
|
+
if item.get("code") == "invalid-syntax" and filename.resolve() in display_paths:
|
|
149
|
+
continue
|
|
150
|
+
location = item.get("location")
|
|
151
|
+
if not isinstance(location, dict):
|
|
152
|
+
raise RuffFailure("Ruff returned a diagnostic without a source location")
|
|
153
|
+
path = display_paths.get(filename.resolve(), _display_path(filename))
|
|
154
|
+
diagnostics.append(
|
|
155
|
+
Diagnostic(
|
|
156
|
+
path=path,
|
|
157
|
+
line=int(location["row"]),
|
|
158
|
+
column=int(location["column"]),
|
|
159
|
+
code=str(item.get("code") or "Ruff"),
|
|
160
|
+
message=str(item.get("message") or "Ruff violation"),
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
return tuple(sorted(diagnostics))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _display_path(path: Path) -> Path:
|
|
167
|
+
try:
|
|
168
|
+
return path.relative_to(Path.cwd())
|
|
169
|
+
except ValueError:
|
|
170
|
+
return path
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _included_files(
|
|
174
|
+
files: Sequence[Path], configuration_arguments: Sequence[str]
|
|
175
|
+
) -> tuple[Path, ...]:
|
|
176
|
+
included_paths: set[Path] = set()
|
|
177
|
+
for targets in _path_batches(files):
|
|
178
|
+
completed = _run_ruff(
|
|
179
|
+
"check",
|
|
180
|
+
"--show-files",
|
|
181
|
+
"--force-exclude",
|
|
182
|
+
*configuration_arguments,
|
|
183
|
+
"--",
|
|
184
|
+
*(str(path) for path in targets),
|
|
185
|
+
)
|
|
186
|
+
included_paths.update(
|
|
187
|
+
Path(line).resolve() for line in completed.stdout.splitlines()
|
|
188
|
+
)
|
|
189
|
+
return tuple(path for path in files if path.resolve() in included_paths)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _path_batches(
|
|
193
|
+
files: Sequence[Path], batch_size: int = 500
|
|
194
|
+
) -> tuple[tuple[Path, ...], ...]:
|
|
195
|
+
return tuple(
|
|
196
|
+
tuple(files[index : index + batch_size])
|
|
197
|
+
for index in range(0, len(files), batch_size)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _resolved_settings(file: Path, extra_arguments: Sequence[str] = ()) -> RuffSettings:
|
|
202
|
+
completed = _run_ruff("check", "--show-settings", *extra_arguments, "--", str(file))
|
|
203
|
+
return parse_settings(completed.stdout)
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import tomllib
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
_RECOMMENDED_RULES = ("C901", "PLR0915", "TID251", "E722", "BLE001")
|
|
11
|
+
_RECOMMENDED_BANNED_APIS = {
|
|
12
|
+
"mock.patch": "Avoid patching state. Pass the dependency explicitly instead.",
|
|
13
|
+
"unittest.mock.patch": (
|
|
14
|
+
"Avoid patching state. Pass the dependency explicitly instead."
|
|
15
|
+
),
|
|
16
|
+
}
|
|
17
|
+
_MAX_COMPLEXITY = 10
|
|
18
|
+
_MAX_STATEMENTS = 40
|
|
19
|
+
_RULE_CODE = re.compile(r"\(([A-Z][A-Z0-9]*\d+)\),?$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RuffFailure(RuntimeError):
|
|
23
|
+
"""Raised when Ruff cannot complete a check or expose its settings."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class RuffSettings:
|
|
28
|
+
enabled_rules: frozenset[str]
|
|
29
|
+
max_complexity: int
|
|
30
|
+
max_statements: int
|
|
31
|
+
banned_apis: frozenset[str]
|
|
32
|
+
banned_api_messages: tuple[tuple[str, str], ...]
|
|
33
|
+
per_file_ignores: tuple[tuple[str, frozenset[str]], ...]
|
|
34
|
+
path: Path | None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def settings_scopes(
|
|
38
|
+
files: Sequence[Path],
|
|
39
|
+
) -> tuple[tuple[Path | None, tuple[Path, ...]], ...]:
|
|
40
|
+
scopes: dict[Path | None, list[Path]] = {}
|
|
41
|
+
config_cache: dict[Path, Path | None] = {}
|
|
42
|
+
for file in files:
|
|
43
|
+
scopes.setdefault(ruff_config_for(file, config_cache), []).append(file)
|
|
44
|
+
return tuple(
|
|
45
|
+
(configuration, tuple(scope)) for configuration, scope in scopes.items()
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def configuration_arguments(path: Path | None) -> tuple[str, ...]:
|
|
50
|
+
if path is None:
|
|
51
|
+
return ("--isolated",)
|
|
52
|
+
return ("--config", str(path))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ruff_config_for(file: Path, cache: dict[Path, Path | None]) -> Path | None:
|
|
56
|
+
return ruff_config_for_directory(file.resolve().parent, cache)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def ruff_config_for_directory(
|
|
60
|
+
directory: Path, cache: dict[Path, Path | None]
|
|
61
|
+
) -> Path | None:
|
|
62
|
+
if directory in cache:
|
|
63
|
+
return cache[directory]
|
|
64
|
+
|
|
65
|
+
for filename in (".ruff.toml", "ruff.toml"):
|
|
66
|
+
candidate = directory / filename
|
|
67
|
+
if candidate.is_file():
|
|
68
|
+
cache[directory] = candidate
|
|
69
|
+
return candidate
|
|
70
|
+
|
|
71
|
+
pyproject = directory / "pyproject.toml"
|
|
72
|
+
if pyproject.is_file():
|
|
73
|
+
try:
|
|
74
|
+
configuration = tomllib.loads(pyproject.read_text())
|
|
75
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
76
|
+
cache[directory] = pyproject
|
|
77
|
+
return pyproject
|
|
78
|
+
tool = configuration.get("tool")
|
|
79
|
+
if isinstance(tool, dict) and isinstance(tool.get("ruff"), dict):
|
|
80
|
+
cache[directory] = pyproject
|
|
81
|
+
return pyproject
|
|
82
|
+
parent = directory.parent
|
|
83
|
+
resolved = None if parent == directory else ruff_config_for_directory(parent, cache)
|
|
84
|
+
cache[directory] = resolved
|
|
85
|
+
return resolved
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse_settings(output: str) -> RuffSettings:
|
|
89
|
+
enabled_rules = _rule_codes(
|
|
90
|
+
_setting_block(output, "linter.rules.enabled", "[", "]")
|
|
91
|
+
)
|
|
92
|
+
banned_api_block = _setting_block(
|
|
93
|
+
output, "linter.flake8_tidy_imports.banned_api", "{", "}"
|
|
94
|
+
)
|
|
95
|
+
banned_api_messages = _parse_banned_apis(banned_api_block)
|
|
96
|
+
per_file_block = _setting_block(output, "linter.per_file_ignores", "{", "}")
|
|
97
|
+
raw_path = _optional_setting_value(output, "Settings path:")
|
|
98
|
+
return RuffSettings(
|
|
99
|
+
enabled_rules=enabled_rules,
|
|
100
|
+
max_complexity=int(_setting_value(output, "linter.mccabe.max_complexity")),
|
|
101
|
+
max_statements=int(_setting_value(output, "linter.pylint.max_statements")),
|
|
102
|
+
banned_apis=frozenset(api for api, _ in banned_api_messages),
|
|
103
|
+
banned_api_messages=banned_api_messages,
|
|
104
|
+
per_file_ignores=_parse_per_file_ignores(per_file_block),
|
|
105
|
+
path=Path(_quoted_value(raw_path)) if raw_path is not None else None,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def default_arguments(settings: RuffSettings) -> tuple[str, ...]:
|
|
110
|
+
ignored_selectors = _configured_ignore_selectors(settings.path)
|
|
111
|
+
default_rules = [
|
|
112
|
+
code
|
|
113
|
+
for code in _RECOMMENDED_RULES
|
|
114
|
+
if not any(_selector_matches(code, selector) for selector in ignored_selectors)
|
|
115
|
+
]
|
|
116
|
+
arguments: list[str] = []
|
|
117
|
+
if default_rules:
|
|
118
|
+
arguments.extend(["--extend-select", ",".join(default_rules)])
|
|
119
|
+
if not _configuration_defines_any(
|
|
120
|
+
settings.path,
|
|
121
|
+
(("lint", "mccabe", "max-complexity"), ("mccabe", "max-complexity")),
|
|
122
|
+
):
|
|
123
|
+
arguments.extend(
|
|
124
|
+
["--config", f"lint.mccabe.max-complexity = {_MAX_COMPLEXITY}"]
|
|
125
|
+
)
|
|
126
|
+
if not _configuration_defines_any(
|
|
127
|
+
settings.path,
|
|
128
|
+
(("lint", "pylint", "max-statements"), ("pylint", "max-statements")),
|
|
129
|
+
):
|
|
130
|
+
arguments.extend(
|
|
131
|
+
["--config", f"lint.pylint.max-statements = {_MAX_STATEMENTS}"]
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if not _configuration_defines_any(
|
|
135
|
+
settings.path,
|
|
136
|
+
(
|
|
137
|
+
("lint", "flake8-tidy-imports", "banned-api"),
|
|
138
|
+
("flake8-tidy-imports", "banned-api"),
|
|
139
|
+
),
|
|
140
|
+
):
|
|
141
|
+
entries = ", ".join(
|
|
142
|
+
f"{json.dumps(api)} = {{ msg = {json.dumps(message)} }}"
|
|
143
|
+
for api, message in sorted(_RECOMMENDED_BANNED_APIS.items())
|
|
144
|
+
)
|
|
145
|
+
arguments.extend(
|
|
146
|
+
["--config", f"lint.flake8-tidy-imports.banned-api = {{{entries}}}"]
|
|
147
|
+
)
|
|
148
|
+
return tuple(arguments)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def policy_notices_for_scopes(
|
|
152
|
+
settings: Sequence[RuffSettings],
|
|
153
|
+
) -> tuple[str, ...]:
|
|
154
|
+
multiple_scopes = len(settings) > 1
|
|
155
|
+
notices: list[str] = []
|
|
156
|
+
for resolved in settings:
|
|
157
|
+
suffix = ""
|
|
158
|
+
if multiple_scopes and resolved.path is not None:
|
|
159
|
+
suffix = f" [Ruff settings: {resolved.path}]"
|
|
160
|
+
notices.extend(f"{notice}{suffix}" for notice in _policy_notices(resolved))
|
|
161
|
+
return tuple(notices)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _configured_ignore_selectors(path: Path | None) -> tuple[str, ...]:
|
|
165
|
+
selectors: list[str] = []
|
|
166
|
+
for configuration in _configuration_chain(path):
|
|
167
|
+
replacement = _lint_setting(configuration, "ignore")
|
|
168
|
+
if isinstance(replacement, list):
|
|
169
|
+
selectors = [str(value) for value in replacement]
|
|
170
|
+
additions = _lint_setting(configuration, "extend-ignore")
|
|
171
|
+
if isinstance(additions, list):
|
|
172
|
+
selectors.extend(str(value) for value in additions)
|
|
173
|
+
return tuple(selectors)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _selector_matches(code: str, selector: str) -> bool:
|
|
177
|
+
return selector == "ALL" or code.startswith(selector)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _lint_setting(configuration: dict[str, object], name: str) -> object:
|
|
181
|
+
lint = configuration.get("lint")
|
|
182
|
+
hyphenated = name.replace("_", "-")
|
|
183
|
+
underscored = name.replace("-", "_")
|
|
184
|
+
if isinstance(lint, dict):
|
|
185
|
+
if hyphenated in lint:
|
|
186
|
+
return lint[hyphenated]
|
|
187
|
+
if underscored in lint:
|
|
188
|
+
return lint[underscored]
|
|
189
|
+
if hyphenated in configuration:
|
|
190
|
+
return configuration[hyphenated]
|
|
191
|
+
if underscored in configuration:
|
|
192
|
+
return configuration[underscored]
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _configuration_chain(
|
|
197
|
+
path: Path | None, seen: frozenset[Path] = frozenset()
|
|
198
|
+
) -> tuple[dict[str, object], ...]:
|
|
199
|
+
if path is None:
|
|
200
|
+
return ()
|
|
201
|
+
resolved_path = path.resolve()
|
|
202
|
+
if resolved_path in seen:
|
|
203
|
+
return ()
|
|
204
|
+
try:
|
|
205
|
+
document = tomllib.loads(resolved_path.read_text())
|
|
206
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
207
|
+
return ()
|
|
208
|
+
|
|
209
|
+
configuration: object = document
|
|
210
|
+
if resolved_path.name == "pyproject.toml":
|
|
211
|
+
configuration = document.get("tool", {}).get("ruff", {})
|
|
212
|
+
if not isinstance(configuration, dict):
|
|
213
|
+
return ()
|
|
214
|
+
|
|
215
|
+
base: tuple[dict[str, object], ...] = ()
|
|
216
|
+
extended = configuration.get("extend")
|
|
217
|
+
if isinstance(extended, str):
|
|
218
|
+
extended_path = Path(extended)
|
|
219
|
+
if not extended_path.is_absolute():
|
|
220
|
+
extended_path = resolved_path.parent / extended_path
|
|
221
|
+
base = _configuration_chain(extended_path, seen | {resolved_path})
|
|
222
|
+
return (*base, configuration)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _configuration_defines_any(
|
|
226
|
+
path: Path | None, setting_paths: Sequence[tuple[str, ...]]
|
|
227
|
+
) -> bool:
|
|
228
|
+
return any(
|
|
229
|
+
_configuration_defines(path, setting_path) for setting_path in setting_paths
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _configuration_defines(
|
|
234
|
+
path: Path | None,
|
|
235
|
+
setting_path: tuple[str, ...],
|
|
236
|
+
) -> bool:
|
|
237
|
+
return any(
|
|
238
|
+
_mapping_has_path(configuration, setting_path)
|
|
239
|
+
for configuration in _configuration_chain(path)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _mapping_has_path(mapping: dict[str, object], path: tuple[str, ...]) -> bool:
|
|
244
|
+
current: object = mapping
|
|
245
|
+
for part in path:
|
|
246
|
+
if not isinstance(current, dict):
|
|
247
|
+
return False
|
|
248
|
+
hyphenated = part.replace("_", "-")
|
|
249
|
+
underscored = part.replace("-", "_")
|
|
250
|
+
if hyphenated in current:
|
|
251
|
+
current = current[hyphenated]
|
|
252
|
+
elif underscored in current:
|
|
253
|
+
current = current[underscored]
|
|
254
|
+
else:
|
|
255
|
+
return False
|
|
256
|
+
return True
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _setting_block(output: str, name: str, opener: str, closer: str) -> str:
|
|
260
|
+
lines = output.splitlines()
|
|
261
|
+
marker = f"{name} = {opener}"
|
|
262
|
+
empty_marker = f"{marker}{closer}"
|
|
263
|
+
for index, line in enumerate(lines):
|
|
264
|
+
if line.strip() == empty_marker:
|
|
265
|
+
return ""
|
|
266
|
+
if line.strip() != marker:
|
|
267
|
+
continue
|
|
268
|
+
block: list[str] = []
|
|
269
|
+
for candidate in lines[index + 1 :]:
|
|
270
|
+
if candidate.strip() == closer:
|
|
271
|
+
return "\n".join(block)
|
|
272
|
+
block.append(candidate)
|
|
273
|
+
raise RuffFailure("Ruff returned an unsupported resolved-settings format")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _setting_value(output: str, name: str) -> str:
|
|
277
|
+
value = _optional_setting_value(output, f"{name} =")
|
|
278
|
+
if value is None:
|
|
279
|
+
raise RuffFailure("Ruff returned an unsupported resolved-settings format")
|
|
280
|
+
return value
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _optional_setting_value(output: str, marker: str) -> str | None:
|
|
284
|
+
for line in output.splitlines():
|
|
285
|
+
if line.strip().startswith(marker):
|
|
286
|
+
return line.strip().removeprefix(marker).strip()
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _rule_codes(block: str) -> frozenset[str]:
|
|
291
|
+
rules: set[str] = set()
|
|
292
|
+
for line in block.splitlines():
|
|
293
|
+
_add_rule_code(line.strip(), rules)
|
|
294
|
+
return frozenset(rules)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _parse_banned_apis(block: str) -> tuple[tuple[str, str], ...]:
|
|
298
|
+
banned_apis: list[tuple[str, str]] = []
|
|
299
|
+
for line in block.splitlines():
|
|
300
|
+
api, separator, message = line.strip().removesuffix(",").partition(" = ")
|
|
301
|
+
if separator:
|
|
302
|
+
banned_apis.append((api, message))
|
|
303
|
+
return tuple(banned_apis)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _parse_per_file_ignores(
|
|
307
|
+
block: str,
|
|
308
|
+
) -> tuple[tuple[str, frozenset[str]], ...]:
|
|
309
|
+
ignores: list[tuple[str, frozenset[str]]] = []
|
|
310
|
+
current_pattern: str | None = None
|
|
311
|
+
current_rules: set[str] = set()
|
|
312
|
+
for line in block.splitlines():
|
|
313
|
+
stripped = line.strip()
|
|
314
|
+
if stripped.startswith("basename_matcher = "):
|
|
315
|
+
if current_pattern is not None:
|
|
316
|
+
ignores.append((current_pattern, frozenset(current_rules)))
|
|
317
|
+
current_pattern = _quoted_value(stripped.partition(" = ")[2])
|
|
318
|
+
current_rules = set()
|
|
319
|
+
else:
|
|
320
|
+
_add_rule_code(stripped, current_rules)
|
|
321
|
+
if current_pattern is not None:
|
|
322
|
+
ignores.append((current_pattern, frozenset(current_rules)))
|
|
323
|
+
return tuple(ignores)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _quoted_value(value: str) -> str:
|
|
327
|
+
try:
|
|
328
|
+
parsed = json.loads(value)
|
|
329
|
+
except json.JSONDecodeError:
|
|
330
|
+
return value
|
|
331
|
+
return str(parsed)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _add_rule_code(line: str, destination: set[str]) -> None:
|
|
335
|
+
match = _RULE_CODE.search(line)
|
|
336
|
+
if match is not None:
|
|
337
|
+
destination.add(match.group(1))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _policy_notices(settings: RuffSettings) -> tuple[str, ...]:
|
|
341
|
+
notices = _disabled_rule_notices(settings)
|
|
342
|
+
notices.extend(_limit_notices(settings))
|
|
343
|
+
notices.extend(_banned_api_notices(settings))
|
|
344
|
+
notices.extend(_per_file_ignore_notices(settings))
|
|
345
|
+
return tuple(notices)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _disabled_rule_notices(settings: RuffSettings) -> list[str]:
|
|
349
|
+
return [
|
|
350
|
+
f"{code} is disabled; recommended: enabled"
|
|
351
|
+
for code in _RECOMMENDED_RULES
|
|
352
|
+
if code not in settings.enabled_rules
|
|
353
|
+
]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _limit_notices(settings: RuffSettings) -> list[str]:
|
|
357
|
+
notices: list[str] = []
|
|
358
|
+
if "C901" in settings.enabled_rules and settings.max_complexity > _MAX_COMPLEXITY:
|
|
359
|
+
notices.append(
|
|
360
|
+
f"C901 allows complexity {settings.max_complexity}; "
|
|
361
|
+
f"recommended maximum: {_MAX_COMPLEXITY}"
|
|
362
|
+
)
|
|
363
|
+
if (
|
|
364
|
+
"PLR0915" in settings.enabled_rules
|
|
365
|
+
and settings.max_statements > _MAX_STATEMENTS
|
|
366
|
+
):
|
|
367
|
+
notices.append(
|
|
368
|
+
f"PLR0915 allows {settings.max_statements} statements; "
|
|
369
|
+
f"recommended maximum: {_MAX_STATEMENTS}"
|
|
370
|
+
)
|
|
371
|
+
return notices
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _banned_api_notices(settings: RuffSettings) -> list[str]:
|
|
375
|
+
if "TID251" not in settings.enabled_rules:
|
|
376
|
+
return []
|
|
377
|
+
return [
|
|
378
|
+
f"TID251 does not ban {api}; recommended: ban this API"
|
|
379
|
+
for api in _RECOMMENDED_BANNED_APIS
|
|
380
|
+
if not _api_is_banned(api, settings.banned_apis)
|
|
381
|
+
]
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _api_is_banned(api: str, banned_apis: frozenset[str]) -> bool:
|
|
385
|
+
return any(api == banned or api.startswith(f"{banned}.") for banned in banned_apis)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _per_file_ignore_notices(settings: RuffSettings) -> list[str]:
|
|
389
|
+
notices: list[str] = []
|
|
390
|
+
enabled_recommendations = settings.enabled_rules.intersection(_RECOMMENDED_RULES)
|
|
391
|
+
for pattern, ignored_rules in settings.per_file_ignores:
|
|
392
|
+
for code in _RECOMMENDED_RULES:
|
|
393
|
+
if code in enabled_recommendations and code in ignored_rules:
|
|
394
|
+
notices.append(
|
|
395
|
+
f"{code} is ignored for {pattern}; "
|
|
396
|
+
"recommended for all checked files"
|
|
397
|
+
)
|
|
398
|
+
return notices
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from anti_slop_python.rules.base import Rule
|
|
2
|
+
from anti_slop_python.rules.no_any_containers import RULE as NO_ANY_CONTAINERS
|
|
3
|
+
from anti_slop_python.rules.no_dynamic_attribute_access import (
|
|
4
|
+
RULE as NO_DYNAMIC_ATTRIBUTE_ACCESS,
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
RULES: tuple[Rule, ...] = (
|
|
8
|
+
NO_ANY_CONTAINERS,
|
|
9
|
+
NO_DYNAMIC_ATTRIBUTE_ACCESS,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = ["RULES", "Rule"]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections.abc import Callable, Iterable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from anti_slop_python.diagnostics import Diagnostic
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Rule:
|
|
13
|
+
"""Metadata and checker function for one rule."""
|
|
14
|
+
|
|
15
|
+
code: str
|
|
16
|
+
name: str
|
|
17
|
+
message: str
|
|
18
|
+
check: Callable[[RuleContext], Iterable[Diagnostic]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class RuleContext:
|
|
23
|
+
"""Parsed source and the shared import analysis used by rules."""
|
|
24
|
+
|
|
25
|
+
path: Path
|
|
26
|
+
tree: ast.AST
|
|
27
|
+
imports: dict[str, str] = field(init=False)
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
self.imports = _collect_imports(self.tree)
|
|
31
|
+
|
|
32
|
+
def qualified_name(self, node: ast.AST) -> str | None:
|
|
33
|
+
"""Resolve a simple imported name without full semantic analysis."""
|
|
34
|
+
|
|
35
|
+
name = _dotted_name(node)
|
|
36
|
+
if name is None:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
first, separator, remainder = name.partition(".")
|
|
40
|
+
resolved = self.imports.get(first, first)
|
|
41
|
+
if separator:
|
|
42
|
+
return f"{resolved}.{remainder}"
|
|
43
|
+
return resolved
|
|
44
|
+
|
|
45
|
+
def diagnostic(self, rule: Rule, node: ast.AST) -> Diagnostic:
|
|
46
|
+
return Diagnostic(
|
|
47
|
+
path=self.path,
|
|
48
|
+
line=node.lineno,
|
|
49
|
+
column=node.col_offset + 1,
|
|
50
|
+
code=rule.code,
|
|
51
|
+
message=rule.message,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _collect_imports(tree: ast.AST) -> dict[str, str]:
|
|
56
|
+
imports: dict[str, str] = {}
|
|
57
|
+
for node in ast.walk(tree):
|
|
58
|
+
if isinstance(node, ast.Import):
|
|
59
|
+
for alias in node.names:
|
|
60
|
+
local_name = alias.asname or alias.name.split(".", 1)[0]
|
|
61
|
+
target = alias.name if alias.asname else local_name
|
|
62
|
+
imports[local_name] = target
|
|
63
|
+
elif isinstance(node, ast.ImportFrom):
|
|
64
|
+
dots = "." * (node.level or 0)
|
|
65
|
+
if node.module:
|
|
66
|
+
module_prefix = f"{dots}{node.module}."
|
|
67
|
+
elif dots:
|
|
68
|
+
module_prefix = f"{dots}"
|
|
69
|
+
else:
|
|
70
|
+
module_prefix = ""
|
|
71
|
+
for alias in node.names:
|
|
72
|
+
if alias.name == "*":
|
|
73
|
+
continue
|
|
74
|
+
local_name = alias.asname or alias.name
|
|
75
|
+
imports[local_name] = f"{module_prefix}{alias.name}"
|
|
76
|
+
return imports
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _dotted_name(node: ast.AST) -> str | None:
|
|
80
|
+
if isinstance(node, ast.Name):
|
|
81
|
+
return node.id
|
|
82
|
+
if isinstance(node, ast.Attribute):
|
|
83
|
+
parent = _dotted_name(node.value)
|
|
84
|
+
if parent is not None:
|
|
85
|
+
return f"{parent}.{node.attr}"
|
|
86
|
+
return None
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
from anti_slop_python.diagnostics import Diagnostic
|
|
7
|
+
from anti_slop_python.rules.base import Rule, RuleContext
|
|
8
|
+
|
|
9
|
+
_CONTAINERS = {
|
|
10
|
+
"dict",
|
|
11
|
+
"list",
|
|
12
|
+
"set",
|
|
13
|
+
"tuple",
|
|
14
|
+
"typing.Dict",
|
|
15
|
+
"typing.List",
|
|
16
|
+
"typing.Set",
|
|
17
|
+
"typing.Tuple",
|
|
18
|
+
"typing_extensions.Dict",
|
|
19
|
+
"typing_extensions.List",
|
|
20
|
+
"typing_extensions.Set",
|
|
21
|
+
"typing_extensions.Tuple",
|
|
22
|
+
}
|
|
23
|
+
_ANY_NAMES = {"Any", "typing.Any", "typing_extensions.Any"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _check(context: RuleContext) -> Iterable[Diagnostic]:
|
|
27
|
+
for node in ast.walk(context.tree):
|
|
28
|
+
if not isinstance(node, ast.Subscript):
|
|
29
|
+
continue
|
|
30
|
+
if context.qualified_name(node.value) not in _CONTAINERS:
|
|
31
|
+
continue
|
|
32
|
+
if _contains_any(context, node.slice):
|
|
33
|
+
yield context.diagnostic(RULE, node)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _contains_any(context: RuleContext, node: ast.AST) -> bool:
|
|
37
|
+
if (
|
|
38
|
+
isinstance(node, ast.Subscript)
|
|
39
|
+
and context.qualified_name(node.value) in _CONTAINERS
|
|
40
|
+
):
|
|
41
|
+
return False
|
|
42
|
+
if (
|
|
43
|
+
isinstance(node, (ast.Name, ast.Attribute))
|
|
44
|
+
and context.qualified_name(node) in _ANY_NAMES
|
|
45
|
+
):
|
|
46
|
+
return True
|
|
47
|
+
return any(_contains_any(context, child) for child in ast.iter_child_nodes(node))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
RULE = Rule(
|
|
51
|
+
code="SPY001",
|
|
52
|
+
name="no-any-containers",
|
|
53
|
+
message="Avoid containers parameterized with Any",
|
|
54
|
+
check=_check,
|
|
55
|
+
)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
from anti_slop_python.diagnostics import Diagnostic
|
|
7
|
+
from anti_slop_python.rules.base import Rule, RuleContext
|
|
8
|
+
|
|
9
|
+
_DYNAMIC_ATTRIBUTE_FUNCTIONS = {
|
|
10
|
+
"builtins.delattr",
|
|
11
|
+
"builtins.getattr",
|
|
12
|
+
"builtins.setattr",
|
|
13
|
+
"delattr",
|
|
14
|
+
"getattr",
|
|
15
|
+
"setattr",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _check(context: RuleContext) -> Iterable[Diagnostic]:
|
|
20
|
+
for node in ast.walk(context.tree):
|
|
21
|
+
if not isinstance(node, ast.Call):
|
|
22
|
+
continue
|
|
23
|
+
if context.qualified_name(node.func) in _DYNAMIC_ATTRIBUTE_FUNCTIONS:
|
|
24
|
+
yield context.diagnostic(RULE, node)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
RULE = Rule(
|
|
28
|
+
code="SPY002",
|
|
29
|
+
name="no-dynamic-attribute-access",
|
|
30
|
+
message="Avoid dynamic attribute access",
|
|
31
|
+
check=_check,
|
|
32
|
+
)
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: anti-slop-python
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An opinionated architectural linter for Python
|
|
5
|
+
Project-URL: Homepage, https://github.com/ruarfff/anti-slop-python
|
|
6
|
+
Project-URL: Repository, https://github.com/ruarfff/anti-slop-python
|
|
7
|
+
Project-URL: Issues, https://github.com/ruarfff/anti-slop-python/issues
|
|
8
|
+
Author: Ruairí O'Brien
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: anti-slop,architecture,code-quality,linter,pre-commit,ruff
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: ruff<0.17,>=0.16.5
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# anti-slop-python
|
|
23
|
+
|
|
24
|
+
[](https://skills.sh/ruarfff/anti-slop-python)
|
|
25
|
+
|
|
26
|
+
`anti-slop-python` is a small, opinionated architectural
|
|
27
|
+
linter for Python inspired by and largely copied from
|
|
28
|
+
[dmmulroy/anti-slop](https://github.com/dmmulroy/anti-slop).
|
|
29
|
+
|
|
30
|
+
It requires [ruff](https://github.com/astral-sh/ruff) so it is not a standalone linter.
|
|
31
|
+
|
|
32
|
+
`anti-slop-python` does not attempt to determine whether code was written by a human
|
|
33
|
+
or an agent. It rejects patterns that weaken evidence about types, invariants,
|
|
34
|
+
boundaries, and dependencies.
|
|
35
|
+
|
|
36
|
+
It also catches very common issues when using LLMs to generate Python code like functions
|
|
37
|
+
and files getting way too big.
|
|
38
|
+
|
|
39
|
+
## Setup
|
|
40
|
+
|
|
41
|
+
### Install it using the agent skill
|
|
42
|
+
|
|
43
|
+
The repository includes `install-anti-slop-python`, an Agent Skill that
|
|
44
|
+
installs and configures anti-slop-python in a target Python repository.
|
|
45
|
+
|
|
46
|
+
The [`skills` CLI](https://github.com/vercel-labs/skills) supports project and
|
|
47
|
+
global installs for many coding agents.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# Install interactively for the current project
|
|
51
|
+
npx skills add ruarfff/anti-slop-python --skill install-anti-slop-python
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Make the skill available everywhere on your system:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx skills add ruarfff/anti-slop-python --skill install-anti-slop-python --agent '*' --global --yes
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
See the [`skills` CLI documentation](https://github.com/vercel-labs/skills#supported-agents).
|
|
61
|
+
|
|
62
|
+
### Install it with pre-commit
|
|
63
|
+
|
|
64
|
+
This repository applies `anti-slop-python` to itself through the local hook in
|
|
65
|
+
`.pre-commit-config.yaml`.
|
|
66
|
+
|
|
67
|
+
The repository includes hook metadata. Add this entry to
|
|
68
|
+
`.pre-commit-config.yaml` and replace the revision with the release to use:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
repos:
|
|
72
|
+
- repo: https://github.com/ruarfff/anti-slop-python
|
|
73
|
+
rev: v0.1.0
|
|
74
|
+
hooks:
|
|
75
|
+
- id: anti-slop-python
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Run against an entire project:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uv run pre-commit run anti-slop-python --all-files
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
#### Adopt it for selected directories
|
|
85
|
+
|
|
86
|
+
Use pre-commit's `files` regular expression to limit the hook to selected
|
|
87
|
+
parts of a project:
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
repos:
|
|
91
|
+
- repo: https://github.com/ruarfff/anti-slop-python
|
|
92
|
+
rev: v0.1.0
|
|
93
|
+
hooks:
|
|
94
|
+
- id: anti-slop-python
|
|
95
|
+
files: ^(?:src/a-specific-module/|tests/tests-for-that-module/)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Useful if you want to gradually introduce `anti-slop-python` to a project.
|
|
99
|
+
|
|
100
|
+
Expand the `files` expression as more directories adopt the policy.
|
|
101
|
+
This does not override the project's Ruff configuration for files outside the selected directories.
|
|
102
|
+
|
|
103
|
+
## Rules
|
|
104
|
+
|
|
105
|
+
Native `anti-slop-python` rules cover some checks that Ruff does not provide:
|
|
106
|
+
|
|
107
|
+
| Rule | Name | Rejected pattern |
|
|
108
|
+
| --- | --- | --- |
|
|
109
|
+
| SPY001 | `no-any-containers` | `dict`, `list`, `set`, or `tuple` parameterized with `Any` (including their `typing` aliases) |
|
|
110
|
+
| SPY002 | `no-dynamic-attribute-access` | Calls to `getattr()`, `setattr()`, or `delattr()` |
|
|
111
|
+
|
|
112
|
+
### Ruff-backed policy
|
|
113
|
+
|
|
114
|
+
`anti-slop-python` uses Ruff for checks that Ruff already provides. It enables this
|
|
115
|
+
policy by default:
|
|
116
|
+
|
|
117
|
+
| Rule | Default policy |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| [`C901`](https://docs.astral.sh/ruff/rules/complex-structure/) | Cyclomatic complexity of at most 10 |
|
|
120
|
+
| [`PLR0915`](https://docs.astral.sh/ruff/rules/too-many-statements/) | At most 40 statements per function or method |
|
|
121
|
+
| [`TID251`](https://docs.astral.sh/ruff/rules/banned-api/) | Ban `unittest.mock.patch` and `mock.patch` |
|
|
122
|
+
| [`E722`](https://docs.astral.sh/ruff/rules/bare-except/) | Reject bare exception handlers |
|
|
123
|
+
| [`BLE001`](https://docs.astral.sh/ruff/rules/blind-except/) | Reject broad exception handlers |
|
|
124
|
+
|
|
125
|
+
No Ruff configuration is required when you run `anti-slop-python`. Its defaults are
|
|
126
|
+
equivalent to:
|
|
127
|
+
|
|
128
|
+
```toml
|
|
129
|
+
[tool.ruff.lint]
|
|
130
|
+
extend-select = ["BLE001", "C901", "E722", "PLR0915", "TID251"]
|
|
131
|
+
|
|
132
|
+
[tool.ruff.lint.mccabe]
|
|
133
|
+
max-complexity = 10
|
|
134
|
+
|
|
135
|
+
[tool.ruff.lint.pylint]
|
|
136
|
+
max-statements = 40
|
|
137
|
+
|
|
138
|
+
[tool.ruff.lint.flake8-tidy-imports.banned-api]
|
|
139
|
+
"mock.patch".msg = "Avoid patching state. Pass the dependency explicitly instead."
|
|
140
|
+
"unittest.mock.patch".msg = "Avoid patching state. Pass the dependency explicitly instead."
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Project Ruff settings remain authoritative. Use them only when the project
|
|
144
|
+
must change or extend the defaults. For example:
|
|
145
|
+
|
|
146
|
+
```toml
|
|
147
|
+
[tool.ruff.lint]
|
|
148
|
+
extend-ignore = ["C901"]
|
|
149
|
+
|
|
150
|
+
[tool.ruff.lint.pylint]
|
|
151
|
+
max-statements = 50
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`ignore` and `extend-ignore` disable default rules. Explicit thresholds,
|
|
155
|
+
exclusions, per-file ignores, and `noqa` comments also apply.
|
|
156
|
+
|
|
157
|
+
If the project defines the `TID251` banned-API table, it replaces the default
|
|
158
|
+
provided by `anti-slop-python`.
|
|
159
|
+
|
|
160
|
+
Inline `noqa` comments can suppress a recommended Ruff diagnostic, so
|
|
161
|
+
`anti-slop-python` audits them and prints a non-failing policy notice with the
|
|
162
|
+
source location. Ruff exclusions apply to Ruff-backed checks. Native SPY rules
|
|
163
|
+
still check every Python file in the paths passed to `anti-slop-python`; use the
|
|
164
|
+
command paths or pre-commit `files`/`exclude` settings to control that scope.
|
|
165
|
+
|
|
166
|
+
When an override is weaker than the default policy, `anti-slop-python` prints a
|
|
167
|
+
non-failing policy notice. Stricter settings do not produce a notice. The
|
|
168
|
+
defaults apply only to Ruff runs started by `anti-slop-python`; add the equivalent
|
|
169
|
+
configuration above if a separate `ruff check` command must enforce the same
|
|
170
|
+
policy.
|
|
171
|
+
|
|
172
|
+
## Why these rules exist
|
|
173
|
+
|
|
174
|
+
These rules do not identify whether an LLM wrote the code. Humans produce the
|
|
175
|
+
same patterns.
|
|
176
|
+
|
|
177
|
+
The rules here are an opinionated set of guidelines that cover common issues
|
|
178
|
+
with LLM generated code and are an attempt to reduce the review burden.
|
|
179
|
+
|
|
180
|
+
An agent can add a large amount of locally plausible code without first "learning" the
|
|
181
|
+
project's types, boundaries, or dependency design. The rules help against some common
|
|
182
|
+
shortcuts LLMs use to make code fulfill an immediate goal while making later changes
|
|
183
|
+
harder to reason about.
|
|
184
|
+
|
|
185
|
+
The rules also try to encourage better design by pushing back on the most annoying
|
|
186
|
+
things LLMs tend to do like writing massive functions, files with thousands of lines,
|
|
187
|
+
tests that patch and mock everything, etc.
|
|
188
|
+
|
|
189
|
+
The following are some example rules and why they were added:
|
|
190
|
+
|
|
191
|
+
### SPY001 — Do not put `Any` in containers
|
|
192
|
+
|
|
193
|
+
`SPY001` rejects `dict`, `list`, `set`, and `tuple` types parameterized with
|
|
194
|
+
`Any`, including the equivalent aliases from `typing`. A container usually
|
|
195
|
+
carries data across several lines, functions, or layers. Once its element type
|
|
196
|
+
becomes `Any`, every value taken from it escapes useful static checking and can
|
|
197
|
+
spread uncertainty through the rest of the program.
|
|
198
|
+
|
|
199
|
+
An agent often introduces this pattern when it can see that a value is a list
|
|
200
|
+
or dictionary but has not established the shape of the contents. `Any` makes
|
|
201
|
+
the immediate type error disappear without resolving that missing knowledge.
|
|
202
|
+
The rule forces the implementation to name the real type. A burden you probably
|
|
203
|
+
wouldn't always want for yourself but an LLM can deal with it and maybe produce
|
|
204
|
+
better code because of it.
|
|
205
|
+
|
|
206
|
+
Ruff's [`ANN401`](https://docs.astral.sh/ruff/rules/any-type/) rule is related
|
|
207
|
+
but does not replace `SPY001`: `ANN401` checks function parameters annotated
|
|
208
|
+
directly with `Any`, while `SPY001` checks for `Any` inside container types such
|
|
209
|
+
as `list[Any]` and `dict[str, Any]`.
|
|
210
|
+
|
|
211
|
+
### SPY002 — Do not hide attributes behind strings
|
|
212
|
+
|
|
213
|
+
`SPY002` rejects calls to `getattr()`, `setattr()`, and `delattr()`. Dynamic
|
|
214
|
+
attribute access turns an interface into a runtime string convention. Type
|
|
215
|
+
checkers and refactoring tools have less evidence, misspelled names fail late,
|
|
216
|
+
and a default passed to `getattr()` can hide a missing invariant.
|
|
217
|
+
|
|
218
|
+
Generated code often uses `getattr(value, "name", None)` to support several
|
|
219
|
+
assumed object shapes without checking which shapes the application actually
|
|
220
|
+
allows. The rule requires direct attribute access for a known interface.
|
|
221
|
+
|
|
222
|
+
Ruff has related [`B009`](https://docs.astral.sh/ruff/rules/get-attr-with-constant/),
|
|
223
|
+
[`B010`](https://docs.astral.sh/ruff/rules/set-attr-with-constant/), and
|
|
224
|
+
preview-only [`B043`](https://docs.astral.sh/ruff/rules/del-attr-with-constant/)
|
|
225
|
+
rules. They flag `getattr()`, `setattr()`, and `delattr()` only when the
|
|
226
|
+
attribute name is a constant string. They therefore allow calls such as
|
|
227
|
+
`getattr(value, name)`, which may be intentional but still make an interface
|
|
228
|
+
depend on runtime strings. `SPY002` rejects
|
|
229
|
+
the built-ins regardless of whether the name is constant.
|
|
230
|
+
|
|
231
|
+
### [`C901`](https://docs.astral.sh/ruff/rules/complex-structure/) — Limit decision complexity
|
|
232
|
+
|
|
233
|
+
`C901` measures the number of paths through a function. Anti-slop-python uses a
|
|
234
|
+
maximum McCabe complexity of 10. A function can be short and still be complex
|
|
235
|
+
when it contains many branches, loops, or exception paths. Each added path
|
|
236
|
+
increases the number of states that a reader and a test suite must consider.
|
|
237
|
+
|
|
238
|
+
Coding agents tend to tack on more complexity to achieve a goal like making tests pass.
|
|
239
|
+
|
|
240
|
+
### [`PLR0915`](https://docs.astral.sh/ruff/rules/too-many-statements/) — Limit function size
|
|
241
|
+
|
|
242
|
+
`PLR0915` rejects functions or methods with more than 40 statements. This
|
|
243
|
+
complements `C901`: a long function can have simple control flow and still do
|
|
244
|
+
too much.
|
|
245
|
+
|
|
246
|
+
Generated implementations tend to keep the full requested workflow in one
|
|
247
|
+
function because that is the easiest shape to produce in one pass. The
|
|
248
|
+
statement limit makes things like mixed responsibilities more visible.
|
|
249
|
+
|
|
250
|
+
### [`TID251`](https://docs.astral.sh/ruff/rules/banned-api/) — Do not patch dependencies
|
|
251
|
+
|
|
252
|
+
Ruff's `TID251` rule can ban project-selected APIs. Anti-slop-python uses it to ban
|
|
253
|
+
`unittest.mock.patch` and `mock.patch`. Patching replaces module or object
|
|
254
|
+
state at runtime, so a test depends on where a symbol happens to be imported
|
|
255
|
+
rather than on an explicit interface. Refactoring an import can then break the
|
|
256
|
+
test even when behavior has not changed.
|
|
257
|
+
|
|
258
|
+
Agents frequently reach for `patch()` because it can isolate almost any call
|
|
259
|
+
without changing production design. There's a tendency to test implementation rather than behavior
|
|
260
|
+
when trying to improve test coverage.
|
|
261
|
+
|
|
262
|
+
This setting helps a little with directing a clearer interface-based design, although it is generally
|
|
263
|
+
not enough by itself and LLMs need a lot of direction to get to this kind of design.
|
|
264
|
+
|
|
265
|
+
### [`E722`](https://docs.astral.sh/ruff/rules/bare-except/) — Do not use bare exception handlers
|
|
266
|
+
|
|
267
|
+
`E722` rejects a bare `except:` handler. A bare handler catches
|
|
268
|
+
`BaseException`, including `KeyboardInterrupt` and `SystemExit`. It can prevent
|
|
269
|
+
a process from stopping and can disguise failures that the code cannot
|
|
270
|
+
actually recover from.
|
|
271
|
+
|
|
272
|
+
LLMs tend to be good at building exception handling but occasionally they take shortcuts and
|
|
273
|
+
this helps avoid that.
|
|
274
|
+
|
|
275
|
+
### [`BLE001`](https://docs.astral.sh/ruff/rules/blind-except/) — Do not catch broad exceptions
|
|
276
|
+
|
|
277
|
+
`BLE001` flags broad named handlers such as `except Exception` and
|
|
278
|
+
`except BaseException` when they handle or swallow the error.
|
|
279
|
+
|
|
280
|
+
Ruff permits broad handlers that re-raise and recognized logging patterns
|
|
281
|
+
that retain the exception trace.
|
|
282
|
+
|
|
283
|
+
A coding agent may wrap a large generated block in `except Exception` because it
|
|
284
|
+
does not know the operation's failure contract. This setting forces the agent to
|
|
285
|
+
work through the possible errors and make them clear.
|
|
286
|
+
|
|
287
|
+
## Usage
|
|
288
|
+
|
|
289
|
+
Run Ruff and the native checker together on directories or individual Python
|
|
290
|
+
files:
|
|
291
|
+
|
|
292
|
+
```console
|
|
293
|
+
uvx anti-slop-python .
|
|
294
|
+
uvx anti-slop-python src/
|
|
295
|
+
uvx anti-slop-python src/anti_slop_python/cli.py
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Diagnostics use a conventional source format, and any diagnostic makes the
|
|
299
|
+
command exit with status 1:
|
|
300
|
+
|
|
301
|
+
```text
|
|
302
|
+
src/api/parser.py:41:12 SPY001 Avoid containers parameterized with Any
|
|
303
|
+
src/api/parser.py:45:8 SPY002 Avoid dynamic attribute access
|
|
304
|
+
src/orders/service.py:18:5 C901 `create_order` is too complex (14 > 10)
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Policy notices are written to standard error and do not change the exit code:
|
|
308
|
+
|
|
309
|
+
```text
|
|
310
|
+
anti-slop-python policy notice: C901 is disabled; recommended: enabled
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Ruff runs in check-only mode even if the project has `fix = true`.
|
|
314
|
+
|
|
315
|
+
For local development:
|
|
316
|
+
|
|
317
|
+
```console
|
|
318
|
+
uv sync --dev
|
|
319
|
+
uv run pre-commit install
|
|
320
|
+
uv run anti-slop-python src tests
|
|
321
|
+
uv run pre-commit run --all-files
|
|
322
|
+
uv run pytest
|
|
323
|
+
uv run ruff format --check .
|
|
324
|
+
uv run ruff check .
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## Examples
|
|
328
|
+
|
|
329
|
+
[`examples/basic_project`](examples/basic_project) is a small Python project
|
|
330
|
+
with intentional native and Ruff-backed violations, plus preferred
|
|
331
|
+
alternatives. This repository's Ruff and pre-commit checks exclude `examples/`;
|
|
332
|
+
its direct self-check targets `src` and `tests`.
|
|
333
|
+
|
|
334
|
+
Run the example explicitly to see its diagnostics:
|
|
335
|
+
|
|
336
|
+
```console
|
|
337
|
+
uv run anti-slop-python examples/basic_project
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
## Scope and limitations
|
|
341
|
+
|
|
342
|
+
Native checks use Python's built-in `ast` and a pragmatic import alias map.
|
|
343
|
+
They do not perform scope-aware name resolution, type checking, cross-file
|
|
344
|
+
analysis, configuration, suppressions, or autofixes. Ruff-backed checks use
|
|
345
|
+
the project's effective Ruff configuration and suppression behavior.
|
|
346
|
+
|
|
347
|
+
## License
|
|
348
|
+
|
|
349
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
anti_slop_python/__init__.py,sha256=oSNUe5E1IfvjdHknDmnTbQRwHhJhDFM1YhWM2s7ZnEM,243
|
|
2
|
+
anti_slop_python/__main__.py,sha256=yv0XL1KDDfArsZW2K7WnRsrd3yT1QYtfsMpbXdLkYQQ,64
|
|
3
|
+
anti_slop_python/checker.py,sha256=lO2ysGZNC0fHcwuEiaPCkA0rkEGGu2Fnl0ukSiA8BaA,1526
|
|
4
|
+
anti_slop_python/cli.py,sha256=A1EuYu-yNimswzxBUVtm7K4aijRItwzIu7qGJQCRzkw,2566
|
|
5
|
+
anti_slop_python/diagnostics.py,sha256=34UxfZPmUkHl6Oeq1QnGXtKpSFdjGpvH9-3vypUxoUE,403
|
|
6
|
+
anti_slop_python/ruff_integration.py,sha256=W9k5u38AORFU6xuWQYMq97_yIb3QD9X4Ej62Bfju-cc,6646
|
|
7
|
+
anti_slop_python/ruff_policy.py,sha256=8AJv62Xsw8GvrzZxyUBad-tkBLzxf8JuRXm5wf7WfkM,13461
|
|
8
|
+
anti_slop_python/rules/__init__.py,sha256=fVfcMMdM3FHF0VX0Gh2azu725rATRKtLppKw-JhSswk,348
|
|
9
|
+
anti_slop_python/rules/base.py,sha256=eR7GS_-Ays1uZUfRLATpQhvItTDKjxpcnCjtfKPkz9A,2572
|
|
10
|
+
anti_slop_python/rules/no_any_containers.py,sha256=pG3D4nfImnhq3M9zoH3ST-r6qxYK5Ua6RE58cwemPDk,1443
|
|
11
|
+
anti_slop_python/rules/no_dynamic_attribute_access.py,sha256=4b0OEGpXlM_AuoU5lTwlommPNb_BKxvkh990FhnCI3A,779
|
|
12
|
+
anti_slop_python-0.1.0.dist-info/METADATA,sha256=u26KSgk9J7jkudKVwI5niRaqpwfNYF_EPKIRvGP6p4k,13755
|
|
13
|
+
anti_slop_python-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
14
|
+
anti_slop_python-0.1.0.dist-info/entry_points.txt,sha256=2LuCaWUA9_X8B3dT-ccfKZ-prhl7pNwGn9NanzNG9i0,63
|
|
15
|
+
anti_slop_python-0.1.0.dist-info/licenses/LICENSE,sha256=-DUrK07cTAr9HVwvRNoUZHJc9gfE2X-CuFkrIG5FvjQ,1072
|
|
16
|
+
anti_slop_python-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ruairí O'Brien
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|