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.
@@ -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