pycodegate 0.1.3__py3-none-any.whl → 0.3.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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """Python Doctor - Diagnose your Python project's health."""
2
2
 
3
- __version__ = "0.1.3"
3
+ __version__ = "0.2.0"
pycodegate/cli.py CHANGED
@@ -9,7 +9,7 @@ import click
9
9
 
10
10
  from pycodegate import __version__
11
11
  from pycodegate.config import load_config
12
- from pycodegate.output import output_json, print_scan_result
12
+ from pycodegate.output import output_json, output_sarif, print_scan_result
13
13
  from pycodegate.scan import scan_project
14
14
  from pycodegate.types import Severity
15
15
  from pycodegate.utils.badge import generate_badge, generate_ci_workflow
@@ -35,6 +35,7 @@ class CliOptions:
35
35
  ci_workflow: bool
36
36
  precommit: bool
37
37
  min_score: int | None
38
+ sarif_output: bool
38
39
 
39
40
 
40
41
  def _handle_early_exits(opts: CliOptions) -> bool:
@@ -81,7 +82,9 @@ def _output_result(opts: CliOptions, result) -> None:
81
82
  click.echo(generate_badge(result.score.value, result.score.label))
82
83
  return
83
84
 
84
- if opts.json_output:
85
+ if opts.sarif_output:
86
+ output_sarif(result)
87
+ elif opts.json_output:
85
88
  output_json(result)
86
89
  elif opts.score_only:
87
90
  click.echo(str(result.score.value))
@@ -152,6 +155,13 @@ def _apply_exit_codes(opts: CliOptions, result) -> None:
152
155
  default=None,
153
156
  help="Minimum score threshold (exit 1 if below).",
154
157
  )
