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 +1 -1
- pycodegate/cli.py +14 -2
- pycodegate/constants.py +8 -0
- pycodegate/discover.py +27 -0
- pycodegate/output.py +62 -0
- pycodegate/rules/__init__.py +27 -10
- pycodegate/rules/celery.py +177 -0
- pycodegate/rules/dead_code.py +5 -2
- pycodegate/rules/imports.py +1 -1
- pycodegate/rules/logging_rules.py +124 -0
- pycodegate/rules/numpy_rules.py +176 -0
- pycodegate/rules/pandas_rules.py +137 -0
- pycodegate/rules/pydantic.py +299 -0
- pycodegate/rules/pytest_rules.py +154 -0
- pycodegate/rules/requests_rules.py +168 -0
- pycodegate/rules/security.py +94 -0
- pycodegate/rules/sqlalchemy.py +160 -0
- pycodegate/scan.py +5 -5
- pycodegate/types.py +9 -0
- {pycodegate-0.1.3.dist-info → pycodegate-0.3.0.dist-info}/METADATA +1 -1
- {pycodegate-0.1.3.dist-info → pycodegate-0.3.0.dist-info}/RECORD +24 -16
- {pycodegate-0.1.3.dist-info → pycodegate-0.3.0.dist-info}/WHEEL +0 -0
- {pycodegate-0.1.3.dist-info → pycodegate-0.3.0.dist-info}/entry_points.txt +0 -0
- {pycodegate-0.1.3.dist-info → pycodegate-0.3.0.dist-info}/licenses/LICENSE +0 -0
pycodegate/__init__.py
CHANGED
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.
|
|
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],
|
pycodegate/rules/__init__.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
"""Rule registry for
|
|
1
|
+
"""Rule registry for pycodegate."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from pycodegate.rules.architecture import ArchitectureRules
|
|
6
6
|
from pycodegate.rules.base import BaseRules
|
|
7
|
+
from pycodegate.rules.celery import CeleryRules
|
|
7
8
|
from pycodegate.rules.complexity import ComplexityRules
|
|
8
9
|
from pycodegate.rules.correctness import CorrectnessRules
|
|
9
10
|
from pycodegate.rules.dependencies import DependencyRules as DependencyRules
|
|
@@ -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
|
-
|
|
31
|
-
""
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
}
|
|
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 []
|
pycodegate/rules/dead_code.py
CHANGED
|
@@ -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(
|
|
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:
|
pycodegate/rules/imports.py
CHANGED
|
@@ -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
|