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.
Files changed (46) hide show
  1. pycodegate/__init__.py +9 -2
  2. pycodegate/__main__.py +6 -0
  3. pycodegate/api.py +2 -2
  4. pycodegate/cli.py +8 -4
  5. pycodegate/config.py +53 -4
  6. pycodegate/constants.py +30 -1
  7. pycodegate/discover.py +27 -0
  8. pycodegate/output.py +20 -52
  9. pycodegate/profile.py +1 -1
  10. pycodegate/py.typed +0 -0
  11. pycodegate/rules/__init__.py +31 -11
  12. pycodegate/rules/architecture.py +30 -3
  13. pycodegate/rules/celery.py +165 -0
  14. pycodegate/rules/complexity.py +2 -1
  15. pycodegate/rules/correctness.py +36 -44
  16. pycodegate/rules/dead_code.py +5 -2
  17. pycodegate/rules/django.py +33 -1
  18. pycodegate/rules/fastapi.py +39 -8
  19. pycodegate/rules/flask.py +22 -15
  20. pycodegate/rules/import_boundaries.py +70 -0
  21. pycodegate/rules/imports.py +13 -1
  22. pycodegate/rules/logging_rules.py +165 -0
  23. pycodegate/rules/numpy_rules.py +164 -0
  24. pycodegate/rules/pandas_rules.py +165 -0
  25. pycodegate/rules/performance.py +67 -54
  26. pycodegate/rules/pydantic.py +271 -0
  27. pycodegate/rules/pytest_rules.py +142 -0
  28. pycodegate/rules/requests_rules.py +84 -0
  29. pycodegate/rules/security.py +39 -0
  30. pycodegate/rules/sqlalchemy.py +145 -0
  31. pycodegate/rules/structure.py +18 -3
  32. pycodegate/scan.py +51 -10
  33. pycodegate/score.py +32 -16
  34. pycodegate/types.py +18 -1
  35. pycodegate/utils/ast_helpers.py +15 -0
  36. pycodegate/utils/badge.py +2 -2
  37. pycodegate/utils/file_discovery.py +22 -10
  38. pycodegate/utils/fixer.py +4 -4
  39. pycodegate/utils/is_test_file.py +22 -0
  40. pycodegate/utils/precommit.py +10 -13
  41. {pycodegate-0.2.0.dist-info → pycodegate-0.5.0.dist-info}/METADATA +77 -25
  42. pycodegate-0.5.0.dist-info/RECORD +50 -0
  43. {pycodegate-0.2.0.dist-info → pycodegate-0.5.0.dist-info}/WHEEL +1 -1
  44. pycodegate-0.2.0.dist-info/RECORD +0 -38
  45. {pycodegate-0.2.0.dist-info → pycodegate-0.5.0.dist-info}/entry_points.txt +0 -0
  46. {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
- """Python Doctor - Diagnose your Python project's health."""
1
+ """PyCodeGate — the quality gate for AI-generated Python code."""
2
2
 
3
- __version__ = "0.2.0"
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
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m pycodegate``."""
2
+
3
+ from pycodegate.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
pycodegate/api.py CHANGED
@@ -1,4 +1,4 @@
1
- """Programmatic API for python-doctor."""
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 python-doctor analysis programmatically."""
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 py-doctor."""
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
- config = load_config(opts.directory)
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.get("per-file-suppress", {}),
57
- max_deduction=data.get("max-deduction", {}),
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.get("per-file-suppress", {}),
80
- max_deduction=section.get("max-deduction", {}),
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 python-doctor."""
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
- _print_category_groups(console, result.diagnostics, verbose)
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 earned == maximum:
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-subprocess", "no-shell-exec"}),
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
@@ -1,23 +1,32 @@
1
- """Rule registry for python-doctor."""
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 all available rule sets."""
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 get_framework_rules(framework: str | None) -> list[BaseRules]:
31
- """Return framework-specific rule sets."""
32
- _framework_map: dict[str, type[BaseRules]] = {
33
- "django": DjangoRules,
34
- "fastapi": FastAPIRules,
35
- "flask": FlaskRules,
36
- }
37
- cls = _framework_map.get(framework or "")
38
- return [cls()] if cls else []
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
+ ]
@@ -51,10 +51,25 @@ class ArchitectureRules(BaseRules):
51
51
  return diags
52
52
 
53
53
  def _walk_nesting(
54
- self, node: ast.AST, depth: int, filename: str, diags: list[Diagnostic]
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 = (ast.If, ast.For, ast.While, ast.With, ast.Try)
57
- if isinstance(node, nesting_nodes):
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 []
@@ -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