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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """Python Doctor - Diagnose your Python project's health."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.3"
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
- if ci_workflow:
84
- click.echo(generate_ci_workflow(), nl=False)
85
- return
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 precommit:
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
- if fix:
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
- if badge:
111
- click.echo(generate_badge(result.score.value, result.score.label))
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.print(f" [bold]{emoji} {name}[/bold] [yellow]({earned}/{maximum})[/yellow]")
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
- try:
46
- with open(pyproject, "rb") as f:
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
- # Also check requirements.txt
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
- # CLI tools
90
- cli_deps = {"click", "typer", "fire", "cement", "cliff", "argparse"}
91
- if (deps & cli_deps) or has_scripts:
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"]
@@ -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 check_project(self, project_path: str) -> list[Diagnostic]:
18
- if not shutil.which("pip-audit"):
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
- data = json.loads(result.stdout)
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"]:
@@ -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
- try:
31
- source = Path(filepath).read_text()
32
- tree = ast.parse(source)
33
- except (SyntaxError, OSError, UnicodeDecodeError):
34
- continue
35
-
36
- module_name = self._file_to_module(filepath, str(root))
37
- if not module_name:
38
- continue
39
- file_map[module_name] = filepath
40
-
41
- for node in ast.walk(tree):
42
- if isinstance(node, ast.Import):
43
- for alias in node.names:
44
- graph[module_name].add(alias.name)
45
- elif isinstance(node, ast.ImportFrom):
46
- if node.module and node.level == 0:
47
- graph[module_name].add(node.module)
48
-
49
- # Detect direct A↔B cycles
50
- diags: list[Diagnostic] = []
51
- seen_pairs: set[tuple[str, str]] = set()
84
+ self._collect_imports_for_module(filepath, root, graph, file_map)
52
85
 
53
- for mod_a, imports in graph.items():
54
- for mod_b in imports:
55
- if mod_b in graph and mod_a in graph[mod_b]:
56
- pair = tuple(sorted([mod_a, mod_b]))
57
- if pair not in seen_pairs:
58
- seen_pairs.add(pair)
59
- file_a = file_map.get(mod_a, mod_a)
60
- diags.append(
61
- Diagnostic(
62
- file_path=file_a,
63
- rule="imports/circular",
64
- severity=Severity.ERROR,
65
- category=Category.IMPORTS,
66
- message=f"Circular import between '{mod_a}' and '{mod_b}'",
67
- help="Break the cycle by moving shared code to a third module",
68
- line=1,
69
- cost=3.0,
70
- )
71
- )
72
-
73
- return diags
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."""
@@ -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
- # Test ratio check
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 calculate_score(
19
- diagnostics: list[Diagnostic], max_deduction_overrides: dict | None = None
20
- ) -> Score:
21
- """Calculate a 0-100 health score from diagnostics using category budgets.
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
- # 2. Group diagnostics by resolved category
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
- # 3. Calculate deduction per category with diminishing returns
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 Exception:
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.2
4
- Summary: One command. One score. Your Python quality gate.
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>One command. One score. Your Python quality gate.</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 starts by detecting 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.
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=joZnN6S8V6dL3973Q7VQbG-JZvnKhwxcei9XLDbhGFo,84
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=PD8bblh80hRvj1A-CuYYdiXoRfXdd9ekayl8fUIoL8k,4135
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=MVcn5qcfxjFNIIFP6vieN7BRi9VX8v9iJsqGf81nAlM,9775
9
- pycodegate/profile.py,sha256=XCmFHuyQlYfr1jru0RWXoxouQ3IVh-vgc46AjTEp3xc,3438
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=Dy01pkWyi0p9ypa64aekVSlfZxXKSbdhKP54EgqYu34,2366
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=q8w0VAJeGV9FMNCOVqs0CYpc5Zmqoc-qZ-x4FxMn7sA,3732
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=yp5aSIrcw7AdqgljkJEywEJ_HQ2GcqZVIcDdVSK_R30,3404
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=VC19nrGyiATurP4tvnK7scBI1dZ9bDKyPYKiowzIVLI,9138
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=EDw4dMqLqnzGaQwaGyehrnHBirFajYxRDONnzp9I6HM,890
32
+ pycodegate/utils/fixer.py,sha256=SlKH1mt9YaMxfZk-T_rFOc3FeDKNTnqTzbFdiD9eTXI,918
33
33
  pycodegate/utils/precommit.py,sha256=nzcmXc0NPAaxL-ZqDkDyHuuua4SiRXK2HrJZq3HLkMc,1737
34
- pycodegate-0.1.2.dist-info/METADATA,sha256=worPWZbmaQz_PNIsueyzvqXmy5chnpHcZa2KalzKoU8,11516
35
- pycodegate-0.1.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
36
- pycodegate-0.1.2.dist-info/entry_points.txt,sha256=oSjjP_VAA3O74Y5a-IqIHYsT7-Ljuv97wXiMQ5RdOg0,51
37
- pycodegate-0.1.2.dist-info/licenses/LICENSE,sha256=YQAkET2EceAFEwM7LLUco13527CKFujpBapIIRPshTQ,1068
38
- pycodegate-0.1.2.dist-info/RECORD,,
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,,