pycodegate 0.2.0__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/constants.py +8 -0
- pycodegate/discover.py +27 -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/sqlalchemy.py +160 -0
- pycodegate/scan.py +5 -5
- pycodegate/types.py +9 -0
- {pycodegate-0.2.0.dist-info → pycodegate-0.3.0.dist-info}/METADATA +1 -1
- {pycodegate-0.2.0.dist-info → pycodegate-0.3.0.dist-info}/RECORD +20 -12
- {pycodegate-0.2.0.dist-info → pycodegate-0.3.0.dist-info}/WHEEL +0 -0
- {pycodegate-0.2.0.dist-info → pycodegate-0.3.0.dist-info}/entry_points.txt +0 -0
- {pycodegate-0.2.0.dist-info → pycodegate-0.3.0.dist-info}/licenses/LICENSE +0 -0
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/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
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""NumPy rules: array equality in boolean context, builtins on arrays, NaN in int arrays."""
|
|
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
|
+
_NP_ARRAY_CONSTRUCTORS = {"array", "zeros", "ones", "empty", "arange", "linspace"}
|
|
11
|
+
_PYTHON_BUILTINS = {"sum", "max", "min", "any", "all"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NumpyRules(BaseRules):
|
|
15
|
+
"""Checks for common NumPy misuse."""
|
|
16
|
+
|
|
17
|
+
def check(self, source: str, filename: str) -> list[Diagnostic]:
|
|
18
|
+
tree = self._parse(source)
|
|
19
|
+
if tree is None:
|
|
20
|
+
return []
|
|
21
|
+
|
|
22
|
+
diags: list[Diagnostic] = []
|
|
23
|
+
for node in ast.walk(tree):
|
|
24
|
+
if isinstance(node, (ast.If, ast.While)):
|
|
25
|
+
diags.extend(self._check_array_equality(node, filename))
|
|
26
|
+
if isinstance(node, ast.Call):
|
|
27
|
+
diags.extend(self._check_builtin_on_array(node, filename))
|
|
28
|
+
diags.extend(self._check_nan_in_int_array(node, filename))
|
|
29
|
+
return diags
|
|
30
|
+
|
|
31
|
+
# ------------------------------------------------------------------
|
|
32
|
+
# Rule 1: numpy-array-equality
|
|
33
|
+
# ------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def _is_np_array_call(self, node: ast.expr) -> bool:
|
|
36
|
+
"""Return True if the node is a call to an np array constructor."""
|
|
37
|
+
if not isinstance(node, ast.Call):
|
|
38
|
+
return False
|
|
39
|
+
func = node.func
|
|
40
|
+
return (
|
|
41
|
+
isinstance(func, ast.Attribute)
|
|
42
|
+
and isinstance(func.value, ast.Name)
|
|
43
|
+
and func.value.id == "np"
|
|
44
|
+
and func.attr in _NP_ARRAY_CONSTRUCTORS
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
def _compare_has_array(self, node: ast.Compare) -> bool:
|
|
48
|
+
"""Return True if a Compare uses == or != with an np array call on either side."""
|
|
49
|
+
for op in node.ops:
|
|
50
|
+
if isinstance(op, (ast.Eq, ast.NotEq)):
|
|
51
|
+
all_operands = [node.left, *node.comparators]
|
|
52
|
+
if any(self._is_np_array_call(operand) for operand in all_operands):
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
def _check_array_equality(self, node: ast.If | ast.While, filename: str) -> list[Diagnostic]:
|
|
57
|
+
test = node.test
|
|
58
|
+
flagged: list[ast.AST] = []
|
|
59
|
+
|
|
60
|
+
if isinstance(test, ast.Compare) and self._compare_has_array(test):
|
|
61
|
+
flagged.append(test)
|
|
62
|
+
elif isinstance(test, ast.BoolOp):
|
|
63
|
+
for value in test.values:
|
|
64
|
+
if isinstance(value, ast.Compare) and self._compare_has_array(value):
|
|
65
|
+
flagged.append(value)
|
|
66
|
+
|
|
67
|
+
return [
|
|
68
|
+
Diagnostic(
|
|
69
|
+
file_path=filename,
|
|
70
|
+
rule="numpy-array-equality",
|
|
71
|
+
severity=Severity.ERROR,
|
|
72
|
+
category=Category.NUMPY,
|
|
73
|
+
message="Array comparison in boolean context — raises ValueError at runtime",
|
|
74
|
+
help="Use np.array_equal(a, b) or np.allclose(a, b) for float arrays",
|
|
75
|
+
line=cmp.lineno,
|
|
76
|
+
column=cmp.col_offset,
|
|
77
|
+
cost=2.0,
|
|
78
|
+
)
|
|
79
|
+
for cmp in flagged
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
# Rule 2: numpy-builtin-on-array
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def _is_np_call(self, node: ast.expr) -> bool:
|
|
87
|
+
"""Return True if node is any call whose func involves 'np'."""
|
|
88
|
+
if not isinstance(node, ast.Call):
|
|
89
|
+
return False
|
|
90
|
+
func = node.func
|
|
91
|
+
return (
|
|
92
|
+
isinstance(func, ast.Attribute)
|
|
93
|
+
and isinstance(func.value, ast.Name)
|
|
94
|
+
and func.value.id == "np"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _check_builtin_on_array(self, node: ast.Call, filename: str) -> list[Diagnostic]:
|
|
98
|
+
if not (isinstance(node.func, ast.Name) and node.func.id in _PYTHON_BUILTINS):
|
|
99
|
+
return []
|
|
100
|
+
if not node.args:
|
|
101
|
+
return []
|
|
102
|
+
if not self._is_np_call(node.args[0]):
|
|
103
|
+
return []
|
|
104
|
+
name = node.func.id
|
|
105
|
+
return [
|
|
106
|
+
Diagnostic(
|
|
107
|
+
file_path=filename,
|
|
108
|
+
rule="numpy-builtin-on-array",
|
|
109
|
+
severity=Severity.WARNING,
|
|
110
|
+
category=Category.NUMPY,
|
|
111
|
+
message=f"Python builtin '{name}()' on numpy array — use np.{name}() for correct results",
|
|
112
|
+
help=f"Replace with np.{name}() or array.{name}()",
|
|
113
|
+
line=node.lineno,
|
|
114
|
+
column=node.col_offset,
|
|
115
|
+
cost=1.0,
|
|
116
|
+
)
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# Rule 3: numpy-nan-in-int-array
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def _check_nan_in_int_array(self, node: ast.Call, filename: str) -> list[Diagnostic]:
|
|
124
|
+
if not self._is_np_array_literal(node):
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
elements = node.args[0].elts
|
|
128
|
+
has_int = any(self._is_int_literal(e) for e in elements)
|
|
129
|
+
has_none_or_nan = any(self._is_none_or_nan(e) for e in elements)
|
|
130
|
+
|
|
131
|
+
if not (has_int and has_none_or_nan):
|
|
132
|
+
return []
|
|
133
|
+
return [
|
|
134
|
+
Diagnostic(
|
|
135
|
+
file_path=filename,
|
|
136
|
+
rule="numpy-nan-in-int-array",
|
|
137
|
+
severity=Severity.WARNING,
|
|
138
|
+
category=Category.NUMPY,
|
|
139
|
+
message="Mixing integers with None/NaN in np.array() — silently coerces to object/float dtype",
|
|
140
|
+
help="Use a masked array or pandas nullable integer type instead",
|
|
141
|
+
line=node.lineno,
|
|
142
|
+
column=node.col_offset,
|
|
143
|
+
cost=1.0,
|
|
144
|
+
)
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
@staticmethod
|
|
148
|
+
def _is_np_array_literal(node: ast.Call) -> bool:
|
|
149
|
+
func = node.func
|
|
150
|
+
return (
|
|
151
|
+
isinstance(func, ast.Attribute)
|
|
152
|
+
and isinstance(func.value, ast.Name)
|
|
153
|
+
and func.value.id == "np"
|
|
154
|
+
and func.attr == "array"
|
|
155
|
+
and bool(node.args)
|
|
156
|
+
and isinstance(node.args[0], ast.List)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _is_int_literal(elt: ast.expr) -> bool:
|
|
161
|
+
return (
|
|
162
|
+
isinstance(elt, ast.Constant)
|
|
163
|
+
and isinstance(elt.value, int)
|
|
164
|
+
and not isinstance(elt.value, bool)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def _is_none_or_nan(elt: ast.expr) -> bool:
|
|
169
|
+
if isinstance(elt, ast.Constant) and elt.value is None:
|
|
170
|
+
return True
|
|
171
|
+
return (
|
|
172
|
+
isinstance(elt, ast.Attribute)
|
|
173
|
+
and isinstance(elt.value, ast.Name)
|
|
174
|
+
and elt.value.id == "np"
|
|
175
|
+
and elt.attr == "nan"
|
|
176
|
+
)
|