pycodegate 0.2.0__py3-none-any.whl → 0.5.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.
- pycodegate/__init__.py +9 -2
- pycodegate/__main__.py +6 -0
- pycodegate/api.py +2 -2
- pycodegate/cli.py +8 -4
- pycodegate/config.py +53 -4
- pycodegate/constants.py +30 -1
- pycodegate/discover.py +27 -0
- pycodegate/output.py +20 -52
- pycodegate/profile.py +1 -1
- pycodegate/py.typed +0 -0
- pycodegate/rules/__init__.py +31 -11
- pycodegate/rules/architecture.py +30 -3
- pycodegate/rules/celery.py +165 -0
- pycodegate/rules/complexity.py +2 -1
- pycodegate/rules/correctness.py +36 -44
- pycodegate/rules/dead_code.py +5 -2
- pycodegate/rules/django.py +33 -1
- pycodegate/rules/fastapi.py +39 -8
- pycodegate/rules/flask.py +22 -15
- pycodegate/rules/import_boundaries.py +70 -0
- pycodegate/rules/imports.py +13 -1
- pycodegate/rules/logging_rules.py +165 -0
- pycodegate/rules/numpy_rules.py +164 -0
- pycodegate/rules/pandas_rules.py +165 -0
- pycodegate/rules/performance.py +67 -54
- pycodegate/rules/pydantic.py +271 -0
- pycodegate/rules/pytest_rules.py +142 -0
- pycodegate/rules/requests_rules.py +84 -0
- pycodegate/rules/security.py +39 -0
- pycodegate/rules/sqlalchemy.py +145 -0
- pycodegate/rules/structure.py +18 -3
- pycodegate/scan.py +51 -10
- pycodegate/score.py +32 -16
- pycodegate/types.py +18 -1
- pycodegate/utils/ast_helpers.py +15 -0
- pycodegate/utils/badge.py +2 -2
- pycodegate/utils/file_discovery.py +22 -10
- pycodegate/utils/fixer.py +4 -4
- pycodegate/utils/is_test_file.py +22 -0
- pycodegate/utils/precommit.py +10 -13
- {pycodegate-0.2.0.dist-info → pycodegate-0.5.0.dist-info}/METADATA +77 -25
- pycodegate-0.5.0.dist-info/RECORD +50 -0
- {pycodegate-0.2.0.dist-info → pycodegate-0.5.0.dist-info}/WHEEL +1 -1
- pycodegate-0.2.0.dist-info/RECORD +0 -38
- {pycodegate-0.2.0.dist-info → pycodegate-0.5.0.dist-info}/entry_points.txt +0 -0
- {pycodegate-0.2.0.dist-info → pycodegate-0.5.0.dist-info}/licenses/LICENSE +0 -0
pycodegate/__init__.py
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""PyCodeGate — the quality gate for AI-generated Python code."""
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("pycodegate")
|
|
9
|
+
except PackageNotFoundError: # running from a source tree without an install
|
|
10
|
+
__version__ = "0.0.0+unknown"
|
pycodegate/__main__.py
ADDED
pycodegate/api.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Programmatic API for
|
|
1
|
+
"""Programmatic API for pycodegate."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -18,7 +18,7 @@ def diagnose(
|
|
|
18
18
|
dead_code: bool = True,
|
|
19
19
|
diff_base: str | None = None,
|
|
20
20
|
) -> ScanResult:
|
|
21
|
-
"""Run
|
|
21
|
+
"""Run pycodegate analysis programmatically."""
|
|
22
22
|
config = load_config(project_path)
|
|
23
23
|
config.lint = lint
|
|
24
24
|
config.dead_code = dead_code
|
pycodegate/cli.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""CLI entry point for
|
|
1
|
+
"""CLI entry point for pycodegate."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -58,15 +58,19 @@ def _maybe_run_fix(opts: CliOptions) -> None:
|
|
|
58
58
|
if not opts.fix:
|
|
59
59
|
return
|
|
60
60
|
fixes = run_ruff_fix(opts.directory)
|
|
61
|
+
# Progress goes to stderr so it never pollutes --json/--score/--sarif stdout.
|
|
61
62
|
if fixes == -1:
|
|
62
|
-
click.echo("ruff not found, skipping auto-fix")
|
|
63
|
+
click.echo("ruff not found, skipping auto-fix", err=True)
|
|
63
64
|
else:
|
|
64
|
-
click.echo(f"Fixed {fixes} issues via ruff")
|
|
65
|
+
click.echo(f"Fixed {fixes} issues via ruff", err=True)
|
|
65
66
|
|
|
66
67
|
|
|
67
68
|
def _build_config(opts: CliOptions):
|
|
68
69
|
"""Load config and apply CLI overrides."""
|
|
69
|
-
|
|
70
|
+
try:
|
|
71
|
+
config = load_config(opts.directory)
|
|
72
|
+
except ValueError as error:
|
|
73
|
+
raise click.ClickException(str(error)) from error
|
|
70
74
|
config.lint = opts.lint
|
|
71
75
|
config.dead_code = opts.dead_code
|
|
72
76
|
config.verbose = opts.verbose
|
pycodegate/config.py
CHANGED
|
@@ -6,6 +6,7 @@ from dataclasses import dataclass, field
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
from pycodegate._compat import tomllib
|
|
9
|
+
from pycodegate.types import ImportBoundary
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
@dataclass
|
|
@@ -19,6 +20,7 @@ class Config:
|
|
|
19
20
|
profile: str | None = None
|
|
20
21
|
per_file_suppress: dict[str, list[str]] = field(default_factory=dict)
|
|
21
22
|
max_deduction: dict[str, int] = field(default_factory=dict)
|
|
23
|
+
import_boundaries: list[ImportBoundary] = field(default_factory=list)
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
def load_config(project_path: str) -> Config:
|
|
@@ -53,8 +55,9 @@ def _parse_doctor_toml(path: Path) -> Config:
|
|
|
53
55
|
ignore_rules=ignore.get("rules", []),
|
|
54
56
|
ignore_files=ignore.get("files", []),
|
|
55
57
|
profile=options.get("profile", None),
|
|
56
|
-
per_file_suppress=data
|
|
57
|
-
max_deduction=data
|
|
58
|
+
per_file_suppress=_read_per_file_suppress(data),
|
|
59
|
+
max_deduction=_read_max_deduction(data),
|
|
60
|
+
import_boundaries=_read_import_boundaries(data),
|
|
58
61
|
)
|
|
59
62
|
|
|
60
63
|
|
|
@@ -76,6 +79,52 @@ def _parse_pyproject_toml(path: Path) -> Config:
|
|
|
76
79
|
ignore_rules=ignore.get("rules", []),
|
|
77
80
|
ignore_files=ignore.get("files", []),
|
|
78
81
|
profile=section.get("profile", None),
|
|
79
|
-
per_file_suppress=section
|
|
80
|
-
max_deduction=section
|
|
82
|
+
per_file_suppress=_read_per_file_suppress(section),
|
|
83
|
+
max_deduction=_read_max_deduction(section),
|
|
84
|
+
import_boundaries=_read_import_boundaries(section),
|
|
81
85
|
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_per_file_suppress(table: dict) -> dict:
|
|
89
|
+
"""Read per-file rule suppression, accepting the documented ``per-file-ignores`` alias."""
|
|
90
|
+
return table.get("per-file-suppress") or table.get("per-file-ignores") or {}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _read_import_boundaries(table: dict) -> list[ImportBoundary]:
|
|
94
|
+
boundaries = table.get("import-boundaries", [])
|
|
95
|
+
if not isinstance(boundaries, list):
|
|
96
|
+
raise ValueError("import-boundaries must be an array of tables")
|
|
97
|
+
result = []
|
|
98
|
+
for boundary in boundaries:
|
|
99
|
+
if not isinstance(boundary, dict):
|
|
100
|
+
raise ValueError("Each import boundary must be a table")
|
|
101
|
+
for key in ("files", "forbidden"):
|
|
102
|
+
values = boundary.get(key)
|
|
103
|
+
if not isinstance(values, list) or not values:
|
|
104
|
+
raise ValueError(f"Import boundary {key} must be a nonempty array of strings")
|
|
105
|
+
if any(not isinstance(value, str) or not value.strip() for value in values):
|
|
106
|
+
raise ValueError(f"Import boundary {key} must contain nonempty strings")
|
|
107
|
+
message = boundary.get("message", "Move shared code behind the allowed layer's API")
|
|
108
|
+
if any(
|
|
109
|
+
not all(part.isidentifier() for part in module.split("."))
|
|
110
|
+
for module in boundary["forbidden"]
|
|
111
|
+
):
|
|
112
|
+
raise ValueError("Import boundary forbidden entries must be dotted module names")
|
|
113
|
+
if not isinstance(message, str) or not message.strip():
|
|
114
|
+
raise ValueError("Import boundary message must be a nonempty string")
|
|
115
|
+
result.append(
|
|
116
|
+
ImportBoundary(tuple(boundary["files"]), tuple(boundary["forbidden"]), message)
|
|
117
|
+
)
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _read_max_deduction(table: dict) -> dict:
|
|
122
|
+
"""Read scoring max-deduction overrides.
|
|
123
|
+
|
|
124
|
+
Documented location is the ``[scoring]`` table (``max-deduction.Security = 20``);
|
|
125
|
+
the historical top-level ``[max-deduction]`` table is still accepted.
|
|
126
|
+
"""
|
|
127
|
+
scoring = table.get("scoring", {})
|
|
128
|
+
if isinstance(scoring, dict) and "max-deduction" in scoring:
|
|
129
|
+
return scoring["max-deduction"]
|
|
130
|
+
return table.get("max-deduction", {})
|
pycodegate/constants.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Constants and thresholds for
|
|
1
|
+
"""Constants and thresholds for pycodegate."""
|
|
2
2
|
|
|
3
3
|
from pycodegate.types import Category
|
|
4
4
|
|
|
@@ -21,12 +21,41 @@ CATEGORY_WEIGHTS: dict = {
|
|
|
21
21
|
Category.PERFORMANCE: 2,
|
|
22
22
|
Category.STRUCTURE: 2,
|
|
23
23
|
Category.IMPORTS: 1,
|
|
24
|
+
Category.DEPENDENCIES: 1,
|
|
24
25
|
Category.DEAD_CODE: 1,
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
# Rules suppressed in test files: idiomatic in tests, noise as findings.
|
|
29
|
+
# Asserts are the pytest idiom; tests are long, deeply nested, take many fixtures,
|
|
30
|
+
# and legitimately contain fixture "secrets", weak hashes, and pickle round-trips.
|
|
31
|
+
RULES_SUPPRESSED_IN_TESTS: frozenset[str] = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"no-assert-in-production",
|
|
34
|
+
"no-god-function",
|
|
35
|
+
"no-giant-module",
|
|
36
|
+
"no-deep-nesting",
|
|
37
|
+
"too-many-arguments",
|
|
38
|
+
"high-complexity",
|
|
39
|
+
"critical-complexity",
|
|
40
|
+
"structure/large-file",
|
|
41
|
+
"no-hardcoded-secret",
|
|
42
|
+
"no-weak-hash",
|
|
43
|
+
"no-pickle-load",
|
|
44
|
+
"no-string-concat-in-loop",
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
27
48
|
# Framework categories map to their parent category for scoring
|
|
28
49
|
FRAMEWORK_CATEGORY_MAP: dict = {
|
|
29
50
|
Category.DJANGO: Category.SECURITY,
|
|
30
51
|
Category.FASTAPI: Category.CORRECTNESS,
|
|
31
52
|
Category.FLASK: Category.SECURITY,
|
|
53
|
+
Category.PYDANTIC: Category.CORRECTNESS,
|
|
54
|
+
Category.SQLALCHEMY: Category.SECURITY,
|
|
55
|
+
Category.CELERY: Category.CORRECTNESS,
|
|
56
|
+
Category.REQUESTS: Category.SECURITY,
|
|
57
|
+
Category.LOGGING: Category.CORRECTNESS,
|
|
58
|
+
Category.PANDAS: Category.CORRECTNESS,
|
|
59
|
+
Category.PYTEST: Category.CORRECTNESS,
|
|
60
|
+
Category.NUMPY: Category.CORRECTNESS,
|
|
32
61
|
}
|
pycodegate/discover.py
CHANGED
|
@@ -23,6 +23,7 @@ def discover_project(project_path: str) -> ProjectInfo:
|
|
|
23
23
|
test_framework=_detect_test_framework(deps),
|
|
24
24
|
has_type_hints=_detect_type_hints(root),
|
|
25
25
|
source_file_count=len(find_python_files(project_path)),
|
|
26
|
+
frameworks=_detect_frameworks(deps),
|
|
26
27
|
)
|
|
27
28
|
|
|
28
29
|
|
|
@@ -76,6 +77,32 @@ def _detect_framework(deps: set[str]) -> str | None:
|
|
|
76
77
|
return None
|
|
77
78
|
|
|
78
79
|
|
|
80
|
+
def _detect_frameworks(deps: set[str]) -> list[str]:
|
|
81
|
+
"""Detect all frameworks and libraries present in project dependencies."""
|
|
82
|
+
found: list[str] = []
|
|
83
|
+
if "django" in deps or "django-rest-framework" in deps or "djangorestframework" in deps:
|
|
84
|
+
found.append("django")
|
|
85
|
+
if "fastapi" in deps:
|
|
86
|
+
found.append("fastapi")
|
|
87
|
+
if "flask" in deps:
|
|
88
|
+
found.append("flask")
|
|
89
|
+
if "pydantic" in deps:
|
|
90
|
+
found.append("pydantic")
|
|
91
|
+
if "sqlalchemy" in deps:
|
|
92
|
+
found.append("sqlalchemy")
|
|
93
|
+
if "celery" in deps:
|
|
94
|
+
found.append("celery")
|
|
95
|
+
if "requests" in deps or "httpx" in deps:
|
|
96
|
+
found.append("requests")
|
|
97
|
+
if "pandas" in deps:
|
|
98
|
+
found.append("pandas")
|
|
99
|
+
if "pytest" in deps:
|
|
100
|
+
found.append("pytest")
|
|
101
|
+
if "numpy" in deps:
|
|
102
|
+
found.append("numpy")
|
|
103
|
+
return found
|
|
104
|
+
|
|
105
|
+
|
|
79
106
|
def _detect_package_manager(root: Path) -> str | None:
|
|
80
107
|
if (root / "uv.lock").exists():
|
|
81
108
|
return "uv"
|
pycodegate/output.py
CHANGED
|
@@ -12,6 +12,7 @@ from rich.text import Text
|
|
|
12
12
|
|
|
13
13
|
from pycodegate import __version__
|
|
14
14
|
from pycodegate.constants import CATEGORY_WEIGHTS, FRAMEWORK_CATEGORY_MAP
|
|
15
|
+
from pycodegate.score import category_breakdown
|
|
15
16
|
from pycodegate.types import Category, Diagnostic, ScanResult, Severity
|
|
16
17
|
|
|
17
18
|
BAR_WIDTH = 50
|
|
@@ -78,42 +79,6 @@ def _score_color(score: int) -> str:
|
|
|
78
79
|
return "red"
|
|
79
80
|
|
|
80
81
|
|
|
81
|
-
def _compute_category_sub_scores(
|
|
82
|
-
diagnostics: list[Diagnostic],
|
|
83
|
-
) -> dict[Category, tuple[int, int]]:
|
|
84
|
-
"""Return {resolved_category: (earned, max)} for each category in CATEGORY_WEIGHTS."""
|
|
85
|
-
total_weight = sum(CATEGORY_WEIGHTS.values())
|
|
86
|
-
max_deductions = {cat: round(w / total_weight * 100) for cat, w in CATEGORY_WEIGHTS.items()}
|
|
87
|
-
# Fix rounding to ensure sum == 100
|
|
88
|
-
diff = 100 - sum(max_deductions.values())
|
|
89
|
-
if diff != 0:
|
|
90
|
-
highest = max(CATEGORY_WEIGHTS, key=lambda k: CATEGORY_WEIGHTS[k])
|
|
91
|
-
max_deductions[highest] += diff
|
|
92
|
-
|
|
93
|
-
# Group diagnostics by resolved category
|
|
94
|
-
by_category: dict[Category, list[Diagnostic]] = defaultdict(list)
|
|
95
|
-
for d in diagnostics:
|
|
96
|
-
resolved = FRAMEWORK_CATEGORY_MAP.get(d.category, d.category)
|
|
97
|
-
by_category[resolved].append(d)
|
|
98
|
-
|
|
99
|
-
# Compute actual deduction per category (same logic as score.py)
|
|
100
|
-
actual_deductions: dict[Category, float] = {}
|
|
101
|
-
for cat, diags in by_category.items():
|
|
102
|
-
costs = sorted([d.cost for d in diags], reverse=True)
|
|
103
|
-
cat_total = sum(c if i < 3 else c * 0.1 for i, c in enumerate(costs))
|
|
104
|
-
cap = max_deductions.get(cat, 10)
|
|
105
|
-
actual_deductions[cat] = min(cat_total, cap)
|
|
106
|
-
|
|
107
|
-
# Build (earned, max) per category
|
|
108
|
-
result: dict[Category, tuple[int, int]] = {}
|
|
109
|
-
for cat, max_ded in max_deductions.items():
|
|
110
|
-
deducted = round(actual_deductions.get(cat, 0.0))
|
|
111
|
-
earned = max_ded - deducted
|
|
112
|
-
result[cat] = (earned, max_ded)
|
|
113
|
-
|
|
114
|
-
return result
|
|
115
|
-
|
|
116
|
-
|
|
117
82
|
def format_summary(result: ScanResult) -> str:
|
|
118
83
|
"""Format the full scan summary as a string (for testing)."""
|
|
119
84
|
lines: list[str] = []
|
|
@@ -152,8 +117,9 @@ def print_scan_result(result: ScanResult, verbose: bool = False) -> None:
|
|
|
152
117
|
console.print(f" [dim]Source files:[/dim] {p.source_file_count}")
|
|
153
118
|
console.print()
|
|
154
119
|
|
|
155
|
-
# Category-grouped diagnostics with sub-scores
|
|
156
|
-
|
|
120
|
+
# Category-grouped diagnostics with sub-scores (single source of truth from scoring)
|
|
121
|
+
breakdown = result.category_scores or category_breakdown(result.diagnostics)
|
|
122
|
+
_print_category_groups(console, result.diagnostics, breakdown, verbose)
|
|
157
123
|
|
|
158
124
|
# Summary panel
|
|
159
125
|
color = _score_color(result.score.value)
|
|
@@ -187,6 +153,7 @@ def output_json(result: ScanResult) -> None:
|
|
|
187
153
|
"path": p.path,
|
|
188
154
|
"score": result.score.value,
|
|
189
155
|
"label": result.score.label,
|
|
156
|
+
"profile": result.profile,
|
|
190
157
|
"errors": errors,
|
|
191
158
|
"warnings": warnings,
|
|
192
159
|
"elapsed_ms": result.elapsed_ms,
|
|
@@ -231,6 +198,18 @@ def _sarif_rules(diagnostics: list[Diagnostic]) -> list[dict]:
|
|
|
231
198
|
]
|
|
232
199
|
|
|
233
200
|
|
|
201
|
+
def _sarif_location(diag: Diagnostic) -> dict:
|
|
202
|
+
"""Build a SARIF physicalLocation, omitting the region for project-level findings.
|
|
203
|
+
|
|
204
|
+
SARIF 2.1.0 requires region.startLine >= 1, so a line of 0 (project-level
|
|
205
|
+
diagnostics) points at the artifact only rather than an invalid line 0.
|
|
206
|
+
"""
|
|
207
|
+
physical: dict = {"artifactLocation": {"uri": diag.file_path}}
|
|
208
|
+
if diag.line and diag.line >= 1:
|
|
209
|
+
physical["region"] = {"startLine": diag.line, "startColumn": (diag.column or 0) + 1}
|
|
210
|
+
return {"physicalLocation": physical}
|
|
211
|
+
|
|
212
|
+
|
|
234
213
|
def _sarif_results(diagnostics: list[Diagnostic]) -> list[dict]:
|
|
235
214
|
"""Build SARIF results array from diagnostics."""
|
|
236
215
|
return [
|
|
@@ -238,17 +217,7 @@ def _sarif_results(diagnostics: list[Diagnostic]) -> list[dict]:
|
|
|
238
217
|
"ruleId": d.rule,
|
|
239
218
|
"level": "error" if d.severity == Severity.ERROR else "warning",
|
|
240
219
|
"message": {"text": d.message},
|
|
241
|
-
"locations": [
|
|
242
|
-
{
|
|
243
|
-
"physicalLocation": {
|
|
244
|
-
"artifactLocation": {"uri": d.file_path},
|
|
245
|
-
"region": {
|
|
246
|
-
"startLine": d.line,
|
|
247
|
-
"startColumn": (d.column or 0) + 1,
|
|
248
|
-
},
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
],
|
|
220
|
+
"locations": [_sarif_location(d)],
|
|
252
221
|
}
|
|
253
222
|
for d in diagnostics
|
|
254
223
|
]
|
|
@@ -319,11 +288,10 @@ def _print_category_with_issues(
|
|
|
319
288
|
def _print_category_groups(
|
|
320
289
|
console: Console,
|
|
321
290
|
diagnostics: list[Diagnostic],
|
|
291
|
+
sub_scores: dict[Category, tuple[int, int]],
|
|
322
292
|
verbose: bool,
|
|
323
293
|
) -> None:
|
|
324
294
|
"""Print diagnostics grouped by category with sub-scores."""
|
|
325
|
-
sub_scores = _compute_category_sub_scores(diagnostics)
|
|
326
|
-
|
|
327
295
|
# Build rule-grouped diagnostics per resolved category
|
|
328
296
|
by_category: dict[Category, list[Diagnostic]] = defaultdict(list)
|
|
329
297
|
for d in diagnostics:
|
|
@@ -338,7 +306,7 @@ def _print_category_groups(
|
|
|
338
306
|
earned, maximum = sub_scores.get(cat, (CATEGORY_WEIGHTS[cat], CATEGORY_WEIGHTS[cat]))
|
|
339
307
|
cat_diags = by_category.get(cat, [])
|
|
340
308
|
|
|
341
|
-
if
|
|
309
|
+
if not cat_diags:
|
|
342
310
|
console.print(
|
|
343
311
|
f" [bold]{emoji} {name}[/bold] "
|
|
344
312
|
f"[green]({earned}/{maximum})[/green] [green]✓[/green]"
|
pycodegate/profile.py
CHANGED
|
@@ -20,7 +20,7 @@ PROFILES = {
|
|
|
20
20
|
"web": Profile(name="web"),
|
|
21
21
|
"cli": Profile(
|
|
22
22
|
name="cli",
|
|
23
|
-
suppressed_rules=frozenset({"no-
|
|
23
|
+
suppressed_rules=frozenset({"no-os-system", "no-subprocess-shell"}),
|
|
24
24
|
max_deduction_overrides={"Security": 15},
|
|
25
25
|
),
|
|
26
26
|
"library": Profile(name="library"),
|
pycodegate/py.typed
ADDED
|
File without changes
|
pycodegate/rules/__init__.py
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
1
|
-
"""Rule registry for
|
|
1
|
+
"""Rule registry for pycodegate."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from pycodegate.rules.architecture import ArchitectureRules
|
|
6
6
|
from pycodegate.rules.base import BaseRules
|
|
7
|
+
from pycodegate.rules.celery import CeleryRules
|
|
7
8
|
from pycodegate.rules.complexity import ComplexityRules
|
|
8
9
|
from pycodegate.rules.correctness import CorrectnessRules
|
|
9
10
|
from pycodegate.rules.dependencies import DependencyRules as DependencyRules
|
|
10
11
|
from pycodegate.rules.django import DjangoRules
|
|
11
12
|
from pycodegate.rules.fastapi import FastAPIRules
|
|
12
13
|
from pycodegate.rules.flask import FlaskRules
|
|
14
|
+
from pycodegate.rules.import_boundaries import ImportBoundaryRules as ImportBoundaryRules
|
|
13
15
|
from pycodegate.rules.imports import ImportsRules as ImportsRules
|
|
16
|
+
from pycodegate.rules.logging_rules import LoggingRules
|
|
17
|
+
from pycodegate.rules.numpy_rules import NumpyRules
|
|
18
|
+
from pycodegate.rules.pandas_rules import PandasRules
|
|
14
19
|
from pycodegate.rules.performance import PerformanceRules
|
|
20
|
+
from pycodegate.rules.pydantic import PydanticRules
|
|
21
|
+
from pycodegate.rules.pytest_rules import PytestRules
|
|
22
|
+
from pycodegate.rules.requests_rules import RequestsRules
|
|
15
23
|
from pycodegate.rules.security import SecurityRules
|
|
24
|
+
from pycodegate.rules.sqlalchemy import SQLAlchemyRules
|
|
16
25
|
from pycodegate.rules.structure import StructureRules as StructureRules
|
|
17
26
|
|
|
18
27
|
|
|
19
28
|
def get_all_rule_sets() -> list[BaseRules]:
|
|
20
|
-
"""Return
|
|
29
|
+
"""Return the core rule sets that run on every Python file."""
|
|
21
30
|
return [
|
|
22
31
|
SecurityRules(),
|
|
23
32
|
PerformanceRules(),
|
|
@@ -27,12 +36,23 @@ def get_all_rule_sets() -> list[BaseRules]:
|
|
|
27
36
|
]
|
|
28
37
|
|
|
29
38
|
|
|
30
|
-
def
|
|
31
|
-
"""Return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
def get_import_gated_rules() -> list[tuple[BaseRules, str]]:
|
|
40
|
+
"""Return (rule_set, trigger_module) pairs.
|
|
41
|
+
|
|
42
|
+
Each library rule set runs only on files that actually import its module, so a
|
|
43
|
+
pandas rule never fires on a file that does not touch pandas — activation is
|
|
44
|
+
per-file and import-based rather than project-wide from the dependency manifest.
|
|
45
|
+
"""
|
|
46
|
+
return [
|
|
47
|
+
(DjangoRules(), "django"),
|
|
48
|
+
(FastAPIRules(), "fastapi"),
|
|
49
|
+
(FlaskRules(), "flask"),
|
|
50
|
+
(PydanticRules(), "pydantic"),
|
|
51
|
+
(SQLAlchemyRules(), "sqlalchemy"),
|
|
52
|
+
(CeleryRules(), "celery"),
|
|
53
|
+
(RequestsRules(), "requests"),
|
|
54
|
+
(LoggingRules(), "logging"),
|
|
55
|
+
(PandasRules(), "pandas"),
|
|
56
|
+
(NumpyRules(), "numpy"),
|
|
57
|
+
(PytestRules(), "pytest"),
|
|
58
|
+
]
|
pycodegate/rules/architecture.py
CHANGED
|
@@ -51,10 +51,25 @@ class ArchitectureRules(BaseRules):
|
|
|
51
51
|
return diags
|
|
52
52
|
|
|
53
53
|
def _walk_nesting(
|
|
54
|
-
self,
|
|
54
|
+
self,
|
|
55
|
+
node: ast.AST,
|
|
56
|
+
depth: int,
|
|
57
|
+
filename: str,
|
|
58
|
+
diags: list[Diagnostic],
|
|
59
|
+
is_elif: bool = False,
|
|
55
60
|
) -> None:
|
|
56
|
-
nesting_nodes = (
|
|
57
|
-
|
|
61
|
+
nesting_nodes = (
|
|
62
|
+
ast.If,
|
|
63
|
+
ast.For,
|
|
64
|
+
ast.AsyncFor,
|
|
65
|
+
ast.While,
|
|
66
|
+
ast.With,
|
|
67
|
+
ast.AsyncWith,
|
|
68
|
+
ast.Try,
|
|
69
|
+
)
|
|
70
|
+
# An `elif` continues its parent `if` at the same nesting level, so it must
|
|
71
|
+
# not deepen the count — only a true block does.
|
|
72
|
+
if isinstance(node, nesting_nodes) and not is_elif:
|
|
58
73
|
depth += 1
|
|
59
74
|
if depth >= MAX_NESTING_DEPTH:
|
|
60
75
|
diags.append(
|
|
@@ -70,6 +85,18 @@ class ArchitectureRules(BaseRules):
|
|
|
70
85
|
cost=1.0,
|
|
71
86
|
)
|
|
72
87
|
)
|
|
88
|
+
|
|
89
|
+
if isinstance(node, ast.If):
|
|
90
|
+
for child in node.body:
|
|
91
|
+
self._walk_nesting(child, depth, filename, diags)
|
|
92
|
+
if len(node.orelse) == 1 and isinstance(node.orelse[0], ast.If):
|
|
93
|
+
# `elif`: same level, continue the chain without deepening.
|
|
94
|
+
self._walk_nesting(node.orelse[0], depth, filename, diags, is_elif=True)
|
|
95
|
+
else:
|
|
96
|
+
for child in node.orelse:
|
|
97
|
+
self._walk_nesting(child, depth, filename, diags)
|
|
98
|
+
return
|
|
99
|
+
|
|
73
100
|
for child in ast.iter_child_nodes(node):
|
|
74
101
|
self._walk_nesting(child, depth, filename, diags)
|
|
75
102
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Celery-specific rules: missing bind, retry without exc, broad autoretry, direct call."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
|
|
7
|
+
from pycodegate.rules.base import BaseRules
|
|
8
|
+
from pycodegate.types import Category, Diagnostic, Severity
|
|
9
|
+
|
|
10
|
+
_TASK_DECORATOR_ATTRS = {"task"}
|
|
11
|
+
_TASK_DECORATOR_NAMES = {"shared_task"}
|
|
12
|
+
_TASK_CALLER_PREFIXES = {"app", "celery"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _is_task_decorator(dec: ast.expr) -> bool:
|
|
16
|
+
"""Return True if *dec* looks like @app.task, @celery.task, or @shared_task."""
|
|
17
|
+
if isinstance(dec, ast.Attribute) and dec.attr in _TASK_DECORATOR_ATTRS:
|
|
18
|
+
if isinstance(dec.value, ast.Name) and dec.value.id in _TASK_CALLER_PREFIXES:
|
|
19
|
+
return True
|
|
20
|
+
if isinstance(dec, ast.Name) and dec.id in _TASK_DECORATOR_NAMES:
|
|
21
|
+
return True
|
|
22
|
+
if isinstance(dec, ast.Call):
|
|
23
|
+
return _is_task_decorator(dec.func)
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _decorator_call(dec: ast.expr) -> ast.Call | None:
|
|
28
|
+
"""Return the Call node if the decorator is called, else None."""
|
|
29
|
+
if isinstance(dec, ast.Call):
|
|
30
|
+
return dec
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CeleryRules(BaseRules):
|
|
35
|
+
"""Celery framework-specific checks."""
|
|
36
|
+
|
|
37
|
+
def check(self, source: str, filename: str) -> list[Diagnostic]:
|
|
38
|
+
tree = self._parse(source)
|
|
39
|
+
if tree is None:
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
diags: list[Diagnostic] = []
|
|
43
|
+
|
|
44
|
+
# First pass: collect task function names for direct-call detection.
|
|
45
|
+
task_names: set[str] = set()
|
|
46
|
+
for node in ast.walk(tree):
|
|
47
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
48
|
+
for dec in node.decorator_list:
|
|
49
|
+
if _is_task_decorator(dec):
|
|
50
|
+
task_names.add(node.name)
|
|
51
|
+
|
|
52
|
+
# Second pass: all checks.
|
|
53
|
+
for node in ast.walk(tree):
|
|
54
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
55
|
+
diags.extend(self._check_task_func(node, filename))
|
|
56
|
+
elif isinstance(node, ast.ExceptHandler):
|
|
57
|
+
diags.extend(self._check_retry_no_exc(node, filename))
|
|
58
|
+
elif isinstance(node, ast.Call):
|
|
59
|
+
diags.extend(self._check_direct_call(node, task_names, filename))
|
|
60
|
+
|
|
61
|
+
return diags
|
|
62
|
+
|
|
63
|
+
def _check_task_func(self, node, filename) -> list[Diagnostic]:
|
|
64
|
+
"""Run task-decorator checks on a single function node."""
|
|
65
|
+
diags: list[Diagnostic] = []
|
|
66
|
+
for dec in node.decorator_list:
|
|
67
|
+
if not _is_task_decorator(dec):
|
|
68
|
+
continue
|
|
69
|
+
diags.extend(self._check_missing_bind(node, dec, filename))
|
|
70
|
+
call = _decorator_call(dec)
|
|
71
|
+
if call is not None:
|
|
72
|
+
diags.extend(self._check_broad_autoretry(node, call, filename))
|
|
73
|
+
return diags
|
|
74
|
+
|
|
75
|
+
def _check_missing_bind(self, func_node, decorator, filename) -> list[Diagnostic]:
|
|
76
|
+
args = func_node.args
|
|
77
|
+
first_arg = args.args[0].arg if args.args else None
|
|
78
|
+
if first_arg != "self":
|
|
79
|
+
return []
|
|
80
|
+
|
|
81
|
+
call = _decorator_call(decorator)
|
|
82
|
+
if call is not None and any(
|
|
83
|
+
kw.arg == "bind" and isinstance(kw.value, ast.Constant) and kw.value.value is True
|
|
84
|
+
for kw in call.keywords
|
|
85
|
+
):
|
|
86
|
+
return []
|
|
87
|
+
|
|
88
|
+
return [
|
|
89
|
+
Diagnostic(
|
|
90
|
+
file_path=filename,
|
|
91
|
+
rule="celery-missing-bind",
|
|
92
|
+
severity=Severity.ERROR,
|
|
93
|
+
category=Category.CELERY,
|
|
94
|
+
message=f"Task '{func_node.name}' uses 'self' but decorator is missing bind=True",
|
|
95
|
+
help="Add bind=True to the task decorator",
|
|
96
|
+
line=func_node.lineno,
|
|
97
|
+
column=func_node.col_offset,
|
|
98
|
+
cost=2.0,
|
|
99
|
+
)
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
def _check_retry_no_exc(self, handler: ast.ExceptHandler, filename: str) -> list[Diagnostic]:
|
|
103
|
+
diags: list[Diagnostic] = []
|
|
104
|
+
for node in ast.walk(handler):
|
|
105
|
+
if not isinstance(node, ast.Call):
|
|
106
|
+
continue
|
|
107
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == "retry":
|
|
108
|
+
if isinstance(node.func.value, ast.Name) and node.func.value.id == "self":
|
|
109
|
+
has_exc = any(kw.arg == "exc" for kw in node.keywords)
|
|
110
|
+
if not has_exc:
|
|
111
|
+
diags.append(
|
|
112
|
+
Diagnostic(
|
|
113
|
+
file_path=filename,
|
|
114
|
+
rule="celery-retry-no-exc",
|
|
115
|
+
severity=Severity.WARNING,
|
|
116
|
+
category=Category.CELERY,
|
|
117
|
+
message="self.retry() called without exc keyword — original traceback is lost",
|
|
118
|
+
help="Pass exc=exc to self.retry() to preserve the traceback",
|
|
119
|
+
line=node.lineno,
|
|
120
|
+
column=node.col_offset,
|
|
121
|
+
cost=1.0,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
return diags
|
|
125
|
+
|
|
126
|
+
def _check_broad_autoretry(self, func_node, call: ast.Call, filename: str) -> list[Diagnostic]:
|
|
127
|
+
for kw in call.keywords:
|
|
128
|
+
if kw.arg != "autoretry_for":
|
|
129
|
+
continue
|
|
130
|
+
if isinstance(kw.value, ast.Tuple):
|
|
131
|
+
for elt in kw.value.elts:
|
|
132
|
+
if isinstance(elt, ast.Name) and elt.id in {"Exception", "BaseException"}:
|
|
133
|
+
return [
|
|
134
|
+
Diagnostic(
|
|
135
|
+
file_path=filename,
|
|
136
|
+
rule="celery-broad-autoretry",
|
|
137
|
+
severity=Severity.WARNING,
|
|
138
|
+
category=Category.CELERY,
|
|
139
|
+
message=f"Task '{func_node.name}' uses autoretry_for with broad {elt.id}",
|
|
140
|
+
help="Narrow autoretry_for to specific exception types",
|
|
141
|
+
line=kw.value.lineno,
|
|
142
|
+
column=kw.value.col_offset,
|
|
143
|
+
cost=1.0,
|
|
144
|
+
)
|
|
145
|
+
]
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
def _check_direct_call(
|
|
149
|
+
self, node: ast.Call, task_names: set[str], filename: str
|
|
150
|
+
) -> list[Diagnostic]:
|
|
151
|
+
if isinstance(node.func, ast.Name) and node.func.id in task_names:
|
|
152
|
+
return [
|
|
153
|
+
Diagnostic(
|
|
154
|
+
file_path=filename,
|
|
155
|
+
rule="celery-direct-call",
|
|
156
|
+
severity=Severity.WARNING,
|
|
157
|
+
category=Category.CELERY,
|
|
158
|
+
message=f"Task '{node.func.id}' called directly — runs synchronously, not via worker",
|
|
159
|
+
help="Use .delay() or .apply_async() to dispatch to a Celery worker",
|
|
160
|
+
line=node.lineno,
|
|
161
|
+
column=node.col_offset,
|
|
162
|
+
cost=0.5,
|
|
163
|
+
)
|
|
164
|
+
]
|
|
165
|
+
return []
|
pycodegate/rules/complexity.py
CHANGED
|
@@ -21,6 +21,7 @@ def _cyclomatic_complexity(func_node: ast.FunctionDef | ast.AsyncFunctionDef) ->
|
|
|
21
21
|
- and / or boolean operators
|
|
22
22
|
- assert statements
|
|
23
23
|
- ternary expressions (IfExp)
|
|
24
|
+
- match/case arms
|
|
24
25
|
"""
|
|
25
26
|
complexity = 1
|
|
26
27
|
for node in ast.walk(func_node):
|
|
@@ -33,7 +34,7 @@ def _cyclomatic_complexity(func_node: ast.FunctionDef | ast.AsyncFunctionDef) ->
|
|
|
33
34
|
complexity += 1
|
|
34
35
|
elif isinstance(node, ast.BoolOp):
|
|
35
36
|
complexity += len(node.values) - 1
|
|
36
|
-
elif isinstance(node, (ast.Assert, ast.IfExp)):
|
|
37
|
+
elif isinstance(node, (ast.Assert, ast.IfExp, ast.match_case)):
|
|
37
38
|
complexity += 1
|
|
38
39
|
return complexity
|
|
39
40
|
|