158
+ @click.option(
159
+ "--sarif",
160
+ "sarif_output",
161
+ is_flag=True,
162
+ default=False,
163
+ help="Output in SARIF format for GitHub Code Scanning.",
164
+ )
155
165
  def main(
156
166
  directory: str,
157
167
  lint: bool,
@@ -167,6 +177,7 @@ def main(
167
177
  ci_workflow: bool,
168
178
  precommit: bool,
169
179
  min_score: int | None,
180
+ sarif_output: bool,
170
181
  ) -> None:
171
182
  """PyCodeGate — Diagnose your Python project's health."""
172
183
  opts = CliOptions(
@@ -184,6 +195,7 @@ def main(
184
195
  ci_workflow=ci_workflow,
185
196
  precommit=precommit,
186
197
  min_score=min_score,
198
+ sarif_output=sarif_output,
187
199
  )
188
200
 
189
201
  if _handle_early_exits(opts):
pycodegate/constants.py CHANGED
@@ -29,4 +29,12 @@ FRAMEWORK_CATEGORY_MAP: dict = {
29
29
  Category.DJANGO: Category.SECURITY,
30
30
  Category.FASTAPI: Category.CORRECTNESS,
31
31
  Category.FLASK: Category.SECURITY,
32
+ Category.PYDANTIC: Category.CORRECTNESS,
33
+ Category.SQLALCHEMY: Category.SECURITY,
34
+ Category.CELERY: Category.CORRECTNESS,
35
+ Category.REQUESTS: Category.SECURITY,
36
+ Category.LOGGING: Category.CORRECTNESS,
37
+ Category.PANDAS: Category.CORRECTNESS,
38
+ Category.PYTEST: Category.CORRECTNESS,
39
+ Category.NUMPY: Category.CORRECTNESS,
32
40
  }
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
@@ -213,6 +213,68 @@ def output_json(result: ScanResult) -> None:
213
213
  sys.stdout.write(json.dumps(payload, indent=2) + "\n")
214
214
 
215
215
 
216
+ def _sarif_rules(diagnostics: list[Diagnostic]) -> list[dict]:
217
+ """Build SARIF rules registry from unique diagnostic rules."""
218
+ seen: dict[str, Diagnostic] = {}
219
+ for d in diagnostics:
220
+ if d.rule not in seen:
221
+ seen[d.rule] = d
222
+ return [
223
+ {
224
+ "id": rule_id,
225
+ "name": rule_id,
226
+ "shortDescription": {"text": diag.message},
227
+ "helpUri": "",
228
+ "help": {"text": diag.help},
229
+ }
230
+ for rule_id, diag in seen.items()
231
+ ]
232
+
233
+
234
+ def _sarif_results(diagnostics: list[Diagnostic]) -> list[dict]:
235
+ """Build SARIF results array from diagnostics."""
236
+ return [
237
+ {
238
+ "ruleId": d.rule,
239
+ "level": "error" if d.severity == Severity.ERROR else "warning",
240
+ "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
+ ],
252
+ }
253
+ for d in diagnostics
254
+ ]
255
+
256
+
257
+ def output_sarif(result: ScanResult) -> None:
258
+ """Output the scan result in SARIF 2.1.0 format for GitHub Code Scanning."""
259
+ sarif = {
260
+ "version": "2.1.0",
261
+ "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
262
+ "runs": [
263
+ {
264
+ "tool": {
265
+ "driver": {
266
+ "name": "PyCodeGate",
267
+ "version": __version__,
268
+ "rules": _sarif_rules(result.diagnostics),
269
+ }
270
+ },
271
+ "results": _sarif_results(result.diagnostics),
272
+ }
273
+ ],
274
+ }
275
+ sys.stdout.write(json.dumps(sarif, indent=2) + "\n")
276
+
277
+
216
278
  def _print_rule_details(
217
279
  console: Console,
218
280
  rule_diags: list[Diagnostic],
@@ -1,9 +1,10 @@
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
@@ -11,8 +12,15 @@ from pycodegate.rules.django import DjangoRules
11
12
  from pycodegate.rules.fastapi import FastAPIRules
12
13
  from pycodegate.rules.flask import FlaskRules
13
14
  from pycodegate.rules.imports import ImportsRules as ImportsRules
15
+ from pycodegate.rules.logging_rules import LoggingRules
16
+ from pycodegate.rules.numpy_rules import NumpyRules
17
+ from pycodegate.rules.pandas_rules import PandasRules
14
18
  from pycodegate.rules.performance import PerformanceRules
19
+ from pycodegate.rules.pydantic import PydanticRules
20
+ from pycodegate.rules.pytest_rules import PytestRules
21
+ from pycodegate.rules.requests_rules import RequestsRules
15
22
  from pycodegate.rules.security import SecurityRules
23
+ from pycodegate.rules.sqlalchemy import SQLAlchemyRules
16
24
  from pycodegate.rules.structure import StructureRules as StructureRules
17
25
 
18
26
 
@@ -27,12 +35,21 @@ def get_all_rule_sets() -> list[BaseRules]:
27
35
  ]
28
36
 
29
37
 
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 []
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
+ }
51
+
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]
@@ -0,0 +1,177 @@
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
+ # ------------------------------------------------------------------
76
+ # Rule: celery-missing-bind
77
+ # ------------------------------------------------------------------
78
+ def _check_missing_bind(self, func_node, decorator, filename) -> list[Diagnostic]:
79
+ args = func_node.args
80
+ first_arg = args.args[0].arg if args.args else None
81
+ if first_arg != "self":
82
+ return []
83
+
84
+ call = _decorator_call(decorator)
85
+ if call is not None and any(
86
+ kw.arg == "bind" and isinstance(kw.value, ast.Constant) and kw.value.value is True
87
+ for kw in call.keywords
88
+ ):
89
+ return []
90
+
91
+ return [
92
+ Diagnostic(
93
+ file_path=filename,
94
+ rule="celery-missing-bind",
95
+ severity=Severity.ERROR,
96
+ category=Category.CELERY,
97
+ message=f"Task '{func_node.name}' uses 'self' but decorator is missing bind=True",
98
+ help="Add bind=True to the task decorator",
99
+ line=func_node.lineno,
100
+ column=func_node.col_offset,
101
+ cost=2.0,
102
+ )
103
+ ]
104
+
105
+ # ------------------------------------------------------------------
106
+ # Rule: celery-retry-no-exc
107
+ # ------------------------------------------------------------------
108
+ def _check_retry_no_exc(self, handler: ast.ExceptHandler, filename: str) -> list[Diagnostic]:
109
+ diags: list[Diagnostic] = []
110
+ for node in ast.walk(handler):
111
+ if not isinstance(node, ast.Call):
112
+ continue
113
+ if isinstance(node.func, ast.Attribute) and node.func.attr == "retry":
114
+ if isinstance(node.func.value, ast.Name) and node.func.value.id == "self":
115
+ has_exc = any(kw.arg == "exc" for kw in node.keywords)
116
+ if not has_exc:
117
+ diags.append(
118
+ Diagnostic(
119
+ file_path=filename,
120
+ rule="celery-retry-no-exc",
121
+ severity=Severity.WARNING,
122
+ category=Category.CELERY,
123
+ message="self.retry() called without exc keyword — original traceback is lost",
124
+ help="Pass exc=exc to self.retry() to preserve the traceback",
125
+ line=node.lineno,
126
+ column=node.col_offset,
127
+ cost=1.0,
128
+ )
129
+ )
130
+ return diags
131
+
132
+ # ------------------------------------------------------------------
133
+ # Rule: celery-broad-autoretry
134
+ # ------------------------------------------------------------------
135
+ def _check_broad_autoretry(self, func_node, call: ast.Call, filename: str) -> list[Diagnostic]:
136
+ for kw in call.keywords:
137
+ if kw.arg != "autoretry_for":
138
+ continue
139
+ if isinstance(kw.value, ast.Tuple):
140
+ for elt in kw.value.elts:
141
+ if isinstance(elt, ast.Name) and elt.id in {"Exception", "BaseException"}:
142
+ return [
143
+ Diagnostic(
144
+ file_path=filename,
145
+ rule="celery-broad-autoretry",
146
+ severity=Severity.WARNING,
147
+ category=Category.CELERY,
148
+ message=f"Task '{func_node.name}' uses autoretry_for with broad {elt.id}",
149
+ help="Narrow autoretry_for to specific exception types",
150
+ line=kw.value.lineno,
151
+ column=kw.value.col_offset,
152
+ cost=1.0,
153
+ )
154
+ ]
155
+ return []
156
+
157
+ # ------------------------------------------------------------------
158
+ # Rule: celery-direct-call
159
+ # ------------------------------------------------------------------
160
+ def _check_direct_call(
161
+ self, node: ast.Call, task_names: set[str], filename: str
162
+ ) -> list[Diagnostic]:
163
+ if isinstance(node.func, ast.Name) and node.func.id in task_names:
164
+ return [
165
+ Diagnostic(
166
+ file_path=filename,
167
+ rule="celery-direct-call",
168
+ severity=Severity.WARNING,
169
+ category=Category.CELERY,
170
+ message=f"Task '{node.func.id}' called directly — runs synchronously, not via worker",
171
+ help="Use .delay() or .apply_async() to dispatch to a Celery worker",
172
+ line=node.lineno,
173
+ column=node.col_offset,
174
+ cost=0.5,
175
+ )
176
+ ]
177
+ return []
@@ -10,6 +10,7 @@ except ImportError:
10
10
  vulture = None # type: ignore[assignment]
