pycodegate 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (89) hide show
  1. {pycodegate-0.2.0 → pycodegate-0.3.0}/PKG-INFO +1 -1
  2. {pycodegate-0.2.0 → pycodegate-0.3.0}/pyproject.toml +1 -1
  3. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/constants.py +8 -0
  4. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/discover.py +27 -0
  5. pycodegate-0.3.0/src/pycodegate/rules/__init__.py +55 -0
  6. pycodegate-0.3.0/src/pycodegate/rules/celery.py +177 -0
  7. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/rules/dead_code.py +5 -2
  8. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/rules/imports.py +1 -1
  9. pycodegate-0.3.0/src/pycodegate/rules/logging_rules.py +124 -0
  10. pycodegate-0.3.0/src/pycodegate/rules/numpy_rules.py +176 -0
  11. pycodegate-0.3.0/src/pycodegate/rules/pandas_rules.py +137 -0
  12. pycodegate-0.3.0/src/pycodegate/rules/pydantic.py +299 -0
  13. pycodegate-0.3.0/src/pycodegate/rules/pytest_rules.py +154 -0
  14. pycodegate-0.3.0/src/pycodegate/rules/requests_rules.py +168 -0
  15. pycodegate-0.3.0/src/pycodegate/rules/sqlalchemy.py +160 -0
  16. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/scan.py +5 -5
  17. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/types.py +9 -0
  18. pycodegate-0.3.0/tests/rules/test_celery.py +111 -0
  19. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/rules/test_imports.py +24 -0
  20. pycodegate-0.3.0/tests/rules/test_logging_rules.py +58 -0
  21. pycodegate-0.3.0/tests/rules/test_numpy_rules.py +80 -0
  22. pycodegate-0.3.0/tests/rules/test_pandas_rules.py +62 -0
  23. pycodegate-0.3.0/tests/rules/test_pydantic.py +224 -0
  24. pycodegate-0.3.0/tests/rules/test_pytest_rules.py +68 -0
  25. pycodegate-0.3.0/tests/rules/test_requests_rules.py +81 -0
  26. pycodegate-0.3.0/tests/rules/test_sqlalchemy.py +85 -0
  27. pycodegate-0.2.0/src/pycodegate/rules/__init__.py +0 -38
  28. {pycodegate-0.2.0 → pycodegate-0.3.0}/.github/workflows/ci.yml +0 -0
  29. {pycodegate-0.2.0 → pycodegate-0.3.0}/.github/workflows/publish.yml +0 -0
  30. {pycodegate-0.2.0 → pycodegate-0.3.0}/.gitignore +0 -0
  31. {pycodegate-0.2.0 → pycodegate-0.3.0}/AGENTS.md +0 -0
  32. {pycodegate-0.2.0 → pycodegate-0.3.0}/LICENSE +0 -0
  33. {pycodegate-0.2.0 → pycodegate-0.3.0}/README.md +0 -0
  34. {pycodegate-0.2.0 → pycodegate-0.3.0}/docs/superpowers/plans/2026-03-22-python-doctor.md +0 -0
  35. {pycodegate-0.2.0 → pycodegate-0.3.0}/skills/pycodegate/SKILL.md +0 -0
  36. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/__init__.py +0 -0
  37. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/_compat.py +0 -0
  38. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/api.py +0 -0
  39. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/cli.py +0 -0
  40. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/config.py +0 -0
  41. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/output.py +0 -0
  42. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/profile.py +0 -0
  43. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/rules/architecture.py +0 -0
  44. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/rules/base.py +0 -0
  45. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/rules/complexity.py +0 -0
  46. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/rules/correctness.py +0 -0
  47. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/rules/dependencies.py +0 -0
  48. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/rules/django.py +0 -0
  49. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/rules/fastapi.py +0 -0
  50. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/rules/flask.py +0 -0
  51. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/rules/performance.py +0 -0
  52. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/rules/security.py +0 -0
  53. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/rules/structure.py +0 -0
  54. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/score.py +0 -0
  55. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/utils/__init__.py +0 -0
  56. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/utils/ast_helpers.py +0 -0
  57. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/utils/badge.py +0 -0
  58. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/utils/diff.py +0 -0
  59. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/utils/file_discovery.py +0 -0
  60. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/utils/fixer.py +0 -0
  61. {pycodegate-0.2.0 → pycodegate-0.3.0}/src/pycodegate/utils/precommit.py +0 -0
  62. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/conftest.py +0 -0
  63. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/fixtures/basic_python/app.py +0 -0
  64. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/fixtures/clean_project/app.py +0 -0
  65. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/rules/__init__.py +0 -0
  66. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/rules/test_architecture.py +0 -0
  67. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/rules/test_complexity.py +0 -0
  68. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/rules/test_correctness.py +0 -0
  69. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/rules/test_dead_code.py +0 -0
  70. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/rules/test_dependencies.py +0 -0
  71. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/rules/test_django.py +0 -0
  72. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/rules/test_fastapi.py +0 -0
  73. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/rules/test_flask.py +0 -0
  74. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/rules/test_performance.py +0 -0
  75. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/rules/test_security.py +0 -0
  76. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/rules/test_security_new.py +0 -0
  77. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/rules/test_structure.py +0 -0
  78. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/test_cli.py +0 -0
  79. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/test_config.py +0 -0
  80. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/test_discover.py +0 -0
  81. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/test_fixer.py +0 -0
  82. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/test_integration.py +0 -0
  83. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/test_output.py +0 -0
  84. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/test_profile.py +0 -0
  85. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/test_scan.py +0 -0
  86. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/test_score.py +0 -0
  87. {pycodegate-0.2.0 → pycodegate-0.3.0}/tests/test_types.py +0 -0
  88. {pycodegate-0.2.0 → pycodegate-0.3.0}/uv.lock +0 -0
  89. {pycodegate-0.2.0 → pycodegate-0.3.0}/whitelist.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycodegate
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Trust, but verify. The quality gate for AI-generated Python code.
5
5
  Project-URL: Homepage, https://github.com/themohitkhare/pycodegate
6
6
  Project-URL: Repository, https://github.com/themohitkhare/pycodegate
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pycodegate"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Trust, but verify. The quality gate for AI-generated Python code."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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
  }
@@ -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"
@@ -0,0 +1,55 @@
1
+ """Rule registry for pycodegate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pycodegate.rules.architecture import ArchitectureRules
6
+ from pycodegate.rules.base import BaseRules
7
+ from pycodegate.rules.celery import CeleryRules
8
+ from pycodegate.rules.complexity import ComplexityRules
9
+ from pycodegate.rules.correctness import CorrectnessRules
10
+ from pycodegate.rules.dependencies import DependencyRules as DependencyRules
11
+ from pycodegate.rules.django import DjangoRules
12
+ from pycodegate.rules.fastapi import FastAPIRules
13
+ from pycodegate.rules.flask import FlaskRules
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
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
22
+ from pycodegate.rules.security import SecurityRules
23
+ from pycodegate.rules.sqlalchemy import SQLAlchemyRules
24
+ from pycodegate.rules.structure import StructureRules as StructureRules
25
+
26
+
27
+ def get_all_rule_sets() -> list[BaseRules]:
28
+ """Return all available rule sets."""
29
+ return [
30
+ SecurityRules(),
31
+ PerformanceRules(),
32
+ ArchitectureRules(),
33
+ CorrectnessRules(),
34
+ ComplexityRules(),
35
+ ]
36
+
37
+
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
@@ -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
+ )