pycodegate 0.1.1__py3-none-any.whl → 0.1.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycodegate/__init__.py +1 -1
- pycodegate/cli.py +112 -45
- pycodegate/config.py +5 -5
- pycodegate/output.py +43 -25
- pycodegate/profile.py +56 -48
- pycodegate/rules/dependencies.py +28 -18
- pycodegate/rules/imports.py +57 -44
- pycodegate/rules/structure.py +34 -37
- pycodegate/score.py +32 -24
- pycodegate/utils/badge.py +7 -7
- pycodegate/utils/fixer.py +1 -1
- pycodegate/utils/precommit.py +9 -9
- {pycodegate-0.1.1.dist-info → pycodegate-0.1.3.dist-info}/METADATA +47 -43
- {pycodegate-0.1.1.dist-info → pycodegate-0.1.3.dist-info}/RECORD +17 -17
- {pycodegate-0.1.1.dist-info → pycodegate-0.1.3.dist-info}/entry_points.txt +0 -1
- {pycodegate-0.1.1.dist-info → pycodegate-0.1.3.dist-info}/WHEEL +0 -0
- {pycodegate-0.1.1.dist-info → pycodegate-0.1.3.dist-info}/licenses/LICENSE +0 -0
pycodegate/__init__.py
CHANGED
pycodegate/cli.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
6
7
|
|
|
7
8
|
import click
|
|
8
9
|
|
|
@@ -16,8 +17,96 @@ from pycodegate.utils.fixer import run_ruff_fix
|
|
|
16
17
|
from pycodegate.utils.precommit import install_precommit_hook
|
|
17
18
|
|
|
18
19
|
|
|
20
|
+
@dataclass
|
|
21
|
+
class CliOptions:
|
|
22
|
+
"""Bundle of all CLI flags passed to main()."""
|
|
23
|
+
|
|
24
|
+
directory: str
|
|
25
|
+
lint: bool
|
|
26
|
+
dead_code: bool
|
|
27
|
+
verbose: bool
|
|
28
|
+
score_only: bool
|
|
29
|
+
json_output: bool
|
|
30
|
+
diff_base: str | None
|
|
31
|
+
fail_on: str
|
|
32
|
+
fix: bool
|
|
33
|
+
profile_override: str | None
|
|
34
|
+
badge: bool
|
|
35
|
+
ci_workflow: bool
|
|
36
|
+
precommit: bool
|
|
37
|
+
min_score: int | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _handle_early_exits(opts: CliOptions) -> bool:
|
|
41
|
+
"""Handle flags that exit before scanning. Returns True if handled."""
|
|
42
|
+
if opts.ci_workflow:
|
|
43
|
+
click.echo(generate_ci_workflow(), nl=False)
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
if opts.precommit:
|
|
47
|
+
effective_min = opts.min_score if opts.min_score is not None else 50
|
|
48
|
+
msg = install_precommit_hook(opts.directory, min_score=effective_min)
|
|
49
|
+
click.echo(msg)
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _maybe_run_fix(opts: CliOptions) -> None:
|
|
56
|
+
"""Apply ruff auto-fix if --fix was passed."""
|
|
57
|
+
if not opts.fix:
|
|
58
|
+
return
|
|
59
|
+
fixes = run_ruff_fix(opts.directory)
|
|
60
|
+
if fixes == -1:
|
|
61
|
+
click.echo("ruff not found, skipping auto-fix")
|
|
62
|
+
else:
|
|
63
|
+
click.echo(f"Fixed {fixes} issues via ruff")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _build_config(opts: CliOptions):
|
|
67
|
+
"""Load config and apply CLI overrides."""
|
|
68
|
+
config = load_config(opts.directory)
|
|
69
|
+
config.lint = opts.lint
|
|
70
|
+
config.dead_code = opts.dead_code
|
|
71
|
+
config.verbose = opts.verbose
|
|
72
|
+
config.fail_on = opts.fail_on
|
|
73
|
+
if opts.profile_override is not None:
|
|
74
|
+
config.profile = opts.profile_override
|
|
75
|
+
return config
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _output_result(opts: CliOptions, result) -> None:
|
|
79
|
+
"""Write scan result to stdout in the requested format."""
|
|
80
|
+
if opts.badge:
|
|
81
|
+
click.echo(generate_badge(result.score.value, result.score.label))
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
if opts.json_output:
|
|
85
|
+
output_json(result)
|
|
86
|
+
elif opts.score_only:
|
|
87
|
+
click.echo(str(result.score.value))
|
|
88
|
+
else:
|
|
89
|
+
print_scan_result(result, verbose=opts.verbose)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _apply_exit_codes(opts: CliOptions, result) -> None:
|
|
93
|
+
"""Exit with non-zero code based on --fail-on and --min-score."""
|
|
94
|
+
has_errors = any(d.severity == Severity.ERROR for d in result.diagnostics)
|
|
95
|
+
if (opts.fail_on == "error" and has_errors) or (
|
|
96
|
+
opts.fail_on == "warning" and result.diagnostics
|
|
97
|
+
):
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
100
|
+
if opts.min_score is not None and result.score.value < opts.min_score:
|
|
101
|
+
click.echo(
|
|
102
|
+
f"pycodegate: score {result.score.value} is below minimum {opts.min_score}",
|
|
103
|
+
err=True,
|
|
104
|
+
)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
|
|
107
|
+
|
|
19
108
|
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
|
|
20
|
-
@click.version_option(__version__, "-v", "--version", prog_name="
|
|
109
|
+
@click.version_option(__version__, "-v", "--version", prog_name="PyCodeGate")
|
|
21
110
|
@click.argument("directory", default=".", type=click.Path(exists=True))
|
|
22
111
|
@click.option("--lint/--no-lint", default=True, help="Enable/disable lint checks.")
|
|
23
112
|
@click.option(
|
|
@@ -79,53 +168,31 @@ def main(
|
|
|
79
168
|
precommit: bool,
|
|
80
169
|
min_score: int | None,
|
|
81
170
|
) -> None:
|
|
82
|
-
"""
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
171
|
+
"""PyCodeGate — Diagnose your Python project's health."""
|
|
172
|
+
opts = CliOptions(
|
|
173
|
+
directory=directory,
|
|
174
|
+
lint=lint,
|
|
175
|
+
dead_code=dead_code,
|
|
176
|
+
verbose=verbose,
|
|
177
|
+
score_only=score_only,
|
|
178
|
+
json_output=json_output,
|
|
179
|
+
diff_base=diff_base,
|
|
180
|
+
fail_on=fail_on,
|
|
181
|
+
fix=fix,
|
|
182
|
+
profile_override=profile_override,
|
|
183
|
+
badge=badge,
|
|
184
|
+
ci_workflow=ci_workflow,
|
|
185
|
+
precommit=precommit,
|
|
186
|
+
min_score=min_score,
|
|
187
|
+
)
|
|
86
188
|
|
|
87
|
-
if
|
|
88
|
-
effective_min = min_score if min_score is not None else 50
|
|
89
|
-
msg = install_precommit_hook(directory, min_score=effective_min)
|
|
90
|
-
click.echo(msg)
|
|
189
|
+
if _handle_early_exits(opts):
|
|
91
190
|
return
|
|
92
191
|
|
|
93
|
-
|
|
94
|
-
fixes = run_ruff_fix(directory)
|
|
95
|
-
if fixes == -1:
|
|
96
|
-
click.echo("ruff not found, skipping auto-fix")
|
|
97
|
-
else:
|
|
98
|
-
click.echo(f"Fixed {fixes} issues via ruff")
|
|
99
|
-
|
|
100
|
-
config = load_config(directory)
|
|
101
|
-
config.lint = lint
|
|
102
|
-
config.dead_code = dead_code
|
|
103
|
-
config.verbose = verbose
|
|
104
|
-
config.fail_on = fail_on
|
|
105
|
-
if profile_override is not None:
|
|
106
|
-
config.profile = profile_override
|
|
192
|
+
_maybe_run_fix(opts)
|
|
107
193
|
|
|
194
|
+
config = _build_config(opts)
|
|
108
195
|
result = scan_project(directory, config, diff_base=diff_base)
|
|
109
196
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return
|
|
113
|
-
|
|
114
|
-
if json_output:
|
|
115
|
-
output_json(result)
|
|
116
|
-
elif score_only:
|
|
117
|
-
click.echo(str(result.score.value))
|
|
118
|
-
else:
|
|
119
|
-
print_scan_result(result, verbose=verbose)
|
|
120
|
-
|
|
121
|
-
# Exit code based on --fail-on
|
|
122
|
-
has_errors = any(d.severity == Severity.ERROR for d in result.diagnostics)
|
|
123
|
-
if (fail_on == "error" and has_errors) or (fail_on == "warning" and result.diagnostics):
|
|
124
|
-
sys.exit(1)
|
|
125
|
-
|
|
126
|
-
if min_score is not None and result.score.value < min_score:
|
|
127
|
-
click.echo(
|
|
128
|
-
f"py-gate: score {result.score.value} is below minimum {min_score}",
|
|
129
|
-
err=True,
|
|
130
|
-
)
|
|
131
|
-
sys.exit(1)
|
|
197
|
+
_output_result(opts, result)
|
|
198
|
+
_apply_exit_codes(opts, result)
|
pycodegate/config.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Configuration loading for
|
|
1
|
+
"""Configuration loading for pycodegate."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -22,11 +22,11 @@ class Config:
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def load_config(project_path: str) -> Config:
|
|
25
|
-
"""Load config from
|
|
25
|
+
"""Load config from pycodegate.toml or pyproject.toml [tool.pycodegate]."""
|
|
26
26
|
root = Path(project_path)
|
|
27
27
|
|
|
28
|
-
#
|
|
29
|
-
doctor_toml = root / "
|
|
28
|
+
# pycodegate.toml takes precedence
|
|
29
|
+
doctor_toml = root / "pycodegate.toml"
|
|
30
30
|
if doctor_toml.exists():
|
|
31
31
|
return _parse_doctor_toml(doctor_toml)
|
|
32
32
|
|
|
@@ -62,7 +62,7 @@ def _parse_pyproject_toml(path: Path) -> Config:
|
|
|
62
62
|
with open(path, "rb") as f:
|
|
63
63
|
data = tomllib.load(f)
|
|
64
64
|
|
|
65
|
-
section = data.get("tool", {}).get("
|
|
65
|
+
section = data.get("tool", {}).get("pycodegate", {})
|
|
66
66
|
if not section:
|
|
67
67
|
return Config()
|
|
68
68
|
|
pycodegate/output.py
CHANGED
|
@@ -138,7 +138,7 @@ def print_scan_result(result: ScanResult, verbose: bool = False) -> None:
|
|
|
138
138
|
# Project info header
|
|
139
139
|
p = result.project
|
|
140
140
|
console.print()
|
|
141
|
-
console.print(" [bold]
|
|
141
|
+
console.print(f" [bold]PyCodeGate[/bold] — v{__version__}")
|
|
142
142
|
console.print()
|
|
143
143
|
console.print(f" [dim]Path:[/dim] {p.path}")
|
|
144
144
|
if p.python_version:
|
|
@@ -213,6 +213,47 @@ def output_json(result: ScanResult) -> None:
|
|
|
213
213
|
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
|
214
214
|
|
|
215
215
|
|
|
216
|
+
def _print_rule_details(
|
|
217
|
+
console: Console,
|
|
218
|
+
rule_diags: list[Diagnostic],
|
|
219
|
+
verbose: bool,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Print a single rule line and, in verbose mode, the file locations."""
|
|
222
|
+
rule = rule_diags[0].rule
|
|
223
|
+
count = len(rule_diags)
|
|
224
|
+
sev = rule_diags[0].severity
|
|
225
|
+
icon = "[red]✗[/red]" if sev == Severity.ERROR else "[yellow]⚠[/yellow]"
|
|
226
|
+
console.print(f" {icon} {rule} × {count}")
|
|
227
|
+
if not verbose:
|
|
228
|
+
return
|
|
229
|
+
for d in rule_diags[:10]:
|
|
230
|
+
console.print(f" [dim]{d.file_path}:{d.line}[/dim]")
|
|
231
|
+
if count > 10:
|
|
232
|
+
console.print(f" [dim]... and {count - 10} more[/dim]")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _print_category_with_issues(
|
|
236
|
+
console: Console,
|
|
237
|
+
emoji: str,
|
|
238
|
+
name: str,
|
|
239
|
+
earned: int,
|
|
240
|
+
maximum: int,
|
|
241
|
+
cat_diags: list[Diagnostic],
|
|
242
|
+
verbose: bool,
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Print a category header and its grouped rule findings."""
|
|
245
|
+
console.print(f" [bold]{emoji} {name}[/bold] [yellow]({earned}/{maximum})[/yellow]")
|
|
246
|
+
by_rule: dict[str, list[Diagnostic]] = defaultdict(list)
|
|
247
|
+
for d in cat_diags:
|
|
248
|
+
by_rule[d.rule].append(d)
|
|
249
|
+
sorted_rules = sorted(
|
|
250
|
+
by_rule.keys(),
|
|
251
|
+
key=lambda r: (0 if by_rule[r][0].severity == Severity.ERROR else 1, r),
|
|
252
|
+
)
|
|
253
|
+
for rule in sorted_rules:
|
|
254
|
+
_print_rule_details(console, by_rule[rule], verbose)
|
|
255
|
+
|
|
256
|
+
|
|
216
257
|
def _print_category_groups(
|
|
217
258
|
console: Console,
|
|
218
259
|
diagnostics: list[Diagnostic],
|
|
@@ -233,38 +274,15 @@ def _print_category_groups(
|
|
|
233
274
|
continue
|
|
234
275
|
emoji, name = CATEGORY_DISPLAY[cat]
|
|
235
276
|
earned, maximum = sub_scores.get(cat, (CATEGORY_WEIGHTS[cat], CATEGORY_WEIGHTS[cat]))
|
|
236
|
-
# Recompute max from sub_scores (already normalised to 100-point scale)
|
|
237
277
|
cat_diags = by_category.get(cat, [])
|
|
238
278
|
|
|
239
279
|
if earned == maximum:
|
|
240
|
-
# Perfect score for this category
|
|
241
280
|
console.print(
|
|
242
281
|
f" [bold]{emoji} {name}[/bold] "
|
|
243
282
|
f"[green]({earned}/{maximum})[/green] [green]✓[/green]"
|
|
244
283
|
)
|
|
245
284
|
console.print(" [green]✓ All clear.[/green]")
|
|
246
285
|
else:
|
|
247
|
-
console
|
|
248
|
-
# Group by rule within this category
|
|
249
|
-
by_rule: dict[str, list[Diagnostic]] = defaultdict(list)
|
|
250
|
-
for d in cat_diags:
|
|
251
|
-
by_rule[d.rule].append(d)
|
|
252
|
-
|
|
253
|
-
# Sort: errors first, then by rule name
|
|
254
|
-
sorted_rules = sorted(
|
|
255
|
-
by_rule.keys(),
|
|
256
|
-
key=lambda r: (0 if by_rule[r][0].severity == Severity.ERROR else 1, r),
|
|
257
|
-
)
|
|
258
|
-
for rule in sorted_rules:
|
|
259
|
-
rule_diags = by_rule[rule]
|
|
260
|
-
count = len(rule_diags)
|
|
261
|
-
sev = rule_diags[0].severity
|
|
262
|
-
icon = "[red]✗[/red]" if sev == Severity.ERROR else "[yellow]⚠[/yellow]"
|
|
263
|
-
console.print(f" {icon} {rule} × {count}")
|
|
264
|
-
if verbose:
|
|
265
|
-
for d in rule_diags[:10]:
|
|
266
|
-
console.print(f" [dim]{d.file_path}:{d.line}[/dim]")
|
|
267
|
-
if count > 10:
|
|
268
|
-
console.print(f" [dim]... and {count - 10} more[/dim]")
|
|
286
|
+
_print_category_with_issues(console, emoji, name, earned, maximum, cat_diags, verbose)
|
|
269
287
|
|
|
270
288
|
console.print()
|
pycodegate/profile.py
CHANGED
|
@@ -32,6 +32,56 @@ PROFILES = {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def _normalise_dep(dep: str) -> str:
|
|
36
|
+
"""Strip version specifiers and extras from a dependency string."""
|
|
37
|
+
return dep.split("[")[0].split(">")[0].split("<")[0].split("=")[0].split("!")[0].strip().lower()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _deps_from_pyproject(pyproject: Path) -> tuple[set[str], bool, bool]:
|
|
41
|
+
"""Parse pyproject.toml and return (deps, has_build_system, has_scripts)."""
|
|
42
|
+
deps: set[str] = set()
|
|
43
|
+
has_build_system = False
|
|
44
|
+
has_scripts = False
|
|
45
|
+
try:
|
|
46
|
+
with open(pyproject, "rb") as f:
|
|
47
|
+
data = tomllib.load(f)
|
|
48
|
+
for dep in data.get("project", {}).get("dependencies", []):
|
|
49
|
+
deps.add(_normalise_dep(dep))
|
|
50
|
+
has_build_system = "build-system" in data
|
|
51
|
+
has_scripts = bool(data.get("project", {}).get("scripts"))
|
|
52
|
+
except (OSError, ValueError, KeyError):
|
|
53
|
+
pass
|
|
54
|
+
return deps, has_build_system, has_scripts
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _deps_from_requirements(path: Path) -> set[str]:
|
|
58
|
+
"""Parse requirements files and return the set of normalised dep names."""
|
|
59
|
+
deps: set[str] = set()
|
|
60
|
+
for req_file in ["requirements.txt", "requirements-dev.txt"]:
|
|
61
|
+
req_path = path / req_file
|
|
62
|
+
if not req_path.exists():
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
for line in req_path.read_text().splitlines():
|
|
66
|
+
line = line.strip()
|
|
67
|
+
if line and not line.startswith("#") and not line.startswith("-"):
|
|
68
|
+
deps.add(_normalise_dep(line))
|
|
69
|
+
except (OSError, ValueError):
|
|
70
|
+
pass
|
|
71
|
+
return deps
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _classify_by_deps(deps: set[str], has_scripts: bool) -> str | None:
|
|
75
|
+
"""Return a profile name based on dependencies, or None if undetermined."""
|
|
76
|
+
web_deps = {"flask", "django", "fastapi", "starlette", "tornado", "aiohttp", "sanic", "bottle"}
|
|
77
|
+
if deps & web_deps:
|
|
78
|
+
return "web"
|
|
79
|
+
cli_deps = {"click", "typer", "fire", "cement", "cliff", "argparse"}
|
|
80
|
+
if (deps & cli_deps) or has_scripts:
|
|
81
|
+
return "cli"
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
35
85
|
def detect_profile(project_path: str) -> Profile:
|
|
36
86
|
"""Auto-detect project profile from project structure and dependencies."""
|
|
37
87
|
path = Path(project_path)
|
|
@@ -42,56 +92,15 @@ def detect_profile(project_path: str) -> Profile:
|
|
|
42
92
|
has_scripts = False
|
|
43
93
|
|
|
44
94
|
if pyproject.exists():
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
data = tomllib.load(f)
|
|
48
|
-
# Collect dependency names
|
|
49
|
-
for dep in data.get("project", {}).get("dependencies", []):
|
|
50
|
-
deps.add(
|
|
51
|
-
dep.split("[")[0]
|
|
52
|
-
.split(">")[0]
|
|
53
|
-
.split("<")[0]
|
|
54
|
-
.split("=")[0]
|
|
55
|
-
.split("!")[0]
|
|
56
|
-
.strip()
|
|
57
|
-
.lower()
|
|
58
|
-
)
|
|
59
|
-
has_build_system = "build-system" in data
|
|
60
|
-
has_scripts = bool(data.get("project", {}).get("scripts"))
|
|
61
|
-
except Exception:
|
|
62
|
-
pass
|
|
95
|
+
pyproject_deps, has_build_system, has_scripts = _deps_from_pyproject(pyproject)
|
|
96
|
+
deps |= pyproject_deps
|
|
63
97
|
|
|
64
|
-
|
|
65
|
-
for req_file in ["requirements.txt", "requirements-dev.txt"]:
|
|
66
|
-
req_path = path / req_file
|
|
67
|
-
if req_path.exists():
|
|
68
|
-
try:
|
|
69
|
-
for line in req_path.read_text().splitlines():
|
|
70
|
-
line = line.strip()
|
|
71
|
-
if line and not line.startswith("#") and not line.startswith("-"):
|
|
72
|
-
deps.add(
|
|
73
|
-
line.split("[")[0]
|
|
74
|
-
.split(">")[0]
|
|
75
|
-
.split("<")[0]
|
|
76
|
-
.split("=")[0]
|
|
77
|
-
.split("!")[0]
|
|
78
|
-
.strip()
|
|
79
|
-
.lower()
|
|
80
|
-
)
|
|
81
|
-
except Exception:
|
|
82
|
-
pass
|
|
83
|
-
|
|
84
|
-
# Web frameworks
|
|
85
|
-
web_deps = {"flask", "django", "fastapi", "starlette", "tornado", "aiohttp", "sanic", "bottle"}
|
|
86
|
-
if deps & web_deps:
|
|
87
|
-
return PROFILES["web"]
|
|
98
|
+
deps |= _deps_from_requirements(path)
|
|
88
99
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
return PROFILES["cli"]
|
|
100
|
+
profile_name = _classify_by_deps(deps, has_scripts)
|
|
101
|
+
if profile_name is not None:
|
|
102
|
+
return PROFILES[profile_name]
|
|
93
103
|
|
|
94
|
-
# Library
|
|
95
104
|
if has_build_system and not has_scripts:
|
|
96
105
|
return PROFILES["library"]
|
|
97
106
|
|
|
@@ -103,5 +112,4 @@ def detect_profile(project_path: str) -> Profile:
|
|
|
103
112
|
if not has_init and len(py_files) <= 5:
|
|
104
113
|
return PROFILES["script"]
|
|
105
114
|
|
|
106
|
-
# Default to library
|
|
107
115
|
return PROFILES["library"]
|
pycodegate/rules/dependencies.py
CHANGED
|
@@ -14,15 +14,8 @@ from pycodegate.types import Category, Diagnostic, Severity
|
|
|
14
14
|
class DependencyRules:
|
|
15
15
|
"""Dependency vulnerability checks using pip-audit (optional)."""
|
|
16
16
|
|
|
17
|
-
def
|
|
18
|
-
|
|
19
|
-
return []
|
|
20
|
-
|
|
21
|
-
path = Path(project_path)
|
|
22
|
-
req_file = self._find_or_export_requirements(path)
|
|
23
|
-
if not req_file:
|
|
24
|
-
return []
|
|
25
|
-
|
|
17
|
+
def _run_pip_audit(self, req_file: Path) -> dict | None:
|
|
18
|
+
"""Run pip-audit against *req_file* and return parsed JSON, or None on failure."""
|
|
26
19
|
try:
|
|
27
20
|
result = subprocess.run(
|
|
28
21
|
[
|
|
@@ -38,17 +31,14 @@ class DependencyRules:
|
|
|
38
31
|
text=True,
|
|
39
32
|
timeout=60,
|
|
40
33
|
)
|
|
41
|
-
# pip-audit exits non-zero when vulnerabilities found
|
|
42
34
|
if not result.stdout.strip():
|
|
43
|
-
return
|
|
44
|
-
|
|
35
|
+
return None
|
|
36
|
+
return json.loads(result.stdout)
|
|
45
37
|
except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
|
|
46
|
-
return
|
|
47
|
-
finally:
|
|
48
|
-
# Clean up temp file if we created one
|
|
49
|
-
if req_file and str(req_file).startswith(tempfile.gettempdir()):
|
|
50
|
-
req_file.unlink(missing_ok=True)
|
|
38
|
+
return None
|
|
51
39
|
|
|
40
|
+
def _build_vuln_diagnostics(self, data: dict) -> list[Diagnostic]:
|
|
41
|
+
"""Convert pip-audit JSON output into Diagnostic objects."""
|
|
52
42
|
diags: list[Diagnostic] = []
|
|
53
43
|
for vuln in data.get("dependencies", []):
|
|
54
44
|
pkg_name = vuln.get("name", "unknown")
|
|
@@ -71,6 +61,26 @@ class DependencyRules:
|
|
|
71
61
|
)
|
|
72
62
|
return diags
|
|
73
63
|
|
|
64
|
+
def check_project(self, project_path: str) -> list[Diagnostic]:
|
|
65
|
+
if not shutil.which("pip-audit"):
|
|
66
|
+
return []
|
|
67
|
+
|
|
68
|
+
path = Path(project_path)
|
|
69
|
+
req_file = self._find_or_export_requirements(path)
|
|
70
|
+
if not req_file:
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
data = self._run_pip_audit(req_file)
|
|
75
|
+
finally:
|
|
76
|
+
# Clean up temp file if we created one
|
|
77
|
+
if req_file and str(req_file).startswith(tempfile.gettempdir()):
|
|
78
|
+
req_file.unlink(missing_ok=True)
|
|
79
|
+
|
|
80
|
+
if data is None:
|
|
81
|
+
return []
|
|
82
|
+
return self._build_vuln_diagnostics(data)
|
|
83
|
+
|
|
74
84
|
def _find_or_export_requirements(self, path: Path) -> Path | None:
|
|
75
85
|
# Try existing requirements.txt
|
|
76
86
|
for name in ["requirements.txt", "requirements.lock"]:
|
|
@@ -81,7 +91,7 @@ class DependencyRules:
|
|
|
81
91
|
# Try uv export
|
|
82
92
|
if shutil.which("uv") and (path / "pyproject.toml").exists():
|
|
83
93
|
try:
|
|
84
|
-
tmp = Path(tempfile.gettempdir()) / "
|
|
94
|
+
tmp = Path(tempfile.gettempdir()) / "pycodegate-reqs.txt"
|
|
85
95
|
result = subprocess.run(
|
|
86
96
|
[
|
|
87
97
|
"uv",
|
pycodegate/rules/imports.py
CHANGED
|
@@ -17,6 +17,60 @@ class ImportsRules:
|
|
|
17
17
|
"""Detect circular imports across the project."""
|
|
18
18
|
return self._check_circular_imports(project_path, source_files)
|
|
19
19
|
|
|
20
|
+
def _collect_imports_for_module(
|
|
21
|
+
self, filepath: str, root: str, graph: dict[str, set[str]], file_map: dict[str, str]
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Parse *filepath* and populate *graph* and *file_map* in-place."""
|
|
24
|
+
try:
|
|
25
|
+
source = Path(filepath).read_text()
|
|
26
|
+
tree = ast.parse(source)
|
|
27
|
+
except (SyntaxError, OSError, UnicodeDecodeError):
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
module_name = self._file_to_module(filepath, root)
|
|
31
|
+
if not module_name:
|
|
32
|
+
return
|
|
33
|
+
file_map[module_name] = filepath
|
|
34
|
+
|
|
35
|
+
for node in ast.walk(tree):
|
|
36
|
+
if isinstance(node, ast.Import):
|
|
37
|
+
for alias in node.names:
|
|
38
|
+
graph[module_name].add(alias.name)
|
|
39
|
+
elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
|
40
|
+
graph[module_name].add(node.module)
|
|
41
|
+
|
|
42
|
+
def _detect_cycles(
|
|
43
|
+
self,
|
|
44
|
+
graph: dict[str, set[str]],
|
|
45
|
+
file_map: dict[str, str],
|
|
46
|
+
) -> list[Diagnostic]:
|
|
47
|
+
"""Return diagnostics for every direct A↔B cycle in *graph*."""
|
|
48
|
+
diags: list[Diagnostic] = []
|
|
49
|
+
seen_pairs: set[tuple[str, str]] = set()
|
|
50
|
+
|
|
51
|
+
for mod_a, imports in graph.items():
|
|
52
|
+
for mod_b in imports:
|
|
53
|
+
if mod_b not in graph or mod_a not in graph[mod_b]:
|
|
54
|
+
continue
|
|
55
|
+
pair = tuple(sorted([mod_a, mod_b]))
|
|
56
|
+
if pair in seen_pairs:
|
|
57
|
+
continue
|
|
58
|
+
seen_pairs.add(pair)
|
|
59
|
+
diags.append(
|
|
60
|
+
Diagnostic(
|
|
61
|
+
file_path=file_map.get(mod_a, mod_a),
|
|
62
|
+
rule="imports/circular",
|
|
63
|
+
severity=Severity.ERROR,
|
|
64
|
+
category=Category.IMPORTS,
|
|
65
|
+
message=f"Circular import between '{mod_a}' and '{mod_b}'",
|
|
66
|
+
help="Break the cycle by moving shared code to a third module",
|
|
67
|
+
line=1,
|
|
68
|
+
cost=3.0,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return diags
|
|
73
|
+
|
|
20
74
|
def _check_circular_imports(
|
|
21
75
|
self, project_path: str, source_files: list[str]
|
|
22
76
|
) -> list[Diagnostic]:
|
|
@@ -24,53 +78,12 @@ class ImportsRules:
|
|
|
24
78
|
graph: dict[str, set[str]] = defaultdict(set)
|
|
25
79
|
file_map: dict[str, str] = {} # module_name -> file_path
|
|
26
80
|
|
|
27
|
-
root = Path(project_path).resolve()
|
|
81
|
+
root = str(Path(project_path).resolve())
|
|
28
82
|
|
|
29
83
|
for filepath in source_files:
|
|
30
|
-
|
|
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()
|
|
84
|
+
self._collect_imports_for_module(filepath, root, graph, file_map)
|
|
52
85
|
|
|
53
|
-
|
|
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
|
|
86
|
+
return self._detect_cycles(graph, file_map)
|
|
74
87
|
|
|
75
88
|
def _file_to_module(self, filepath: str, root: str) -> str | None:
|
|
76
89
|
"""Convert a file path to a dotted module name."""
|
pycodegate/rules/structure.py
CHANGED
|
@@ -49,6 +49,39 @@ class StructureRules:
|
|
|
49
49
|
pass
|
|
50
50
|
return diags
|
|
51
51
|
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _count_lines(files: list[str]) -> int:
|
|
54
|
+
"""Return total line count across *files*, ignoring unreadable files."""
|
|
55
|
+
return sum(len(Path(f).read_text().splitlines()) for f in files if Path(f).exists())
|
|
56
|
+
|
|
57
|
+
def _check_test_ratio(
|
|
58
|
+
self, path: Path, test_files: list[str], src_files: list[str]
|
|
59
|
+
) -> list[Diagnostic]:
|
|
60
|
+
"""Return a diagnostic if the test-to-source line ratio is too low."""
|
|
61
|
+
test_lines = self._count_lines(test_files)
|
|
62
|
+
src_lines = self._count_lines(src_files)
|
|
63
|
+
if src_lines == 0:
|
|
64
|
+
return []
|
|
65
|
+
ratio = test_lines / src_lines
|
|
66
|
+
if ratio < 0.1:
|
|
67
|
+
cost = 2.0
|
|
68
|
+
elif ratio < 0.3:
|
|
69
|
+
cost = 1.0
|
|
70
|
+
else:
|
|
71
|
+
return []
|
|
72
|
+
return [
|
|
73
|
+
Diagnostic(
|
|
74
|
+
file_path=str(path),
|
|
75
|
+
rule="structure/low-test-ratio",
|
|
76
|
+
severity=Severity.WARNING,
|
|
77
|
+
category=Category.STRUCTURE,
|
|
78
|
+
message=f"Test:source ratio is {ratio:.1f} (< 0.3)",
|
|
79
|
+
help="Add more tests to improve coverage",
|
|
80
|
+
line=0,
|
|
81
|
+
cost=cost,
|
|
82
|
+
)
|
|
83
|
+
]
|
|
84
|
+
|
|
52
85
|
def _check_tests(self, path: Path, source_files: list[str]) -> list[Diagnostic]:
|
|
53
86
|
test_files = [
|
|
54
87
|
f
|
|
@@ -71,43 +104,7 @@ class StructureRules:
|
|
|
71
104
|
)
|
|
72
105
|
]
|
|
73
106
|
|
|
74
|
-
|
|
75
|
-
test_lines = sum(
|
|
76
|
-
len(Path(f).read_text().splitlines()) for f in test_files if Path(f).exists()
|
|
77
|
-
)
|
|
78
|
-
src_lines = sum(
|
|
79
|
-
len(Path(f).read_text().splitlines()) for f in src_files if Path(f).exists()
|
|
80
|
-
)
|
|
81
|
-
|
|
82
|
-
if src_lines > 0:
|
|
83
|
-
ratio = test_lines / src_lines
|
|
84
|
-
if ratio < 0.1:
|
|
85
|
-
return [
|
|
86
|
-
Diagnostic(
|
|
87
|
-
file_path=str(path),
|
|
88
|
-
rule="structure/low-test-ratio",
|
|
89
|
-
severity=Severity.WARNING,
|
|
90
|
-
category=Category.STRUCTURE,
|
|
91
|
-
message=f"Test:source ratio is {ratio:.1f} (< 0.3)",
|
|
92
|
-
help="Add more tests to improve coverage",
|
|
93
|
-
line=0,
|
|
94
|
-
cost=2.0,
|
|
95
|
-
)
|
|
96
|
-
]
|
|
97
|
-
elif ratio < 0.3:
|
|
98
|
-
return [
|
|
99
|
-
Diagnostic(
|
|
100
|
-
file_path=str(path),
|
|
101
|
-
rule="structure/low-test-ratio",
|
|
102
|
-
severity=Severity.WARNING,
|
|
103
|
-
category=Category.STRUCTURE,
|
|
104
|
-
message=f"Test:source ratio is {ratio:.1f} (< 0.3)",
|
|
105
|
-
help="Add more tests to improve coverage",
|
|
106
|
-
line=0,
|
|
107
|
-
cost=1.0,
|
|
108
|
-
)
|
|
109
|
-
]
|
|
110
|
-
return []
|
|
107
|
+
return self._check_test_ratio(path, test_files, src_files)
|
|
111
108
|
|
|
112
109
|
def _check_readme(self, path: Path) -> list[Diagnostic]:
|
|
113
110
|
for name in ["README.md", "README.rst", "README", "readme.md"]:
|
pycodegate/score.py
CHANGED
|
@@ -15,16 +15,10 @@ from pycodegate.constants import (
|
|
|
15
15
|
from pycodegate.types import Diagnostic, Score
|
|
16
16
|
|
|
17
17
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
) ->
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
Each category has a weight that determines its maximum deduction budget.
|
|
24
|
-
Within a category, the top 3 findings are counted at full cost; additional
|
|
25
|
-
findings apply diminishing returns (10% each) to reward fixing top issues.
|
|
26
|
-
"""
|
|
27
|
-
# 1. Compute max_deduction per category from weights
|
|
18
|
+
def _build_budget(
|
|
19
|
+
max_deduction_overrides: dict | None,
|
|
20
|
+
) -> dict:
|
|
21
|
+
"""Return per-category maximum deduction budget normalised to sum to 100."""
|
|
28
22
|
total_weight = sum(CATEGORY_WEIGHTS.values())
|
|
29
23
|
max_deductions = {cat: round(w / total_weight * 100) for cat, w in CATEGORY_WEIGHTS.items()}
|
|
30
24
|
|
|
@@ -34,19 +28,43 @@ def calculate_score(
|
|
|
34
28
|
highest = max(CATEGORY_WEIGHTS, key=CATEGORY_WEIGHTS.get)
|
|
35
29
|
max_deductions[highest] += diff
|
|
36
30
|
|
|
37
|
-
# Apply overrides
|
|
38
31
|
if max_deduction_overrides:
|
|
39
32
|
for cat, val in max_deduction_overrides.items():
|
|
40
33
|
if cat in max_deductions:
|
|
41
34
|
max_deductions[cat] = val
|
|
42
35
|
|
|
43
|
-
|
|
36
|
+
return max_deductions
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _score_label(value: int) -> str:
|
|
40
|
+
"""Return a human-readable label for a numeric score."""
|
|
41
|
+
if value >= 90:
|
|
42
|
+
return LABEL_EXCELLENT
|
|
43
|
+
if value >= 75:
|
|
44
|
+
return LABEL_GREAT
|
|
45
|
+
if value >= 50:
|
|
46
|
+
return LABEL_NEEDS_WORK
|
|
47
|
+
return LABEL_CRITICAL
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def calculate_score(
|
|
51
|
+
diagnostics: list[Diagnostic], max_deduction_overrides: dict | None = None
|
|
52
|
+
) -> Score:
|
|
53
|
+
"""Calculate a 0-100 health score from diagnostics using category budgets.
|
|
54
|
+
|
|
55
|
+
Each category has a weight that determines its maximum deduction budget.
|
|
56
|
+
Within a category, the top 3 findings are counted at full cost; additional
|
|
57
|
+
findings apply diminishing returns (10% each) to reward fixing top issues.
|
|
58
|
+
"""
|
|
59
|
+
max_deductions = _build_budget(max_deduction_overrides)
|
|
60
|
+
|
|
61
|
+
# Group diagnostics by resolved category
|
|
44
62
|
by_category: dict = defaultdict(list)
|
|
45
63
|
for d in diagnostics:
|
|
46
64
|
resolved = FRAMEWORK_CATEGORY_MAP.get(d.category, d.category)
|
|
47
65
|
by_category[resolved].append(d)
|
|
48
66
|
|
|
49
|
-
#
|
|
67
|
+
# Calculate deduction per category with diminishing returns
|
|
50
68
|
total_deduction = 0.0
|
|
51
69
|
for cat, diags in by_category.items():
|
|
52
70
|
costs = sorted([d.cost for d in diags], reverse=True)
|
|
@@ -56,14 +74,4 @@ def calculate_score(
|
|
|
56
74
|
total_deduction += min(cat_total, cap)
|
|
57
75
|
|
|
58
76
|
value = max(0, round(100 - total_deduction))
|
|
59
|
-
|
|
60
|
-
if value >= 90:
|
|
61
|
-
label = LABEL_EXCELLENT
|
|
62
|
-
elif value >= 75:
|
|
63
|
-
label = LABEL_GREAT
|
|
64
|
-
elif value >= 50:
|
|
65
|
-
label = LABEL_NEEDS_WORK
|
|
66
|
-
else:
|
|
67
|
-
label = LABEL_CRITICAL
|
|
68
|
-
|
|
69
|
-
return Score(value=value, label=label)
|
|
77
|
+
return Score(value=value, label=_score_label(value))
|
pycodegate/utils/badge.py
CHANGED
|
@@ -17,7 +17,7 @@ def generate_badge(score: int, label: str) -> str:
|
|
|
17
17
|
color = "red"
|
|
18
18
|
|
|
19
19
|
score_text = urllib.parse.quote(f"{score}/100")
|
|
20
|
-
return f""
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
def generate_ci_workflow() -> str:
|
|
@@ -36,16 +36,16 @@ jobs:
|
|
|
36
36
|
steps:
|
|
37
37
|
- uses: actions/checkout@v4
|
|
38
38
|
- uses: astral-sh/setup-uv@v5
|
|
39
|
-
- run: uv tool install
|
|
40
|
-
- name: Run
|
|
39
|
+
- run: uv tool install pycodegate
|
|
40
|
+
- name: Run pycodegate
|
|
41
41
|
run: |
|
|
42
|
-
SCORE=$(
|
|
43
|
-
BADGE=$(
|
|
42
|
+
SCORE=$(pycodegate . --score)
|
|
43
|
+
BADGE=$(pycodegate . --badge)
|
|
44
44
|
# Update badge in README
|
|
45
|
-
sed -i "s|!\\[
|
|
45
|
+
sed -i "s|!\\[pycodegate score\\](.*)|${BADGE}|" README.md
|
|
46
46
|
- name: Commit badge update
|
|
47
47
|
run: |
|
|
48
48
|
git config user.name "github-actions[bot]"
|
|
49
49
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
50
|
-
git diff --quiet README.md || (git add README.md && git commit -m "chore: update
|
|
50
|
+
git diff --quiet README.md || (git add README.md && git commit -m "chore: update pycodegate score badge" && git push)
|
|
51
51
|
"""
|
pycodegate/utils/fixer.py
CHANGED
|
@@ -21,7 +21,7 @@ def run_ruff_fix(project_path: str) -> int:
|
|
|
21
21
|
except FileNotFoundError:
|
|
22
22
|
# ruff is not installed
|
|
23
23
|
return -1
|
|
24
|
-
except
|
|
24
|
+
except (OSError, subprocess.SubprocessError):
|
|
25
25
|
return 0
|
|
26
26
|
|
|
27
27
|
# ruff prints something like "Fixed 3 errors." to stdout or stderr
|
pycodegate/utils/precommit.py
CHANGED
|
@@ -6,31 +6,31 @@ from pathlib import Path
|
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def install_precommit_hook(project_path: str, min_score: int = 50) -> str:
|
|
9
|
-
"""Install a git pre-commit hook that runs
|
|
9
|
+
"""Install a git pre-commit hook that runs pycodegate."""
|
|
10
10
|
hooks_dir = Path(project_path) / ".git" / "hooks"
|
|
11
11
|
if not hooks_dir.exists():
|
|
12
12
|
return "Error: not a git repository"
|
|
13
13
|
|
|
14
14
|
hook_path = hooks_dir / "pre-commit"
|
|
15
15
|
hook_content = f"""#!/bin/sh
|
|
16
|
-
#
|
|
17
|
-
SCORE=$(
|
|
16
|
+
# pycodegate pre-commit hook
|
|
17
|
+
SCORE=$(pycodegate . --score 2>/dev/null)
|
|
18
18
|
if [ $? -ne 0 ]; then
|
|
19
|
-
echo "
|
|
19
|
+
echo "pycodegate not found. Install with: uvx pycodegate"
|
|
20
20
|
exit 0
|
|
21
21
|
fi
|
|
22
22
|
if [ "$SCORE" -lt {min_score} ]; then
|
|
23
|
-
echo "
|
|
24
|
-
|
|
23
|
+
echo "pycodegate: score $SCORE is below minimum {min_score}. Fix issues before committing."
|
|
24
|
+
pycodegate . 2>/dev/null
|
|
25
25
|
exit 1
|
|
26
26
|
fi
|
|
27
|
-
echo "
|
|
27
|
+
echo "pycodegate: score $SCORE/{min_score} — passed"
|
|
28
28
|
"""
|
|
29
29
|
|
|
30
30
|
# Don't overwrite existing hook — append or warn
|
|
31
31
|
if hook_path.exists():
|
|
32
32
|
existing = hook_path.read_text()
|
|
33
|
-
if "
|
|
33
|
+
if "pycodegate" in existing:
|
|
34
34
|
hook_path.write_text(hook_content)
|
|
35
35
|
hook_path.chmod(0o755)
|
|
36
36
|
return f"Updated pre-commit hook at {hook_path} (min-score: {min_score})"
|
|
@@ -41,7 +41,7 @@ echo "py-gate: score $SCORE/{min_score} — passed"
|
|
|
41
41
|
body = "".join(lines[1:]) if lines[0].startswith("#!/") else hook_content
|
|
42
42
|
with open(hook_path, "a") as f:
|
|
43
43
|
f.write("\n" + body)
|
|
44
|
-
return f"Appended
|
|
44
|
+
return f"Appended pycodegate to existing pre-commit hook at {hook_path}"
|
|
45
45
|
|
|
46
46
|
hook_path.write_text(hook_content)
|
|
47
47
|
hook_path.chmod(0o755)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycodegate
|
|
3
|
-
Version: 0.1.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.1.3
|
|
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
|
|
7
7
|
Project-URL: Issues, https://github.com/themohitkhare/pycodegate/issues
|
|
@@ -32,22 +32,26 @@ Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
|
32
32
|
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
33
33
|
Description-Content-Type: text/markdown
|
|
34
34
|
|
|
35
|
-
<h1 align="center">
|
|
35
|
+
<h1 align="center">PyCodeGate</h1>
|
|
36
36
|
<p align="center">
|
|
37
|
-
<strong>
|
|
37
|
+
<strong>Trust, but verify. The quality gate for AI-generated Python code.</strong>
|
|
38
38
|
</p>
|
|
39
39
|
<p align="center">
|
|
40
40
|
<a href="https://github.com/themohitkhare/pycodegate/actions/workflows/ci.yml"><img src="https://github.com/themohitkhare/pycodegate/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
41
|
-
<a href="https://pypi.org/project/
|
|
42
|
-
<a href="https://pypi.org/project/
|
|
41
|
+
<a href="https://pypi.org/project/pycodegate/"><img src="https://img.shields.io/pypi/v/pycodegate" alt="PyPI"></a>
|
|
42
|
+
<a href="https://pypi.org/project/pycodegate/"><img src="https://img.shields.io/pypi/pyversions/pycodegate" alt="Python"></a>
|
|
43
43
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
|
|
44
44
|
</p>
|
|
45
45
|
|
|
46
46
|
---
|
|
47
47
|
|
|
48
|
+
## Why PyCodeGate?
|
|
49
|
+
|
|
50
|
+
AI coding agents ship code fast — but fast doesn't mean safe. Every `eval()` an LLM drops in, every mutable default it forgets, every circular import it creates is a landmine waiting to go off. PyCodeGate is the trust layer between AI-generated code and your production codebase: one command, one score, zero ambiguity.
|
|
51
|
+
|
|
48
52
|
## How it works
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
PyCodeGate detects your project's context: framework (Django, FastAPI, Flask), Python version, package manager (uv, poetry, pip), and test framework. That context drives which rules are active — Django projects get SQL injection checks, FastAPI projects get async-correctness checks, and so on.
|
|
51
55
|
|
|
52
56
|
It then runs two analysis passes **in parallel**: a lint pass that evaluates 40+ rules across 8 categories (Security, Correctness, Complexity, Architecture, Performance, Structure, Imports, Dead Code), and a dead-code pass powered by [Vulture](https://github.com/jendrikseipp/vulture) that finds unused functions, classes, imports, and variables.
|
|
53
57
|
|
|
@@ -58,39 +62,39 @@ Findings are filtered through your configuration and scored using a **weighted c
|
|
|
58
62
|
Run instantly with `uvx` — no install needed:
|
|
59
63
|
|
|
60
64
|
```bash
|
|
61
|
-
uvx
|
|
65
|
+
uvx pycodegate .
|
|
62
66
|
```
|
|
63
67
|
|
|
64
68
|
Install globally with pipx or uv:
|
|
65
69
|
|
|
66
70
|
```bash
|
|
67
|
-
pipx install
|
|
71
|
+
pipx install pycodegate
|
|
68
72
|
# or
|
|
69
|
-
uv tool install
|
|
73
|
+
uv tool install pycodegate
|
|
70
74
|
# or
|
|
71
|
-
pip install
|
|
75
|
+
pip install pycodegate
|
|
72
76
|
```
|
|
73
77
|
|
|
74
78
|
## Quick Start
|
|
75
79
|
|
|
76
80
|
```bash
|
|
77
81
|
# Basic scan — score + summary
|
|
78
|
-
|
|
82
|
+
pycodegate .
|
|
79
83
|
|
|
80
84
|
# Show file paths and line numbers for every finding
|
|
81
|
-
|
|
85
|
+
pycodegate . --verbose
|
|
82
86
|
|
|
83
87
|
# Machine-readable output for AI agents and CI
|
|
84
|
-
|
|
88
|
+
pycodegate . --json
|
|
85
89
|
|
|
86
90
|
# Auto-fix ruff-fixable issues, then scan
|
|
87
|
-
|
|
91
|
+
pycodegate . --fix
|
|
88
92
|
|
|
89
93
|
# Output only the numeric score (useful in scripts)
|
|
90
|
-
|
|
94
|
+
pycodegate . --score
|
|
91
95
|
|
|
92
96
|
# Scan only files changed vs a base branch
|
|
93
|
-
|
|
97
|
+
pycodegate . --diff main
|
|
94
98
|
```
|
|
95
99
|
|
|
96
100
|
## JSON Output
|
|
@@ -98,7 +102,7 @@ py-gate . --diff main
|
|
|
98
102
|
Pass `--json` to get structured output that AI agents and CI pipelines can parse:
|
|
99
103
|
|
|
100
104
|
```bash
|
|
101
|
-
|
|
105
|
+
pycodegate . --json
|
|
102
106
|
```
|
|
103
107
|
|
|
104
108
|
```json
|
|
@@ -139,7 +143,7 @@ py-gate . --json
|
|
|
139
143
|
|
|
140
144
|
## Agent Integration
|
|
141
145
|
|
|
142
|
-
|
|
146
|
+
PyCodeGate is designed to be used by AI coding agents. Add it to your agent's context so it runs after every Python change.
|
|
143
147
|
|
|
144
148
|
### Claude Code
|
|
145
149
|
|
|
@@ -147,18 +151,18 @@ Add the skill to your project:
|
|
|
147
151
|
|
|
148
152
|
```bash
|
|
149
153
|
mkdir -p .claude/skills
|
|
150
|
-
curl -fsSL https://raw.githubusercontent.com/themohitkhare/pycodegate/main/skills/
|
|
151
|
-
-o .claude/skills/
|
|
154
|
+
curl -fsSL https://raw.githubusercontent.com/themohitkhare/pycodegate/main/skills/pycodegate/SKILL.md \
|
|
155
|
+
-o .claude/skills/pycodegate.md
|
|
152
156
|
```
|
|
153
157
|
|
|
154
158
|
Or copy `AGENTS.md` to your project root — Claude Code picks it up automatically.
|
|
155
159
|
|
|
156
160
|
### Cursor
|
|
157
161
|
|
|
158
|
-
Add to `.cursor/rules/
|
|
162
|
+
Add to `.cursor/rules/pycodegate.mdc`:
|
|
159
163
|
|
|
160
164
|
```markdown
|
|
161
|
-
After modifying Python files, run `uvx
|
|
165
|
+
After modifying Python files, run `uvx pycodegate . --json` and fix all findings
|
|
162
166
|
with severity "error" before marking the task complete. Target score: 80+.
|
|
163
167
|
```
|
|
164
168
|
|
|
@@ -167,7 +171,7 @@ with severity "error" before marking the task complete. Target score: 80+.
|
|
|
167
171
|
Add to `.windsurfrules`:
|
|
168
172
|
|
|
169
173
|
```
|
|
170
|
-
After modifying Python files, run: uvx
|
|
174
|
+
After modifying Python files, run: uvx pycodegate . --json
|
|
171
175
|
Fix all "error" severity findings. Re-run to verify the score improved.
|
|
172
176
|
```
|
|
173
177
|
|
|
@@ -176,7 +180,7 @@ Fix all "error" severity findings. Re-run to verify the score improved.
|
|
|
176
180
|
Add to your system prompt:
|
|
177
181
|
|
|
178
182
|
```
|
|
179
|
-
After modifying Python files, run `uvx
|
|
183
|
+
After modifying Python files, run `uvx pycodegate . --json` to check code quality.
|
|
180
184
|
Fix errors first. Target score: 80+.
|
|
181
185
|
```
|
|
182
186
|
|
|
@@ -194,7 +198,7 @@ name: Quality Gate
|
|
|
194
198
|
on: [push, pull_request]
|
|
195
199
|
|
|
196
200
|
jobs:
|
|
197
|
-
|
|
201
|
+
pycodegate:
|
|
198
202
|
runs-on: ubuntu-latest
|
|
199
203
|
steps:
|
|
200
204
|
- uses: actions/checkout@v4
|
|
@@ -205,10 +209,10 @@ jobs:
|
|
|
205
209
|
with:
|
|
206
210
|
python-version: "3.12"
|
|
207
211
|
|
|
208
|
-
- name: Run
|
|
212
|
+
- name: Run PyCodeGate
|
|
209
213
|
run: |
|
|
210
|
-
pip install
|
|
211
|
-
|
|
214
|
+
pip install pycodegate
|
|
215
|
+
pycodegate . --verbose --diff main --fail-on error
|
|
212
216
|
```
|
|
213
217
|
|
|
214
218
|
## CLI Reference
|
|
@@ -244,7 +248,7 @@ Framework-specific rules (Django, FastAPI, Flask) are mapped into the Security o
|
|
|
244
248
|
|
|
245
249
|
## Scoring
|
|
246
250
|
|
|
247
|
-
|
|
251
|
+
PyCodeGate uses a **weighted category-budget system**:
|
|
248
252
|
|
|
249
253
|
1. Each category has a weight (see table above). Weights are normalized to sum to 100 points of total deduction budget.
|
|
250
254
|
2. Within a category, findings are sorted by cost (errors cost more than warnings). The top 3 findings apply at full cost; every additional finding applies **diminishing returns** (10% of its cost).
|
|
@@ -260,7 +264,7 @@ Py Gate uses a **weighted category-budget system**:
|
|
|
260
264
|
|
|
261
265
|
## Configuration
|
|
262
266
|
|
|
263
|
-
Create `
|
|
267
|
+
Create `pycodegate.toml` in your project root:
|
|
264
268
|
|
|
265
269
|
```toml
|
|
266
270
|
[options]
|
|
@@ -284,27 +288,27 @@ max-deduction.Dead Code = 0 # disable dead-code penalty entirely
|
|
|
284
288
|
Or use `pyproject.toml`:
|
|
285
289
|
|
|
286
290
|
```toml
|
|
287
|
-
[tool.
|
|
291
|
+
[tool.pycodegate]
|
|
288
292
|
lint = true
|
|
289
293
|
dead_code = true
|
|
290
294
|
fail_on = "error"
|
|
291
295
|
|
|
292
|
-
[tool.
|
|
296
|
+
[tool.pycodegate.ignore]
|
|
293
297
|
rules = ["no-import-in-function"]
|
|
294
298
|
files = ["tests/fixtures/**"]
|
|
295
299
|
|
|
296
|
-
[tool.
|
|
300
|
+
[tool.pycodegate.per-file-ignores]
|
|
297
301
|
"src/legacy/*.py" = ["high-complexity"]
|
|
298
302
|
|
|
299
|
-
[tool.
|
|
303
|
+
[tool.pycodegate.scoring]
|
|
300
304
|
"max-deduction" = { Security = 20, "Dead Code" = 0 }
|
|
301
305
|
```
|
|
302
306
|
|
|
303
|
-
If both files exist, `
|
|
307
|
+
If both files exist, `pycodegate.toml` takes precedence. CLI flags always override config values.
|
|
304
308
|
|
|
305
309
|
## Profiles
|
|
306
310
|
|
|
307
|
-
|
|
311
|
+
PyCodeGate auto-detects a project profile and adjusts rule weights accordingly. You can also set a profile explicitly in config (`profile = "library"`).
|
|
308
312
|
|
|
309
313
|
| Profile | Auto-detected when | Adjustments |
|
|
310
314
|
|---------|--------------------|-------------|
|
|
@@ -315,10 +319,10 @@ Py Gate auto-detects a project profile and adjusts rule weights accordingly. You
|
|
|
315
319
|
|
|
316
320
|
## Pre-commit Hook
|
|
317
321
|
|
|
318
|
-
Use `--pre-commit` to run
|
|
322
|
+
Use `--pre-commit` to run PyCodeGate as a pre-commit hook. It automatically scans only the staged files:
|
|
319
323
|
|
|
320
324
|
```bash
|
|
321
|
-
|
|
325
|
+
pycodegate . --pre-commit --fail-on error
|
|
322
326
|
```
|
|
323
327
|
|
|
324
328
|
Add to `.pre-commit-config.yaml`:
|
|
@@ -327,9 +331,9 @@ Add to `.pre-commit-config.yaml`:
|
|
|
327
331
|
repos:
|
|
328
332
|
- repo: local
|
|
329
333
|
hooks:
|
|
330
|
-
- id:
|
|
331
|
-
name:
|
|
332
|
-
entry:
|
|
334
|
+
- id: pycodegate
|
|
335
|
+
name: PyCodeGate quality check
|
|
336
|
+
entry: pycodegate . --pre-commit --fail-on error
|
|
333
337
|
language: system
|
|
334
338
|
types: [python]
|
|
335
339
|
pass_filenames: false
|
|
@@ -342,7 +346,7 @@ git clone https://github.com/themohitkhare/pycodegate
|
|
|
342
346
|
cd pycodegate
|
|
343
347
|
uv sync --all-extras
|
|
344
348
|
uv run pytest -q
|
|
345
|
-
uv run
|
|
349
|
+
uv run pycodegate . --verbose # dogfood it
|
|
346
350
|
```
|
|
347
351
|
|
|
348
352
|
To add a new rule:
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
pycodegate/__init__.py,sha256=
|
|
1
|
+
pycodegate/__init__.py,sha256=C30sIG3GQ43ZgC_imlRU7Ym6StPA5Soq3tnxztdN24Q,84
|
|
2
2
|
pycodegate/_compat.py,sha256=tnXZge9z8NFCDz-zi_vAScbJkDNJo2_vpEW_UgSqbd8,293
|
|
3
3
|
pycodegate/api.py,sha256=W93EWmBlzrE2Uwrs4opr4F0QO-1v6GzRp1jN05b9rBo,631
|
|
4
|
-
pycodegate/cli.py,sha256=
|
|
5
|
-
pycodegate/config.py,sha256=
|
|
4
|
+
pycodegate/cli.py,sha256=O0B7e6JP42dI5a5zeYuT6VbBbKXi4vtBiF1eWWJ3zxQ,5838
|
|
5
|
+
pycodegate/config.py,sha256=56wBpGecabUYYs3UN_olDyzG49My7SumorMTDvlxRuY,2462
|
|
6
6
|
pycodegate/constants.py,sha256=HJnPIZR1OcpDGmqIsNCFan7fHzezJZtt105GT7-WTfI,788
|
|
7
7
|
pycodegate/discover.py,sha256=ywIPIJD-fM5PHt6ACRhehuVoJVyhm36Wf28HFGn0E3I,3694
|
|
8
|
-
pycodegate/output.py,sha256=
|
|
9
|
-
pycodegate/profile.py,sha256=
|
|
8
|
+
pycodegate/output.py,sha256=mUWLoHg9FtZ73wd0KNnC1QErO2kCic_9ITi0hUmMep8,9918
|
|
9
|
+
pycodegate/profile.py,sha256=okmh8_o_P5YpJxUnrOPGVxzDWZhBOb5WCJSkVVoO23c,3862
|
|
10
10
|
pycodegate/scan.py,sha256=WEw_-a-uiDYiz3i8c3gHl8QXBRb_pVgeJciYB_E68Ws,7282
|
|
11
|
-
pycodegate/score.py,sha256=
|
|
11
|
+
pycodegate/score.py,sha256=1ANbWGABy4raiVn_32NCQG-Pu4eNCqBIx90AvXoQL_Q,2613
|
|
12
12
|
pycodegate/types.py,sha256=_5eDkdNmxTkfxDNE-g_FLKpcUF6l0ArKZfwXafhYn2I,1285
|
|
13
13
|
pycodegate/rules/__init__.py,sha256=Do4SzAF41GAnEUS1tn2lPZK99wSYJ67OaLKZD62B2fY,1353
|
|
14
14
|
pycodegate/rules/architecture.py,sha256=e1dh_bSNyno8SaWV2UK4YoSCVMhhDphUpFrK3TSa6Xc,5151
|
|
@@ -16,23 +16,23 @@ pycodegate/rules/base.py,sha256=u7qejC4rDfXMh5RYy_3r-htxxSVTRB5zHETEnQt6X_M,593
|
|
|
16
16
|
pycodegate/rules/complexity.py,sha256=roYTPx3i8PCltqVpHWPt_U0jMZ2MZumJXeZ6mK6RNcU,3388
|
|
17
17
|
pycodegate/rules/correctness.py,sha256=go6n1fcv4NDu89zL8j4LLX-kOBgBRvuOx78C11bxhLg,6049
|
|
18
18
|
pycodegate/rules/dead_code.py,sha256=ZzawHLHbYKaNoK9zcnQQA99clUkOAdA4y64VtUDUouQ,1795
|
|
19
|
-
pycodegate/rules/dependencies.py,sha256=
|
|
19
|
+
pycodegate/rules/dependencies.py,sha256=NOg9rRhzEP92s6_IkYEN0o3dHDKpgERzspoQGgahRbk,4123
|
|
20
20
|
pycodegate/rules/django.py,sha256=IddI4eVnSZ2hDJW5EZu3xnPyrmjVwHq0DOnrpV7XncM,6385
|
|
21
21
|
pycodegate/rules/fastapi.py,sha256=16LOX97gXvVsWf0cvdqp5Ji-byzlKQP51iAy_yGX14Q,2769
|
|
22
22
|
pycodegate/rules/flask.py,sha256=htKFDjV2VZtAyE4ELlxYqDQUSYM4ROJZnS7UI_PK-xA,4574
|
|
23
|
-
pycodegate/rules/imports.py,sha256=
|
|
23
|
+
pycodegate/rules/imports.py,sha256=L5o_JtG1DNvV5LdoG1PgEwjf6CrWgPsUSMzK7v1Q5g0,3751
|
|
24
24
|
pycodegate/rules/performance.py,sha256=qZ9Tw0CNwjeIsqDwPulZXZu0VWWJQuU-r0TvG9odaQU,4512
|
|
25
25
|
pycodegate/rules/security.py,sha256=fdISx3yLVtQ2OjVtnaDBUOPXK6Tpq4ykntRlKr_1bAs,7515
|
|
26
|
-
pycodegate/rules/structure.py,sha256=
|
|
26
|
+
pycodegate/rules/structure.py,sha256=rURxlkMjQp6O-0xhe72fscTuUYZVyU-YxGWhbxXeSwA,8938
|
|
27
27
|
pycodegate/utils/__init__.py,sha256=MTVfXf-fKi2bjxXedkveORadRqo5qSUK4pMl08WJWdA,41
|
|
28
28
|
pycodegate/utils/ast_helpers.py,sha256=bRQwOsUvNASjq0wjZ_Yc0cDmVnXPGhujzxZV2Qff0Z8,538
|
|
29
|
-
pycodegate/utils/badge.py,sha256=
|
|
29
|
+
pycodegate/utils/badge.py,sha256=dh49h3LaOY402WxestPb0m38wQWhLZjNMn5NyuOT_ag,1432
|
|
30
30
|
pycodegate/utils/diff.py,sha256=o8YuqBqOwiHmPDsjFKXAlPphd0m8y20EwXRrcSvaoIg,824
|
|
31
31
|
pycodegate/utils/file_discovery.py,sha256=2tqW1LPoNjH2Y57UDo9sLyCioXvKBv8vFTmtmh2knpw,1472
|
|
32
|
-
pycodegate/utils/fixer.py,sha256=
|
|
33
|
-
pycodegate/utils/precommit.py,sha256=
|
|
34
|
-
pycodegate-0.1.
|
|
35
|
-
pycodegate-0.1.
|
|
36
|
-
pycodegate-0.1.
|
|
37
|
-
pycodegate-0.1.
|
|
38
|
-
pycodegate-0.1.
|
|
32
|
+
pycodegate/utils/fixer.py,sha256=SlKH1mt9YaMxfZk-T_rFOc3FeDKNTnqTzbFdiD9eTXI,918
|
|
33
|
+
pycodegate/utils/precommit.py,sha256=nzcmXc0NPAaxL-ZqDkDyHuuua4SiRXK2HrJZq3HLkMc,1737
|
|
34
|
+
pycodegate-0.1.3.dist-info/METADATA,sha256=oexR4oMM3sHfX4vMPeoYGyCvMxtJvA-qIaKWzddxtik,11879
|
|
35
|
+
pycodegate-0.1.3.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
36
|
+
pycodegate-0.1.3.dist-info/entry_points.txt,sha256=oSjjP_VAA3O74Y5a-IqIHYsT7-Ljuv97wXiMQ5RdOg0,51
|
|
37
|
+
pycodegate-0.1.3.dist-info/licenses/LICENSE,sha256=YQAkET2EceAFEwM7LLUco13527CKFujpBapIIRPshTQ,1068
|
|
38
|
+
pycodegate-0.1.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|