11
11
 
12
12
  from pycodegate.types import Category, Diagnostic, Severity
13
+ from pycodegate.utils.file_discovery import IGNORE_DIRS
13
14
 
14
15
 
15
16
  class DeadCodeRules:
@@ -23,11 +24,13 @@ class DeadCodeRules:
23
24
  v = vulture.Vulture()
24
25
 
25
26
  py_files = list(Path(project_path).rglob("*.py"))
26
- ignore = {".venv", "venv", "node_modules", "__pycache__", ".git", "dist", "build"}
27
27
  py_files = [
28
28
  f
29
29
  for f in py_files
30
- if not any(part in ignore for part in f.relative_to(project_path).parts)
30
+ if not any(
31
+ part in IGNORE_DIRS or part.endswith(".egg-info")
32
+ for part in f.relative_to(project_path).parts
33
+ )
31
34
  ]
32
35
 
33
36
  if not py_files:
@@ -32,7 +32,7 @@ class ImportsRules:
32
32
  return
33
33
  file_map[module_name] = filepath
34
34
 
35
- for node in ast.walk(tree):
35
+ for node in ast.iter_child_nodes(tree):
36
36
  if isinstance(node, ast.Import):
37
37
  for alias in node.names:
38
38
  graph[module_name].add(alias.name)
