pycodegate 0.1.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 +3 -0
- pycodegate/_compat.py +15 -0
- pycodegate/api.py +26 -0
- pycodegate/cli.py +131 -0
- pycodegate/config.py +81 -0
- pycodegate/constants.py +32 -0
- pycodegate/discover.py +114 -0
- pycodegate/output.py +270 -0
- pycodegate/profile.py +107 -0
- pycodegate/rules/__init__.py +38 -0
- pycodegate/rules/architecture.py +121 -0
- pycodegate/rules/base.py +23 -0
- pycodegate/rules/complexity.py +87 -0
- pycodegate/rules/correctness.py +131 -0
- pycodegate/rules/dead_code.py +58 -0
- pycodegate/rules/dependencies.py +107 -0
- pycodegate/rules/django.py +144 -0
- pycodegate/rules/fastapi.py +70 -0
- pycodegate/rules/flask.py +115 -0
- pycodegate/rules/imports.py +96 -0
- pycodegate/rules/performance.py +104 -0
- pycodegate/rules/security.py +167 -0
- pycodegate/rules/structure.py +242 -0
- pycodegate/scan.py +201 -0
- pycodegate/score.py +69 -0
- pycodegate/types.py +65 -0
- pycodegate/utils/__init__.py +1 -0
- pycodegate/utils/ast_helpers.py +19 -0
- pycodegate/utils/badge.py +51 -0
- pycodegate/utils/diff.py +24 -0
- pycodegate/utils/file_discovery.py +62 -0
- pycodegate/utils/fixer.py +32 -0
- pycodegate/utils/precommit.py +48 -0
- pycodegate-0.1.0.dist-info/METADATA +357 -0
- pycodegate-0.1.0.dist-info/RECORD +38 -0
- pycodegate-0.1.0.dist-info/WHEEL +4 -0
- pycodegate-0.1.0.dist-info/entry_points.txt +2 -0
- pycodegate-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Django-specific rules: raw SQL, DEBUG=True, N+1 queries, secret key exposure."""
|
|
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
|
+
|
|
11
|
+
class DjangoRules(BaseRules):
|
|
12
|
+
"""Django framework-specific checks."""
|
|
13
|
+
|
|
14
|
+
def check(self, source: str, filename: str) -> list[Diagnostic]:
|
|
15
|
+
tree = self._parse(source)
|
|
16
|
+
if tree is None:
|
|
17
|
+
return []
|
|
18
|
+
|
|
19
|
+
diags: list[Diagnostic] = []
|
|
20
|
+
diags.extend(self._check_raw_sql(tree, filename))
|
|
21
|
+
diags.extend(self._check_debug_true(tree, filename))
|
|
22
|
+
diags.extend(self._check_n_plus_one(tree, source, filename))
|
|
23
|
+
diags.extend(self._check_secret_key(tree, filename))
|
|
24
|
+
return diags
|
|
25
|
+
|
|
26
|
+
def _check_raw_sql(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
27
|
+
diags: list[Diagnostic] = []
|
|
28
|
+
for node in ast.walk(tree):
|
|
29
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
|
30
|
+
if node.func.attr == "execute" and node.args:
|
|
31
|
+
arg = node.args[0]
|
|
32
|
+
if isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Add):
|
|
33
|
+
diags.append(
|
|
34
|
+
Diagnostic(
|
|
35
|
+
file_path=filename,
|
|
36
|
+
rule="no-raw-sql-injection",
|
|
37
|
+
severity=Severity.ERROR,
|
|
38
|
+
category=Category.DJANGO,
|
|
39
|
+
message="SQL query built with string concatenation — SQL injection risk",
|
|
40
|
+
help="Use parameterized queries: cursor.execute('SELECT ... WHERE id = %s', [user_id])",
|
|
41
|
+
line=node.lineno,
|
|
42
|
+
column=node.col_offset,
|
|
43
|
+
cost=3.0,
|
|
44
|
+
)
|
|
45
|
+
)
|
|
46
|
+
return diags
|
|
47
|
+
|
|
48
|
+
def _check_debug_true(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
49
|
+
if "settings" not in filename:
|
|
50
|
+
return []
|
|
51
|
+
diags: list[Diagnostic] = []
|
|
52
|
+
for node in ast.walk(tree):
|
|
53
|
+
if isinstance(node, ast.Assign):
|
|
54
|
+
for target in node.targets:
|
|
55
|
+
if (
|
|
56
|
+
isinstance(target, ast.Name)
|
|
57
|
+
and target.id == "DEBUG"
|
|
58
|
+
and isinstance(node.value, ast.Constant)
|
|
59
|
+
and node.value.value is True
|
|
60
|
+
):
|
|
61
|
+
diags.append(
|
|
62
|
+
Diagnostic(
|
|
63
|
+
file_path=filename,
|
|
64
|
+
rule="no-debug-true",
|
|
65
|
+
severity=Severity.ERROR,
|
|
66
|
+
category=Category.DJANGO,
|
|
67
|
+
message="DEBUG = True should not be hardcoded in settings",
|
|
68
|
+
help="Use environment variable: DEBUG = os.environ.get('DEBUG', 'False') == 'True'",
|
|
69
|
+
line=node.lineno,
|
|
70
|
+
column=node.col_offset,
|
|
71
|
+
cost=3.0,
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
return diags
|
|
75
|
+
|
|
76
|
+
def _check_n_plus_one(self, tree: ast.Module, source: str, filename: str) -> list[Diagnostic]:
|
|
77
|
+
diags: list[Diagnostic] = []
|
|
78
|
+
for node in ast.walk(tree):
|
|
79
|
+
if not isinstance(node, ast.For) or not self._is_queryset_iter(node.iter):
|
|
80
|
+
continue
|
|
81
|
+
if not isinstance(node.target, ast.Name):
|
|
82
|
+
continue
|
|
83
|
+
var_name = node.target.id
|
|
84
|
+
if self._has_related_access(node, var_name):
|
|
85
|
+
diags.append(
|
|
86
|
+
Diagnostic(
|
|
87
|
+
file_path=filename,
|
|
88
|
+
rule="no-n-plus-one-query",
|
|
89
|
+
severity=Severity.WARNING,
|
|
90
|
+
category=Category.DJANGO,
|
|
91
|
+
message="Potential N+1 query — accessing related object in a loop",
|
|
92
|
+
help="Use select_related() or prefetch_related() on the queryset",
|
|
93
|
+
line=node.lineno,
|
|
94
|
+
column=node.col_offset,
|
|
95
|
+
cost=1.0,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return diags
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _has_related_access(loop_node: ast.For, var_name: str) -> bool:
|
|
102
|
+
for child in ast.walk(loop_node):
|
|
103
|
+
if (
|
|
104
|
+
isinstance(child, ast.Attribute)
|
|
105
|
+
and isinstance(child.value, ast.Attribute)
|
|
106
|
+
and isinstance(child.value.value, ast.Name)
|
|
107
|
+
and child.value.value.id == var_name
|
|
108
|
+
):
|
|
109
|
+
return True
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
def _is_queryset_iter(self, node: ast.expr) -> bool:
|
|
113
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
|
114
|
+
if node.func.attr in ("all", "filter", "exclude", "values", "values_list"):
|
|
115
|
+
return True
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
def _check_secret_key(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
119
|
+
if "settings" not in filename:
|
|
120
|
+
return []
|
|
121
|
+
diags: list[Diagnostic] = []
|
|
122
|
+
for node in ast.walk(tree):
|
|
123
|
+
if isinstance(node, ast.Assign):
|
|
124
|
+
for target in node.targets:
|
|
125
|
+
if (
|
|
126
|
+
isinstance(target, ast.Name)
|
|
127
|
+
and target.id == "SECRET_KEY"
|
|
128
|
+
and isinstance(node.value, ast.Constant)
|
|
129
|
+
and isinstance(node.value.value, str)
|
|
130
|
+
):
|
|
131
|
+
diags.append(
|
|
132
|
+
Diagnostic(
|
|
133
|
+
file_path=filename,
|
|
134
|
+
rule="no-secret-key-in-source",
|
|
135
|
+
severity=Severity.ERROR,
|
|
136
|
+
category=Category.DJANGO,
|
|
137
|
+
message="SECRET_KEY hardcoded in settings — use environment variable",
|
|
138
|
+
help="Use os.environ['SECRET_KEY'] or django-environ",
|
|
139
|
+
line=node.lineno,
|
|
140
|
+
column=node.col_offset,
|
|
141
|
+
cost=3.0,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
return diags
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""FastAPI-specific rules: sync endpoints, missing response_model."""
|
|
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
|
+
_ROUTE_METHODS = {"get", "post", "put", "patch", "delete", "head", "options"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FastAPIRules(BaseRules):
|
|
14
|
+
"""FastAPI framework-specific 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
|
+
for node in ast.walk(tree):
|
|
23
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
24
|
+
route_decorator = self._get_route_decorator(node)
|
|
25
|
+
if route_decorator is not None:
|
|
26
|
+
diags.extend(self._check_sync_endpoint(node, route_decorator, filename))
|
|
27
|
+
diags.extend(self._check_response_model(node, route_decorator, filename))
|
|
28
|
+
return diags
|
|
29
|
+
|
|
30
|
+
def _get_route_decorator(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.Call | None:
|
|
31
|
+
for dec in node.decorator_list:
|
|
32
|
+
if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
|
|
33
|
+
if dec.func.attr in _ROUTE_METHODS:
|
|
34
|
+
return dec
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
def _check_sync_endpoint(self, node, decorator, filename):
|
|
38
|
+
if isinstance(node, ast.FunctionDef):
|
|
39
|
+
return [
|
|
40
|
+
Diagnostic(
|
|
41
|
+
file_path=filename,
|
|
42
|
+
rule="prefer-async-endpoint",
|
|
43
|
+
severity=Severity.WARNING,
|
|
44
|
+
category=Category.FASTAPI,
|
|
45
|
+
message=f"Endpoint '{node.name}' is synchronous — blocks the event loop",
|
|
46
|
+
help="Use 'async def' for I/O-bound endpoints",
|
|
47
|
+
line=node.lineno,
|
|
48
|
+
column=node.col_offset,
|
|
49
|
+
cost=1.0,
|
|
50
|
+
)
|
|
51
|
+
]
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
def _check_response_model(self, node, decorator, filename):
|
|
55
|
+
has_response_model = any(kw.arg == "response_model" for kw in decorator.keywords)
|
|
56
|
+
if not has_response_model:
|
|
57
|
+
return [
|
|
58
|
+
Diagnostic(
|
|
59
|
+
file_path=filename,
|
|
60
|
+
rule="missing-response-model",
|
|
61
|
+
severity=Severity.WARNING,
|
|
62
|
+
category=Category.FASTAPI,
|
|
63
|
+
message=f"Endpoint '{node.name}' missing response_model — no response validation",
|
|
64
|
+
help="Add response_model parameter to the route decorator",
|
|
65
|
+
line=node.lineno,
|
|
66
|
+
column=node.col_offset,
|
|
67
|
+
cost=1.0,
|
|
68
|
+
)
|
|
69
|
+
]
|
|
70
|
+
return []
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Flask-specific rules: secret key in source, debug mode, SQL via string format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from pycodegate.rules.base import BaseRules
|
|
9
|
+
from pycodegate.types import Category, Diagnostic, Severity
|
|
10
|
+
|
|
11
|
+
_SQL_PATTERN = re.compile(
|
|
12
|
+
r"(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER)\s+",
|
|
13
|
+
re.IGNORECASE,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FlaskRules(BaseRules):
|
|
18
|
+
"""Flask framework-specific checks."""
|
|
19
|
+
|
|
20
|
+
def check(self, source: str, filename: str) -> list[Diagnostic]:
|
|
21
|
+
tree = self._parse(source)
|
|
22
|
+
if tree is None:
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
diags: list[Diagnostic] = []
|
|
26
|
+
diags.extend(self._check_secret_key(tree, filename))
|
|
27
|
+
diags.extend(self._check_debug_mode(tree, filename))
|
|
28
|
+
diags.extend(self._check_sql_string_format(tree, filename))
|
|
29
|
+
return diags
|
|
30
|
+
|
|
31
|
+
def _check_secret_key(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
32
|
+
diags: list[Diagnostic] = []
|
|
33
|
+
for node in ast.walk(tree):
|
|
34
|
+
if isinstance(node, ast.Assign):
|
|
35
|
+
for target in node.targets:
|
|
36
|
+
if (
|
|
37
|
+
isinstance(target, ast.Attribute)
|
|
38
|
+
and target.attr == "secret_key"
|
|
39
|
+
and isinstance(node.value, ast.Constant)
|
|
40
|
+
and isinstance(node.value.value, str)
|
|
41
|
+
):
|
|
42
|
+
diags.append(
|
|
43
|
+
Diagnostic(
|
|
44
|
+
file_path=filename,
|
|
45
|
+
rule="no-flask-secret-in-source",
|
|
46
|
+
severity=Severity.ERROR,
|
|
47
|
+
category=Category.FLASK,
|
|
48
|
+
message="Flask secret_key hardcoded in source",
|
|
49
|
+
help="Use os.environ['SECRET_KEY'] or a config file",
|
|
50
|
+
line=node.lineno,
|
|
51
|
+
column=node.col_offset,
|
|
52
|
+
cost=3.0,
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
return diags
|
|
56
|
+
|
|
57
|
+
def _check_debug_mode(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
58
|
+
diags: list[Diagnostic] = []
|
|
59
|
+
for node in ast.walk(tree):
|
|
60
|
+
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)):
|
|
61
|
+
continue
|
|
62
|
+
if node.func.attr != "run":
|
|
63
|
+
continue
|
|
64
|
+
if self._has_debug_true(node):
|
|
65
|
+
diags.append(
|
|
66
|
+
Diagnostic(
|
|
67
|
+
file_path=filename,
|
|
68
|
+
rule="no-flask-debug-mode",
|
|
69
|
+
severity=Severity.ERROR,
|
|
70
|
+
category=Category.FLASK,
|
|
71
|
+
message="Debug mode enabled — exposes debugger and reloader in production",
|
|
72
|
+
help="Use environment variable: app.run(debug=os.environ.get('FLASK_DEBUG'))",
|
|
73
|
+
line=node.lineno,
|
|
74
|
+
column=node.col_offset,
|
|
75
|
+
cost=3.0,
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return diags
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _has_debug_true(call_node: ast.Call) -> bool:
|
|
82
|
+
return any(
|
|
83
|
+
kw.arg == "debug" and isinstance(kw.value, ast.Constant) and kw.value.value is True
|
|
84
|
+
for kw in call_node.keywords
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def _check_sql_string_format(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
88
|
+
diags: list[Diagnostic] = []
|
|
89
|
+
for node in ast.walk(tree):
|
|
90
|
+
if not isinstance(node, ast.Assign) or not isinstance(node.value, ast.JoinedStr):
|
|
91
|
+
continue
|
|
92
|
+
if self._fstring_contains_sql(node.value):
|
|
93
|
+
diags.append(
|
|
94
|
+
Diagnostic(
|
|
95
|
+
file_path=filename,
|
|
96
|
+
rule="no-sql-string-format",
|
|
97
|
+
severity=Severity.ERROR,
|
|
98
|
+
category=Category.FLASK,
|
|
99
|
+
message="SQL query built with f-string — SQL injection risk",
|
|
100
|
+
help="Use parameterized queries with placeholders",
|
|
101
|
+
line=node.lineno,
|
|
102
|
+
column=node.col_offset,
|
|
103
|
+
cost=3.0,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
return diags
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _fstring_contains_sql(joined_str: ast.JoinedStr) -> bool:
|
|
110
|
+
return any(
|
|
111
|
+
isinstance(val, ast.Constant)
|
|
112
|
+
and isinstance(val.value, str)
|
|
113
|
+
and _SQL_PATTERN.search(val.value)
|
|
114
|
+
for val in joined_str.values
|
|
115
|
+
)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Import rules: circular dependency detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import os
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pycodegate.types import Category, Diagnostic, Severity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ImportsRules:
|
|
14
|
+
"""Project-level import analysis."""
|
|
15
|
+
|
|
16
|
+
def check_project(self, project_path: str, source_files: list[str]) -> list[Diagnostic]:
|
|
17
|
+
"""Detect circular imports across the project."""
|
|
18
|
+
return self._check_circular_imports(project_path, source_files)
|
|
19
|
+
|
|
20
|
+
def _check_circular_imports(
|
|
21
|
+
self, project_path: str, source_files: list[str]
|
|
22
|
+
) -> list[Diagnostic]:
|
|
23
|
+
# Build import graph: module_name -> set of imported module_names
|
|
24
|
+
graph: dict[str, set[str]] = defaultdict(set)
|
|
25
|
+
file_map: dict[str, str] = {} # module_name -> file_path
|
|
26
|
+
|
|
27
|
+
root = Path(project_path).resolve()
|
|
28
|
+
|
|
29
|
+
for filepath in source_files:
|
|
30
|
+
try:
|
|
31
|
+
source = Path(filepath).read_text()
|
|
32
|
+
tree = ast.parse(source)
|
|
33
|
+
except (SyntaxError, OSError, UnicodeDecodeError):
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
module_name = self._file_to_module(filepath, str(root))
|
|
37
|
+
if not module_name:
|
|
38
|
+
continue
|
|
39
|
+
file_map[module_name] = filepath
|
|
40
|
+
|
|
41
|
+
for node in ast.walk(tree):
|
|
42
|
+
if isinstance(node, ast.Import):
|
|
43
|
+
for alias in node.names:
|
|
44
|
+
graph[module_name].add(alias.name)
|
|
45
|
+
elif isinstance(node, ast.ImportFrom):
|
|
46
|
+
if node.module and node.level == 0:
|
|
47
|
+
graph[module_name].add(node.module)
|
|
48
|
+
|
|
49
|
+
# Detect direct A↔B cycles
|
|
50
|
+
diags: list[Diagnostic] = []
|
|
51
|
+
seen_pairs: set[tuple[str, str]] = set()
|
|
52
|
+
|
|
53
|
+
for mod_a, imports in graph.items():
|
|
54
|
+
for mod_b in imports:
|
|
55
|
+
if mod_b in graph and mod_a in graph[mod_b]:
|
|
56
|
+
pair = tuple(sorted([mod_a, mod_b]))
|
|
57
|
+
if pair not in seen_pairs:
|
|
58
|
+
seen_pairs.add(pair)
|
|
59
|
+
file_a = file_map.get(mod_a, mod_a)
|
|
60
|
+
diags.append(
|
|
61
|
+
Diagnostic(
|
|
62
|
+
file_path=file_a,
|
|
63
|
+
rule="imports/circular",
|
|
64
|
+
severity=Severity.ERROR,
|
|
65
|
+
category=Category.IMPORTS,
|
|
66
|
+
message=f"Circular import between '{mod_a}' and '{mod_b}'",
|
|
67
|
+
help="Break the cycle by moving shared code to a third module",
|
|
68
|
+
line=1,
|
|
69
|
+
cost=3.0,
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return diags
|
|
74
|
+
|
|
75
|
+
def _file_to_module(self, filepath: str, root: str) -> str | None:
|
|
76
|
+
"""Convert a file path to a dotted module name."""
|
|
77
|
+
try:
|
|
78
|
+
rel = os.path.relpath(filepath, root)
|
|
79
|
+
except ValueError:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
# Remove .py extension
|
|
83
|
+
if rel.endswith(".py"):
|
|
84
|
+
rel = rel[:-3]
|
|
85
|
+
else:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
# Remove __init__ suffix
|
|
89
|
+
if rel.endswith("__init__"):
|
|
90
|
+
rel = rel[:-9].rstrip(os.sep)
|
|
91
|
+
|
|
92
|
+
if not rel:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
# Convert path separators to dots
|
|
96
|
+
return rel.replace(os.sep, ".").replace("/", ".")
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Performance rules: string concat in loops, imports in functions, star imports."""
|
|
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
|
+
|
|
11
|
+
class PerformanceRules(BaseRules):
|
|
12
|
+
"""Performance-related checks."""
|
|
13
|
+
|
|
14
|
+
def check(self, source: str, filename: str) -> list[Diagnostic]:
|
|
15
|
+
tree = self._parse(source)
|
|
16
|
+
if tree is None:
|
|
17
|
+
return []
|
|
18
|
+
|
|
19
|
+
diags: list[Diagnostic] = []
|
|
20
|
+
diags.extend(self._check_string_concat_in_loop(tree, filename))
|
|
21
|
+
diags.extend(self._check_import_in_function(tree, filename))
|
|
22
|
+
diags.extend(self._check_star_imports(tree, filename))
|
|
23
|
+
return diags
|
|
24
|
+
|
|
25
|
+
def _check_string_concat_in_loop(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
26
|
+
string_vars = self._find_string_vars(tree)
|
|
27
|
+
|
|
28
|
+
diags: list[Diagnostic] = []
|
|
29
|
+
for node in ast.walk(tree):
|
|
30
|
+
if isinstance(node, (ast.For, ast.While)):
|
|
31
|
+
for child in ast.walk(node):
|
|
32
|
+
if (
|
|
33
|
+
isinstance(child, ast.AugAssign)
|
|
34
|
+
and isinstance(child.op, ast.Add)
|
|
35
|
+
and isinstance(child.target, ast.Name)
|
|
36
|
+
and child.target.id in string_vars
|
|
37
|
+
):
|
|
38
|
+
diags.append(
|
|
39
|
+
Diagnostic(
|
|
40
|
+
file_path=filename,
|
|
41
|
+
rule="no-string-concat-in-loop",
|
|
42
|
+
severity=Severity.WARNING,
|
|
43
|
+
category=Category.PERFORMANCE,
|
|
44
|
+
message="String concatenation in a loop — O(n^2) memory",
|
|
45
|
+
help="Collect items in a list and use ''.join() at the end",
|
|
46
|
+
line=child.lineno,
|
|
47
|
+
column=child.col_offset,
|
|
48
|
+
cost=0.5,
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
return diags
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _find_string_vars(tree: ast.Module) -> set[str]:
|
|
55
|
+
names: set[str] = set()
|
|
56
|
+
for node in ast.walk(tree):
|
|
57
|
+
if not isinstance(node, ast.Assign):
|
|
58
|
+
continue
|
|
59
|
+
if not (isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)):
|
|
60
|
+
continue
|
|
61
|
+
for target in node.targets:
|
|
62
|
+
if isinstance(target, ast.Name):
|
|
63
|
+
names.add(target.id)
|
|
64
|
+
return names
|
|
65
|
+
|
|
66
|
+
def _check_import_in_function(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
67
|
+
diags: list[Diagnostic] = []
|
|
68
|
+
for node in ast.walk(tree):
|
|
69
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
70
|
+
for child in ast.walk(node):
|
|
71
|
+
if isinstance(child, (ast.Import, ast.ImportFrom)):
|
|
72
|
+
diags.append(
|
|
73
|
+
Diagnostic(
|
|
74
|
+
file_path=filename,
|
|
75
|
+
rule="no-import-in-function",
|
|
76
|
+
severity=Severity.WARNING,
|
|
77
|
+
category=Category.PERFORMANCE,
|
|
78
|
+
message="Import inside function body — re-imported on every call",
|
|
79
|
+
help="Move imports to the top of the module",
|
|
80
|
+
line=child.lineno,
|
|
81
|
+
column=child.col_offset,
|
|
82
|
+
cost=0.5,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
return diags
|
|
86
|
+
|
|
87
|
+
def _check_star_imports(self, tree: ast.Module, filename: str) -> list[Diagnostic]:
|
|
88
|
+
diags: list[Diagnostic] = []
|
|
89
|
+
for node in ast.walk(tree):
|
|
90
|
+
if isinstance(node, ast.ImportFrom) and any(alias.name == "*" for alias in node.names):
|
|
91
|
+
diags.append(
|
|
92
|
+
Diagnostic(
|
|
93
|
+
file_path=filename,
|
|
94
|
+
rule="no-star-import",
|
|
95
|
+
severity=Severity.WARNING,
|
|
96
|
+
category=Category.PERFORMANCE,
|
|
97
|
+
message=f"Star import from {node.module} pollutes namespace and hides dependencies",
|
|
98
|
+
help="Import specific names instead",
|
|
99
|
+
line=node.lineno,
|
|
100
|
+
column=node.col_offset,
|
|
101
|
+
cost=0.5,
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
return diags
|