pycodegate 0.3.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 +22 -1
- pycodegate/output.py +20 -52
- pycodegate/profile.py +1 -1
- pycodegate/py.typed +0 -0
- pycodegate/rules/__init__.py +21 -18
- pycodegate/rules/architecture.py +30 -3
- pycodegate/rules/celery.py +0 -12
- pycodegate/rules/complexity.py +2 -1
- pycodegate/rules/correctness.py +36 -44
- 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 +12 -0
- pycodegate/rules/logging_rules.py +49 -8
- pycodegate/rules/numpy_rules.py +0 -12
- pycodegate/rules/pandas_rules.py +44 -16
- pycodegate/rules/performance.py +67 -54
- pycodegate/rules/pydantic.py +0 -28
- pycodegate/rules/pytest_rules.py +0 -12
- pycodegate/rules/requests_rules.py +21 -105
- pycodegate/rules/security.py +39 -0
- pycodegate/rules/sqlalchemy.py +2 -17
- pycodegate/rules/structure.py +18 -3
- pycodegate/scan.py +51 -10
- pycodegate/score.py +32 -16
- pycodegate/types.py +9 -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.3.0.dist-info → pycodegate-0.5.0.dist-info}/METADATA +77 -25
- pycodegate-0.5.0.dist-info/RECORD +50 -0
- {pycodegate-0.3.0.dist-info → pycodegate-0.5.0.dist-info}/WHEEL +1 -1
- pycodegate-0.3.0.dist-info/RECORD +0 -46
- {pycodegate-0.3.0.dist-info → pycodegate-0.5.0.dist-info}/entry_points.txt +0 -0
- {pycodegate-0.3.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,9 +21,30 @@ 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,
|
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
|
@@ -11,6 +11,7 @@ from pycodegate.rules.dependencies import DependencyRules as DependencyRules
|
|
|
11
11
|
from pycodegate.rules.django import DjangoRules
|
|
12
12
|
from pycodegate.rules.fastapi import FastAPIRules
|
|
13
13
|
from pycodegate.rules.flask import FlaskRules
|
|
14
|
+
from pycodegate.rules.import_boundaries import ImportBoundaryRules as ImportBoundaryRules
|
|
14
15
|
from pycodegate.rules.imports import ImportsRules as ImportsRules
|
|
15
16
|
from pycodegate.rules.logging_rules import LoggingRules
|
|
16
17
|
from pycodegate.rules.numpy_rules import NumpyRules
|
|
@@ -25,7 +26,7 @@ from pycodegate.rules.structure import StructureRules as StructureRules
|
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
def get_all_rule_sets() -> list[BaseRules]:
|
|
28
|
-
"""Return
|
|
29
|
+
"""Return the core rule sets that run on every Python file."""
|
|
29
30
|
return [
|
|
30
31
|
SecurityRules(),
|
|
31
32
|
PerformanceRules(),
|
|
@@ -35,21 +36,23 @@ def get_all_rule_sets() -> list[BaseRules]:
|
|
|
35
36
|
]
|
|
36
37
|
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
"
|
|
40
|
-
"fastapi": FastAPIRules,
|
|
41
|
-
"flask": FlaskRules,
|
|
42
|
-
"pydantic": PydanticRules,
|
|
43
|
-
"sqlalchemy": SQLAlchemyRules,
|
|
44
|
-
"celery": CeleryRules,
|
|
45
|
-
"requests": RequestsRules,
|
|
46
|
-
"logging": LoggingRules,
|
|
47
|
-
"pandas": PandasRules,
|
|
48
|
-
"pytest": PytestRules,
|
|
49
|
-
"numpy": NumpyRules,
|
|
50
|
-
}
|
|
39
|
+
def get_import_gated_rules() -> list[tuple[BaseRules, str]]:
|
|
40
|
+
"""Return (rule_set, trigger_module) pairs.
|
|
51
41
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
pycodegate/rules/celery.py
CHANGED
|
@@ -72,9 +72,6 @@ class CeleryRules(BaseRules):
|
|
|
72
72
|
diags.extend(self._check_broad_autoretry(node, call, filename))
|
|
73
73
|
return diags
|
|
74
74
|
|
|
75
|
-
# ------------------------------------------------------------------
|
|
76
|
-
# Rule: celery-missing-bind
|
|
77
|
-
# ------------------------------------------------------------------
|
|
78
75
|
def _check_missing_bind(self, func_node, decorator, filename) -> list[Diagnostic]:
|
|
79
76
|
args = func_node.args
|
|
80
77
|
first_arg = args.args[0].arg if args.args else None
|
|
@@ -102,9 +99,6 @@ class CeleryRules(BaseRules):
|
|
|
102
99
|
)
|
|
103
100
|
]
|
|
104
101
|
|
|
105
|
-
# ------------------------------------------------------------------
|
|
106
|
-
# Rule: celery-retry-no-exc
|
|
107
|
-
# ------------------------------------------------------------------
|
|
108
102
|
def _check_retry_no_exc(self, handler: ast.ExceptHandler, filename: str) -> list[Diagnostic]:
|
|
109
103
|
diags: list[Diagnostic] = []
|
|
110
104
|
for node in ast.walk(handler):
|
|
@@ -129,9 +123,6 @@ class CeleryRules(BaseRules):
|
|
|
129
123
|
)
|
|
130
124
|
return diags
|
|
131
125
|
|
|
132
|
-
# ------------------------------------------------------------------
|
|
133
|
-
# Rule: celery-broad-autoretry
|
|
134
|
-
# ------------------------------------------------------------------
|
|
135
126
|
def _check_broad_autoretry(self, func_node, call: ast.Call, filename: str) -> list[Diagnostic]:
|
|
136
127
|
for kw in call.keywords:
|
|
137
128
|
if kw.arg != "autoretry_for":
|
|
@@ -154,9 +145,6 @@ class CeleryRules(BaseRules):
|
|
|
154
145
|
]
|
|
155
146
|
return []
|
|
156
147
|
|
|
157
|
-
# ------------------------------------------------------------------
|
|
158
|
-
# Rule: celery-direct-call
|
|
159
|
-
# ------------------------------------------------------------------
|
|
160
148
|
def _check_direct_call(
|
|
161
149
|
self, node: ast.Call, task_names: set[str], filename: str
|
|
162
150
|
) -> list[Diagnostic]:
|
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
|
|
pycodegate/rules/correctness.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Correctness rules: mutable defaults, bare except,
|
|
1
|
+
"""Correctness rules: mutable defaults, bare except, assert in prod, return in __init__."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -19,7 +19,6 @@ class CorrectnessRules(BaseRules):
|
|
|
19
19
|
diags: list[Diagnostic] = []
|
|
20
20
|
diags.extend(self._check_mutable_defaults(tree, filename))
|
|
21
21
|
diags.extend(self._check_bare_except(tree, filename))
|
|
22
|
-
diags.extend(self._check_broad_exception(tree, filename))
|
|
23
22
|
diags.extend(self._check_assert_in_production(tree, filename))
|
|
24
23
|
diags.extend(self._check_return_in_init(tree, filename))
|
|
25
24
|
return diags
|
|
@@ -28,21 +27,23 @@ class CorrectnessRules(BaseRules):
|
|
|
28
27
|
diags: list[Diagnostic] = []
|
|
29
28
|
for node in ast.walk(tree):
|
|
30
29
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
30
|
+
defaults = node.args.defaults + node.args.kw_defaults
|
|
31
|
+
if any(
|
|
32
|
+
d is not None and isinstance(d, (ast.List, ast.Dict, ast.Set)) for d in defaults
|
|
33
|
+
):
|
|
34
|
+
diags.append(
|
|
35
|
+
Diagnostic(
|
|
36
|
+
file_path=filename,
|
|
37
|
+
rule="no-mutable-default",
|
|
38
|
+
severity=Severity.ERROR,
|
|
39
|
+
category=Category.CORRECTNESS,
|
|
40
|
+
message="Mutable default argument — shared across all calls",
|
|
41
|
+
help="Use None as default and create the mutable inside the function body",
|
|
42
|
+
line=node.lineno,
|
|
43
|
+
column=node.col_offset,
|
|
44
|
+
cost=2.0,
|
|
45
45
|
)
|
|
46
|
+
)
|
|
46
47
|
return diags
|
|
47
48
|
|
|
48
49
|
def _check_bare_except(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
@@ -64,33 +65,7 @@ class CorrectnessRules(BaseRules):
|
|
|
64
65
|
)
|
|
65
66
|
return diags
|
|
66
67
|
|
|
67
|
-
def _check_broad_exception(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
68
|
-
diags: list[Diagnostic] = []
|
|
69
|
-
for node in ast.walk(tree):
|
|
70
|
-
if isinstance(node, ast.ExceptHandler) and node.type is not None:
|
|
71
|
-
if isinstance(node.type, ast.Name) and node.type.id in (
|
|
72
|
-
"Exception",
|
|
73
|
-
"BaseException",
|
|
74
|
-
):
|
|
75
|
-
diags.append(
|
|
76
|
-
Diagnostic(
|
|
77
|
-
file_path=filename,
|
|
78
|
-
rule="no-broad-exception",
|
|
79
|
-
severity=Severity.WARNING,
|
|
80
|
-
category=Category.CORRECTNESS,
|
|
81
|
-
message=f"Catching '{node.type.id}' is too broad — masks real errors",
|
|
82
|
-
help="Catch specific exception types (ValueError, TypeError, etc.)",
|
|
83
|
-
line=node.lineno,
|
|
84
|
-
column=node.col_offset,
|
|
85
|
-
cost=1.0,
|
|
86
|
-
)
|
|
87
|
-
)
|
|
88
|
-
return diags
|
|
89
|
-
|
|
90
68
|
def _check_assert_in_production(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
91
|
-
if filename.startswith("test_") or "/test_" in filename or filename.endswith("_test.py"):
|
|
92
|
-
return []
|
|
93
|
-
|
|
94
69
|
diags: list[Diagnostic] = []
|
|
95
70
|
for node in ast.walk(tree):
|
|
96
71
|
if isinstance(node, ast.Assert):
|
|
@@ -113,8 +88,8 @@ class CorrectnessRules(BaseRules):
|
|
|
113
88
|
diags: list[Diagnostic] = []
|
|
114
89
|
for node in ast.walk(tree):
|
|
115
90
|
if isinstance(node, ast.FunctionDef) and node.name == "__init__":
|
|
116
|
-
for child in
|
|
117
|
-
if
|
|
91
|
+
for child in self._returns_in_scope(node):
|
|
92
|
+
if child.value is not None:
|
|
118
93
|
diags.append(
|
|
119
94
|
Diagnostic(
|
|
120
95
|
file_path=filename,
|
|
@@ -129,3 +104,20 @@ class CorrectnessRules(BaseRules):
|
|
|
129
104
|
)
|
|
130
105
|
)
|
|
131
106
|
return diags
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _returns_in_scope(func: ast.FunctionDef) -> list[ast.Return]:
|
|
110
|
+
"""Return statements belonging to *func* directly, not to nested scopes."""
|
|
111
|
+
returns: list[ast.Return] = []
|
|
112
|
+
nested_scopes = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.ClassDef)
|
|
113
|
+
|
|
114
|
+
def visit(node: ast.AST) -> None:
|
|
115
|
+
for child in ast.iter_child_nodes(node):
|
|
116
|
+
if isinstance(child, nested_scopes):
|
|
117
|
+
continue # a nested scope owns its own return statements
|
|
118
|
+
if isinstance(child, ast.Return):
|
|
119
|
+
returns.append(child)
|
|
120
|
+
visit(child)
|
|
121
|
+
|
|
122
|
+
visit(func)
|
|
123
|
+
return returns
|
pycodegate/rules/django.py
CHANGED
|
@@ -3,10 +3,26 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import ast
|
|
6
|
+
import re
|
|
6
7
|
|
|
7
8
|
from pycodegate.rules.base import BaseRules
|
|
8
9
|
from pycodegate.types import Category, Diagnostic, Severity
|
|
9
10
|
|
|
11
|
+
_SQL_PATTERN = re.compile(
|
|
12
|
+
r"(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER)\s",
|
|
13
|
+
re.IGNORECASE,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _binop_has_sql_literal(node: ast.AST) -> bool:
|
|
18
|
+
"""True if any string literal inside a BinOp tree looks like SQL."""
|
|
19
|
+
return any(
|
|
20
|
+
isinstance(child, ast.Constant)
|
|
21
|
+
and isinstance(child.value, str)
|
|
22
|
+
and _SQL_PATTERN.search(child.value)
|
|
23
|
+
for child in ast.walk(node)
|
|
24
|
+
)
|
|
25
|
+
|
|
10
26
|
|
|
11
27
|
class DjangoRules(BaseRules):
|
|
12
28
|
"""Django framework-specific checks."""
|
|
@@ -29,7 +45,11 @@ class DjangoRules(BaseRules):
|
|
|
29
45
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
|
30
46
|
if node.func.attr == "execute" and node.args:
|
|
31
47
|
arg = node.args[0]
|
|
32
|
-
if
|
|
48
|
+
if (
|
|
49
|
+
isinstance(arg, ast.BinOp)
|
|
50
|
+
and isinstance(arg.op, ast.Add)
|
|
51
|
+
and _binop_has_sql_literal(arg)
|
|
52
|
+
):
|
|
33
53
|
diags.append(
|
|
34
54
|
Diagnostic(
|
|
35
55
|
file_path=filename,
|
|
@@ -78,6 +98,8 @@ class DjangoRules(BaseRules):
|
|
|
78
98
|
for node in ast.walk(tree):
|
|
79
99
|
if not isinstance(node, ast.For) or not self._is_queryset_iter(node.iter):
|
|
80
100
|
continue
|
|
101
|
+
if self._uses_eager_loading(node.iter):
|
|
102
|
+
continue # select_related/prefetch_related already prevents the N+1
|
|
81
103
|
if not isinstance(node.target, ast.Name):
|
|
82
104
|
continue
|
|
83
105
|
var_name = node.target.id
|
|
@@ -97,6 +119,16 @@ class DjangoRules(BaseRules):
|
|
|
97
119
|
)
|
|
98
120
|
return diags
|
|
99
121
|
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _uses_eager_loading(node: ast.expr) -> bool:
|
|
124
|
+
"""True if any call in the queryset chain is select_related/prefetch_related."""
|
|
125
|
+
current = node
|
|
126
|
+
while isinstance(current, ast.Call) and isinstance(current.func, ast.Attribute):
|
|
127
|
+
if current.func.attr in ("select_related", "prefetch_related"):
|
|
128
|
+
return True
|
|
129
|
+
current = current.func.value
|
|
130
|
+
return False
|
|
131
|
+
|
|
100
132
|
@staticmethod
|
|
101
133
|
def _has_related_access(loop_node: ast.For, var_name: str) -> bool:
|
|
102
134
|
for child in ast.walk(loop_node):
|