pycodegate 0.1.2__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 +110 -43
- pycodegate/output.py +42 -26
- pycodegate/profile.py +56 -48
- pycodegate/rules/dependencies.py +27 -17
- pycodegate/rules/imports.py +57 -44
- pycodegate/rules/structure.py +34 -37
- pycodegate/score.py +32 -24
- pycodegate/utils/fixer.py +1 -1
- {pycodegate-0.1.2.dist-info → pycodegate-0.1.3.dist-info}/METADATA +8 -4
- {pycodegate-0.1.2.dist-info → pycodegate-0.1.3.dist-info}/RECORD +14 -14
- {pycodegate-0.1.2.dist-info → pycodegate-0.1.3.dist-info}/WHEEL +0 -0
- {pycodegate-0.1.2.dist-info → pycodegate-0.1.3.dist-info}/entry_points.txt +0 -0
- {pycodegate-0.1.2.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,6 +17,94 @@ 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
109
|
@click.version_option(__version__, "-v", "--version", prog_name="PyCodeGate")
|
|
21
110
|
@click.argument("directory", default=".", type=click.Path(exists=True))
|
|
@@ -80,52 +169,30 @@ def main(
|
|
|
80
169
|
min_score: int | None,
|
|
81
170
|
) -> None:
|
|
82
171
|
"""PyCodeGate — Diagnose your Python project's health."""
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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"pycodegate: 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/output.py
CHANGED
|
@@ -138,8 +138,6 @@ 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
|
-
from pycodegate import __version__
|
|
142
|
-
|
|
143
141
|
console.print(f" [bold]PyCodeGate[/bold] — v{__version__}")
|
|
144
142
|
console.print()
|
|
145
143
|
console.print(f" [dim]Path:[/dim] {p.path}")
|
|
@@ -215,6 +213,47 @@ def output_json(result: ScanResult) -> None:
|
|
|
215
213
|
sys.stdout.write(json.dumps(payload, indent=2) + "\n")
|
|
216
214
|
|
|
217
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
|
+
|
|
218
257
|
def _print_category_groups(
|
|
219
258
|
console: Console,
|
|
220
259
|
diagnostics: list[Diagnostic],
|
|
@@ -235,38 +274,15 @@ def _print_category_groups(
|
|
|
235
274
|
continue
|
|
236
275
|
emoji, name = CATEGORY_DISPLAY[cat]
|
|
237
276
|
earned, maximum = sub_scores.get(cat, (CATEGORY_WEIGHTS[cat], CATEGORY_WEIGHTS[cat]))
|
|
238
|
-
# Recompute max from sub_scores (already normalised to 100-point scale)
|
|
239
277
|
cat_diags = by_category.get(cat, [])
|
|
240
278
|
|
|
241
279
|
if earned == maximum:
|
|
242
|
-
# Perfect score for this category
|
|
243
280
|
console.print(
|
|
244
281
|
f" [bold]{emoji} {name}[/bold] "
|
|
245
282
|
f"[green]({earned}/{maximum})[/green] [green]✓[/green]"
|
|
246
283
|
)
|
|
247
284
|
console.print(" [green]✓ All clear.[/green]")
|
|
248
285
|
else:
|
|
249
|
-
console
|
|
250
|
-
# Group by rule within this category
|
|
251
|
-
by_rule: dict[str, list[Diagnostic]] = defaultdict(list)
|
|
252
|
-
for d in cat_diags:
|
|
253
|
-
by_rule[d.rule].append(d)
|
|
254
|
-
|
|
255
|
-
# Sort: errors first, then by rule name
|
|
256
|
-
sorted_rules = sorted(
|
|
257
|
-
by_rule.keys(),
|
|
258
|
-
key=lambda r: (0 if by_rule[r][0].severity == Severity.ERROR else 1, r),
|
|
259
|
-
)
|
|
260
|
-
for rule in sorted_rules:
|
|
261
|
-
rule_diags = by_rule[rule]
|
|
262
|
-
count = len(rule_diags)
|
|
263
|
-
sev = rule_diags[0].severity
|
|
264
|
-
icon = "[red]✗[/red]" if sev == Severity.ERROR else "[yellow]⚠[/yellow]"
|
|
265
|
-
console.print(f" {icon} {rule} × {count}")
|
|
266
|
-
if verbose:
|
|
267
|
-
for d in rule_diags[:10]:
|
|
268
|
-
console.print(f" [dim]{d.file_path}:{d.line}[/dim]")
|
|
269
|
-
if count > 10:
|
|
270
|
-
console.print(f" [dim]... and {count - 10} more[/dim]")
|
|
286
|
+
_print_category_with_issues(console, emoji, name, earned, maximum, cat_diags, verbose)
|
|
271
287
|
|
|
272
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"]:
|
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/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
|
|
@@ -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
|
|
@@ -34,7 +34,7 @@ Description-Content-Type: text/markdown
|
|
|
34
34
|
|
|
35
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>
|
|
@@ -45,9 +45,13 @@ Description-Content-Type: text/markdown
|
|
|
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
|
-
PyCodeGate
|
|
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
|
|
|
@@ -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=
|
|
4
|
+
pycodegate/cli.py,sha256=O0B7e6JP42dI5a5zeYuT6VbBbKXi4vtBiF1eWWJ3zxQ,5838
|
|
5
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
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=
|
|
32
|
+
pycodegate/utils/fixer.py,sha256=SlKH1mt9YaMxfZk-T_rFOc3FeDKNTnqTzbFdiD9eTXI,918
|
|
33
33
|
pycodegate/utils/precommit.py,sha256=nzcmXc0NPAaxL-ZqDkDyHuuua4SiRXK2HrJZq3HLkMc,1737
|
|
34
|
-
pycodegate-0.1.
|
|
35
|
-
pycodegate-0.1.
|
|
36
|
-
pycodegate-0.1.
|
|
37
|
-
pycodegate-0.1.
|
|
38
|
-
pycodegate-0.1.
|
|
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
|
|
File without changes
|