@@ -0,0 +1,124 @@
1
+ """Logging rules: f-string formatting, root logger, missing exc_info."""
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
+ _LOG_LEVELS = {"debug", "info", "warning", "error", "critical", "exception"}
11
+
12
+
13
+ class LoggingRules(BaseRules):
14
+ """Logging-related checks."""
15
+
16
+ def check(self, source: str, filename: str) -> list[Diagnostic]:
17
+ tree = self._parse(source)
18
+ if tree is None:
19
+ return []
20
+
21
+ diags: list[Diagnostic] = []
22
+ diags.extend(self._check_fstring(tree, filename))
23
+ diags.extend(self._check_root_logger(tree, filename))
24
+ diags.extend(self._check_error_no_exc_info(tree, filename))
25
+ return diags
26
+
27
+ def _check_fstring(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
28
+ diags: list[Diagnostic] = []
29
+ for node in ast.walk(tree):
30
+ if not isinstance(node, ast.Call):
31
+ continue
32
+ if not isinstance(node.func, ast.Attribute):
33
+ continue
34
+ if node.func.attr not in _LOG_LEVELS:
35
+ continue
36
+ if not node.args:
37
+ continue
38
+ first_arg = node.args[0]
39
+ is_fstring = isinstance(first_arg, ast.JoinedStr)
40
+ is_format_call = (
41
+ isinstance(first_arg, ast.Call)
42
+ and isinstance(first_arg.func, ast.Attribute)
43
+ and first_arg.func.attr == "format"
44
+ and isinstance(first_arg.func.value, (ast.Constant, ast.JoinedStr))
45
+ )
46
+ if is_fstring or is_format_call:
47
+ diags.append(
48
+ Diagnostic(
49
+ file_path=filename,
50
+ rule="logging-fstring",
51
+ severity=Severity.WARNING,
52
+ category=Category.LOGGING,
53
+ message="f-string in logging call defeats lazy evaluation and breaks log aggregation",
54
+ help="Use %-style: logger.info('User %s logged in', user_id)",
55
+ line=node.lineno,
56
+ column=node.col_offset,
57
+ cost=1.0,
58
+ )
59
+ )
60
+ return diags
61
+
62
+ def _check_root_logger(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
63
+ diags: list[Diagnostic] = []
64
+ for node in ast.walk(tree):
65
+ if not isinstance(node, ast.Call):
66
+ continue
67
+ if not isinstance(node.func, ast.Attribute):
68
+ continue
69
+ if node.func.attr not in _LOG_LEVELS:
70
+ continue
71
+ if not isinstance(node.func.value, ast.Name):
72
+ continue
73
+ if node.func.value.id != "logging":
74
+ continue
75
+ diags.append(
76
+ Diagnostic(
77
+ file_path=filename,
78
+ rule="logging-root-logger",
79
+ severity=Severity.WARNING,
80
+ category=Category.LOGGING,
81
+ message="Using root logger directly — cannot be independently configured",
82
+ help="Use logger = logging.getLogger(__name__) and call logger.info() instead",
83
+ line=node.lineno,
84
+ column=node.col_offset,
85
+ cost=0.5,
86
+ )
87
+ )
88
+ return diags
89
+
90
+ def _check_error_no_exc_info(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
91
+ diags: list[Diagnostic] = []
92
+ for node in ast.walk(tree):
93
+ if not isinstance(node, ast.ExceptHandler):
94
+ continue
95
+ for child in ast.walk(node):
96
+ if not isinstance(child, ast.Call):
97
+ continue
98
+ if not isinstance(child.func, ast.Attribute):
99
+ continue
100
+ if child.func.attr not in ("error", "warning"):
101
+ continue
102
+ # If method is .exception(), it already includes exc_info
103
+ # but .exception() is not "error" or "warning", so skip check
104
+ has_exc_info = any(
105
+ kw.arg == "exc_info"
106
+ and isinstance(kw.value, ast.Constant)
107
+ and kw.value.value is True
108
+ for kw in child.keywords
109
+ )
110
+ if not has_exc_info:
111
+ diags.append(
112
+ Diagnostic(
113
+ file_path=filename,
114
+ rule="logging-error-no-exc-info",
115
+ severity=Severity.WARNING,
116
+ category=Category.LOGGING,
117
+ message="logger.error() in except block without exc_info=True loses the traceback",
118
+ help="Use logger.exception() or add exc_info=True",
119
+ line=child.lineno,
120
+ column=child.col_offset,
121
+ cost=1.0,
122
+ )
123
+ )
124
+ return diags