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.
Files changed (44) 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 +22 -1
  7. pycodegate/output.py +20 -52
  8. pycodegate/profile.py +1 -1
  9. pycodegate/py.typed +0 -0
  10. pycodegate/rules/__init__.py +21 -18
  11. pycodegate/rules/architecture.py +30 -3
  12. pycodegate/rules/celery.py +0 -12
  13. pycodegate/rules/complexity.py +2 -1
  14. pycodegate/rules/correctness.py +36 -44
  15. pycodegate/rules/django.py +33 -1
  16. pycodegate/rules/fastapi.py +39 -8
  17. pycodegate/rules/flask.py +22 -15
  18. pycodegate/rules/import_boundaries.py +70 -0
  19. pycodegate/rules/imports.py +12 -0
  20. pycodegate/rules/logging_rules.py +49 -8
  21. pycodegate/rules/numpy_rules.py +0 -12
  22. pycodegate/rules/pandas_rules.py +44 -16
  23. pycodegate/rules/performance.py +67 -54
  24. pycodegate/rules/pydantic.py +0 -28
  25. pycodegate/rules/pytest_rules.py +0 -12
  26. pycodegate/rules/requests_rules.py +21 -105
  27. pycodegate/rules/security.py +39 -0
  28. pycodegate/rules/sqlalchemy.py +2 -17
  29. pycodegate/rules/structure.py +18 -3
  30. pycodegate/scan.py +51 -10
  31. pycodegate/score.py +32 -16
  32. pycodegate/types.py +9 -1
  33. pycodegate/utils/ast_helpers.py +15 -0
  34. pycodegate/utils/badge.py +2 -2
  35. pycodegate/utils/file_discovery.py +22 -10
  36. pycodegate/utils/fixer.py +4 -4
  37. pycodegate/utils/is_test_file.py +22 -0
  38. pycodegate/utils/precommit.py +10 -13
  39. {pycodegate-0.3.0.dist-info → pycodegate-0.5.0.dist-info}/METADATA +77 -25
  40. pycodegate-0.5.0.dist-info/RECORD +50 -0
  41. {pycodegate-0.3.0.dist-info → pycodegate-0.5.0.dist-info}/WHEEL +1 -1
  42. pycodegate-0.3.0.dist-info/RECORD +0 -46
  43. {pycodegate-0.3.0.dist-info → pycodegate-0.5.0.dist-info}/entry_points.txt +0 -0
  44. {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
- """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,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
- _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
@@ -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 all available rule sets."""
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
- _FRAMEWORK_MAP: dict[str, type[BaseRules]] = {
39
- "django": DjangoRules,
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
- def get_framework_rules(frameworks: list[str]) -> list[BaseRules]:
54
- """Return framework/library-specific rule sets for all detected frameworks."""
55
- return [_FRAMEWORK_MAP[f]() for f in frameworks if f in _FRAMEWORK_MAP]
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
 
@@ -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]:
@@ -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
 
@@ -1,4 +1,4 @@
1
- """Correctness rules: mutable defaults, bare except, broad exception, assert in prod, return in __init__."""
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
- for default in node.args.defaults + node.args.kw_defaults:
32
- if default is not None and isinstance(default, (ast.List, ast.Dict, ast.Set)):
33
- diags.append(
34
- Diagnostic(
35
- file_path=filename,
36
- rule="no-mutable-default",
37
- severity=Severity.ERROR,
38
- category=Category.CORRECTNESS,
39
- message="Mutable default argument — shared across all calls",
40
- help="Use None as default and create the mutable inside the function body",
41
- line=node.lineno,
42
- column=node.col_offset,
43
- cost=2.0,
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 ast.walk(node):
117
- if isinstance(child, ast.Return) and child.value is not None:
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
@@ -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 isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Add):
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):