python-checkup 0.0.1__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.
Files changed (53) hide show
  1. python_checkup/__init__.py +9 -0
  2. python_checkup/__main__.py +3 -0
  3. python_checkup/analysis_request.py +35 -0
  4. python_checkup/analyzer_catalog.py +100 -0
  5. python_checkup/analyzers/__init__.py +54 -0
  6. python_checkup/analyzers/bandit.py +158 -0
  7. python_checkup/analyzers/basedpyright.py +103 -0
  8. python_checkup/analyzers/cached.py +106 -0
  9. python_checkup/analyzers/dependency_vulns.py +298 -0
  10. python_checkup/analyzers/deptry.py +142 -0
  11. python_checkup/analyzers/detect_secrets.py +101 -0
  12. python_checkup/analyzers/mypy.py +217 -0
  13. python_checkup/analyzers/radon.py +150 -0
  14. python_checkup/analyzers/registry.py +69 -0
  15. python_checkup/analyzers/ruff.py +256 -0
  16. python_checkup/analyzers/typos.py +80 -0
  17. python_checkup/analyzers/vulture.py +151 -0
  18. python_checkup/cache.py +244 -0
  19. python_checkup/cli.py +763 -0
  20. python_checkup/config.py +87 -0
  21. python_checkup/dedup.py +119 -0
  22. python_checkup/dependencies/discovery.py +192 -0
  23. python_checkup/detection.py +298 -0
  24. python_checkup/diff.py +130 -0
  25. python_checkup/discovery.py +180 -0
  26. python_checkup/formatters/__init__.py +0 -0
  27. python_checkup/formatters/badge.py +38 -0
  28. python_checkup/formatters/json_fmt.py +22 -0
  29. python_checkup/formatters/terminal.py +396 -0
  30. python_checkup/mcp/__init__.py +3 -0
  31. python_checkup/mcp/installer.py +119 -0
  32. python_checkup/mcp/server.py +411 -0
  33. python_checkup/models.py +114 -0
  34. python_checkup/plan.py +109 -0
  35. python_checkup/progress.py +95 -0
  36. python_checkup/runner.py +438 -0
  37. python_checkup/scoring/__init__.py +0 -0
  38. python_checkup/scoring/engine.py +397 -0
  39. python_checkup/skills/SKILL.md +416 -0
  40. python_checkup/skills/__init__.py +0 -0
  41. python_checkup/skills/agents.py +98 -0
  42. python_checkup/skills/installer.py +248 -0
  43. python_checkup/skills/rule_db.py +806 -0
  44. python_checkup/web/__init__.py +0 -0
  45. python_checkup/web/server.py +285 -0
  46. python_checkup/web/static/__init__.py +0 -0
  47. python_checkup/web/static/index.html +959 -0
  48. python_checkup/web/template.py +26 -0
  49. python_checkup-0.0.1.dist-info/METADATA +250 -0
  50. python_checkup-0.0.1.dist-info/RECORD +53 -0
  51. python_checkup-0.0.1.dist-info/WHEEL +4 -0
  52. python_checkup-0.0.1.dist-info/entry_points.txt +14 -0
  53. python_checkup-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,396 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from typing import TYPE_CHECKING
5
+
6
+ from rich.console import Console
7
+ from rich.panel import Panel
8
+ from rich.table import Table
9
+ from rich.text import Text
10
+
11
+ from python_checkup.models import Category, HealthReport, Severity
12
+
13
+ if TYPE_CHECKING:
14
+ from python_checkup.models import CategoryScore, Diagnostic
15
+
16
+ # Severity icons
17
+ SEVERITY_ICON: dict[Severity, str] = {
18
+ Severity.ERROR: "[red bold]ERR[/red bold]",
19
+ Severity.WARNING: "[yellow]WRN[/yellow]",
20
+ Severity.INFO: "[blue]INF[/blue]",
21
+ }
22
+
23
+ # Category display names
24
+ CATEGORY_LABEL: dict[Category, str] = {
25
+ Category.QUALITY: "Code Quality",
26
+ Category.TYPE_SAFETY: "Type Safety",
27
+ Category.SECURITY: "Security",
28
+ Category.COMPLEXITY: "Complexity",
29
+ Category.DEAD_CODE: "Dead Code",
30
+ Category.DEPENDENCIES: "Dependencies",
31
+ }
32
+
33
+
34
+ def print_report(
35
+ report: HealthReport,
36
+ *,
37
+ verbose: bool = False,
38
+ show_fix: bool = False,
39
+ diff_mode: bool = False,
40
+ changed_file_count: int | None = None,
41
+ total_file_count: int | None = None,
42
+ ) -> None:
43
+ """Print a complete health report to the terminal.
44
+
45
+ All Rich output goes to stderr so stdout stays clean for piping.
46
+ """
47
+ console = Console(file=sys.stderr)
48
+
49
+ console.print()
50
+
51
+ # 1. Score panel
52
+ _print_score_panel(console, report)
53
+
54
+ # 2. Diff mode notice
55
+ if diff_mode and changed_file_count is not None:
56
+ total = total_file_count or "?"
57
+ console.print(
58
+ f"\n [dim]Analyzed {changed_file_count} changed "
59
+ f"files (out of {total} total)[/dim]"
60
+ )
61
+
62
+ # 3. Category breakdown table
63
+ if report.category_scores:
64
+ console.print()
65
+ _print_category_table(console, report.category_scores)
66
+
67
+ if report.coverage:
68
+ console.print()
69
+ _print_coverage(console, report.coverage)
70
+
71
+ # 4. Top issues
72
+ issues = _sort_issues(report.diagnostics)
73
+ if issues:
74
+ console.print()
75
+ _print_top_issues(
76
+ console,
77
+ issues,
78
+ verbose=verbose,
79
+ show_fix=show_fix,
80
+ )
81
+
82
+ # 5. "What to fix first" -- 3 highest-impact issues
83
+ if not verbose and issues:
84
+ high_impact = _select_high_impact(issues, report.category_scores)
85
+ if high_impact:
86
+ console.print()
87
+ _print_fix_first(console, high_impact)
88
+
89
+ # 6. Footer
90
+ console.print()
91
+ _print_footer(console, report)
92
+ console.print()
93
+
94
+
95
+ def _print_score_panel(console: Console, report: HealthReport) -> None:
96
+ score = report.score
97
+ color = _score_color(score)
98
+
99
+ score_text = Text()
100
+ score_text.append(f" {score}", style=f"bold {color}")
101
+ score_text.append(" / 100 ", style="dim")
102
+ score_text.append(f"{report.label}", style=f"bold {color}")
103
+
104
+ panel = Panel(
105
+ score_text,
106
+ title="[bold white]python-checkup[/bold white]",
107
+ subtitle=_score_bar(score, width=30),
108
+ border_style=color,
109
+ expand=False,
110
+ padding=(1, 3),
111
+ )
112
+ console.print(panel)
113
+
114
+
115
+ def _score_bar(score: float, width: int = 30) -> str:
116
+ filled = int(score / 100 * width)
117
+ empty = width - filled
118
+ color = _score_color(score)
119
+ return f"[{color}]{'━' * filled}[/{color}][dim]{'━' * empty}[/dim]"
120
+
121
+
122
+ def _print_category_table(
123
+ console: Console,
124
+ category_scores: list[CategoryScore],
125
+ ) -> None:
126
+ table = Table(
127
+ show_header=True,
128
+ header_style="bold",
129
+ expand=False,
130
+ title="Category Breakdown",
131
+ title_style="bold",
132
+ )
133
+ table.add_column("Category", style="cyan", min_width=14)
134
+ table.add_column("Score", justify="right", min_width=7)
135
+ table.add_column("Weight", justify="right", style="dim", min_width=7)
136
+ table.add_column("Issues", justify="right", min_width=7)
137
+ table.add_column("Status", style="dim", min_width=8)
138
+ table.add_column("Details", style="dim")
139
+
140
+ for cs in sorted(
141
+ category_scores,
142
+ key=lambda c: (c.category == Category.COMPLEXITY, c.score),
143
+ ):
144
+ color = _score_color(cs.score)
145
+ label = CATEGORY_LABEL.get(cs.category, cs.category.value)
146
+ bar = _inline_bar(cs.score, width=12)
147
+
148
+ table.add_row(
149
+ label,
150
+ f"[{color}]{cs.score}[/{color}]",
151
+ f"{cs.weight}%",
152
+ str(cs.issue_count),
153
+ cs.status,
154
+ f"{bar} {cs.details}"
155
+ + (f" ({cs.coverage_note})" if cs.coverage_note else ""),
156
+ )
157
+
158
+ console.print(table)
159
+
160
+
161
+ def _inline_bar(score: float, width: int = 12) -> str:
162
+ filled = int(score / 100 * width)
163
+ empty = width - filled
164
+ color = _score_color(score)
165
+ return f"[{color}]{'█' * filled}[/{color}][dim]{'░' * empty}[/dim]"
166
+
167
+
168
+ def _sort_issues(
169
+ diagnostics: list[Diagnostic],
170
+ ) -> list[Diagnostic]:
171
+ """Sort issues by severity (errors first), then file and line."""
172
+ severity_order = {
173
+ Severity.ERROR: 0,
174
+ Severity.WARNING: 1,
175
+ Severity.INFO: 2,
176
+ }
177
+ return sorted(
178
+ diagnostics,
179
+ key=lambda d: (
180
+ severity_order.get(d.severity, 9),
181
+ str(d.file_path),
182
+ d.line,
183
+ ),
184
+ )
185
+
186
+
187
+ def _print_top_issues(
188
+ console: Console,
189
+ issues: list[Diagnostic],
190
+ *,
191
+ verbose: bool,
192
+ show_fix: bool,
193
+ ) -> None:
194
+ # Filter to errors and warnings (skip info unless verbose)
195
+ if not verbose:
196
+ issues = [d for d in issues if d.severity != Severity.INFO]
197
+
198
+ max_issues = len(issues) if verbose else 10
199
+ shown = issues[:max_issues]
200
+ remaining = len(issues) - len(shown)
201
+
202
+ table = Table(
203
+ show_header=True,
204
+ header_style="bold",
205
+ expand=True,
206
+ title=f"{'All' if verbose else 'Top'} Issues",
207
+ title_style="bold",
208
+ )
209
+ table.add_column("", width=3)
210
+ table.add_column("Location", style="cyan", no_wrap=True)
211
+ table.add_column("Rule", style="bold", no_wrap=True, min_width=6)
212
+ table.add_column("Message")
213
+
214
+ for d in shown:
215
+ icon = SEVERITY_ICON.get(d.severity, "")
216
+ location = f"{d.file_path}:{d.line}:{d.column}"
217
+
218
+ # Truncate long file paths
219
+ if len(location) > 45:
220
+ parts = str(d.file_path).split("/")
221
+ if len(parts) > 3:
222
+ short_path = f".../{'/'.join(parts[-2:])}"
223
+ else:
224
+ short_path = str(d.file_path)
225
+ location = f"{short_path}:{d.line}:{d.column}"
226
+
227
+ message = d.message
228
+ if show_fix and d.fix:
229
+ message += f"\n[dim green] Fix: {d.fix}[/dim green]"
230
+
231
+ table.add_row(icon, location, d.rule_id, message)
232
+
233
+ console.print(table)
234
+
235
+ if remaining > 0:
236
+ console.print(
237
+ f" [dim]...and {remaining} more issues. "
238
+ f"Run with --verbose to see all.[/dim]"
239
+ )
240
+
241
+
242
+ def _select_high_impact(
243
+ issues: list[Diagnostic],
244
+ category_scores: list[CategoryScore],
245
+ ) -> list[Diagnostic]:
246
+ """Select the 3 highest-impact issues to fix first.
247
+
248
+ "High impact" = errors in the lowest-scoring category.
249
+ """
250
+ if not category_scores:
251
+ return issues[:3]
252
+
253
+ worst_categories = sorted(category_scores, key=lambda cs: cs.score)
254
+
255
+ selected: list[Diagnostic] = []
256
+ for cs in worst_categories:
257
+ if len(selected) >= 3:
258
+ break
259
+ category_errors = [
260
+ d
261
+ for d in issues
262
+ if d.category == cs.category
263
+ and d.severity == Severity.ERROR
264
+ and d not in selected
265
+ ]
266
+ selected.extend(category_errors[: 3 - len(selected)])
267
+
268
+ # Fill with remaining errors if needed
269
+ if len(selected) < 3:
270
+ remaining_errors = [
271
+ d for d in issues if d.severity == Severity.ERROR and d not in selected
272
+ ]
273
+ selected.extend(remaining_errors[: 3 - len(selected)])
274
+
275
+ return selected[:3]
276
+
277
+
278
+ def _print_fix_first(console: Console, issues: list[Diagnostic]) -> None:
279
+ console.print("[bold]What to fix first:[/bold]")
280
+ console.print("[dim]These issues have the highest impact on your score:[/dim]")
281
+ console.print()
282
+
283
+ for i, d in enumerate(issues, 1):
284
+ icon = SEVERITY_ICON.get(d.severity, "")
285
+ category_label = CATEGORY_LABEL.get(d.category, d.category.value)
286
+
287
+ console.print(
288
+ f" {i}. {icon} [bold]{d.rule_id}[/bold] in "
289
+ f"[cyan]{d.file_path}:{d.line}[/cyan]"
290
+ )
291
+ console.print(f" {d.message}")
292
+ if d.fix:
293
+ console.print(f" [green]Fix: {d.fix}[/green]")
294
+ console.print(f" [dim]Category: {category_label}[/dim]")
295
+ console.print()
296
+
297
+
298
+ def _print_footer(console: Console, report: HealthReport) -> None:
299
+ files_str = f"{report.project.total_files} files"
300
+ lines_str = f"{report.project.total_lines:,} lines"
301
+ time_str = _format_duration(report.duration_ms)
302
+ analyzers_str = ", ".join(report.analyzers_used) or "none"
303
+
304
+ console.print(
305
+ f" [dim]Scanned {files_str} ({lines_str}) in "
306
+ f"{time_str} using {analyzers_str}[/dim]"
307
+ )
308
+
309
+ if report.analyzers_skipped:
310
+ skipped = ", ".join(report.analyzers_skipped)
311
+ console.print(f" [dim]Skipped: {skipped}[/dim]")
312
+
313
+ if report.cache_stats and report.cache_stats.get("hits", 0) > 0:
314
+ hits = report.cache_stats["hits"]
315
+ misses = report.cache_stats["misses"]
316
+ hit_rate = report.cache_stats.get("hit_rate_pct", 0)
317
+ console.print(
318
+ f" [dim]Cache: {hits} hits, {misses} misses ({hit_rate}% hit rate)[/dim]"
319
+ )
320
+
321
+ if report.project.framework:
322
+ console.print(f" [dim]Framework detected: {report.project.framework}[/dim]")
323
+
324
+
325
+ # Map optional analyzer names to their pip extras for install hints.
326
+ ANALYZER_EXTRA: dict[str, str] = {
327
+ "dependency-vulns": "vulns",
328
+ "detect-secrets": "secrets",
329
+ "basedpyright": "pyright",
330
+ "typos": "quality-extra",
331
+ }
332
+
333
+
334
+ def _print_coverage(console: Console, coverage: object) -> None:
335
+ confidence = getattr(coverage, "confidence", "unknown")
336
+ profile = getattr(coverage, "profile", "default")
337
+ console.print(f" [dim]Coverage: {confidence} ({profile} profile)[/dim]")
338
+
339
+ partial_reasons = getattr(coverage, "partial_reasons", [])
340
+ if partial_reasons:
341
+ for reason in partial_reasons:
342
+ console.print(f" [dim]- {reason}[/dim]")
343
+
344
+ # Explain weight redistribution when categories are not scored.
345
+ cat_coverage: list[object] = getattr(coverage, "category_coverage", [])
346
+ redistributed_cats: list[str] = []
347
+ for cc in cat_coverage:
348
+ status = getattr(cc, "status", "")
349
+ if status in ("unavailable", "skipped_by_user"):
350
+ cat = getattr(cc, "category", None)
351
+ label = CATEGORY_LABEL.get(cat, str(cat)) if cat is not None else "unknown"
352
+ redistributed_cats.append(f"{label} ({status.replace('_', ' ')})")
353
+ if redistributed_cats:
354
+ console.print(
355
+ " [dim]Weight redistributed from: "
356
+ + ", ".join(redistributed_cats)
357
+ + "[/dim]"
358
+ )
359
+
360
+ # Provenance notes (e.g. "scanned from uv.lock (42 packages)")
361
+ provenance: list[str] = getattr(coverage, "provenance", [])
362
+ for note in provenance:
363
+ console.print(f" [dim]{note}[/dim]")
364
+
365
+ optional: list[str] = getattr(coverage, "analyzers_optional_unavailable", [])
366
+ if optional:
367
+ console.print(
368
+ " [dim]Optional analyzers not installed: " + ", ".join(optional) + "[/dim]"
369
+ )
370
+ extras = {ANALYZER_EXTRA[a] for a in optional if a in ANALYZER_EXTRA}
371
+ if extras:
372
+ extras_str = ",".join(sorted(extras))
373
+ console.print(
374
+ f" [dim]Enable with: "
375
+ f"uvx --from 'python-checkup[{extras_str}]' python-checkup .[/dim]"
376
+ )
377
+
378
+
379
+ def _format_duration(ms: int) -> str:
380
+ if ms < 1000:
381
+ return f"{ms}ms"
382
+ elif ms < 60_000:
383
+ return f"{ms / 1000:.1f}s"
384
+ else:
385
+ minutes = ms // 60_000
386
+ seconds = (ms % 60_000) / 1000
387
+ return f"{minutes}m {seconds:.0f}s"
388
+
389
+
390
+ def _score_color(score: float) -> str:
391
+ if score >= 75:
392
+ return "green"
393
+ elif score >= 50:
394
+ return "yellow"
395
+ else:
396
+ return "red"
@@ -0,0 +1,3 @@
1
+ from python_checkup.mcp.server import start_mcp_server
2
+
3
+ __all__ = ["start_mcp_server"]
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from rich.console import Console
8
+
9
+ console = Console(file=sys.stderr)
10
+
11
+ # MCP config entry to install for each editor
12
+ MCP_CONFIG_ENTRY: dict[str, object] = {
13
+ "command": "uvx",
14
+ "args": ["python-checkup", "--mcp"],
15
+ }
16
+
17
+ # Editor config file locations and their JSON key for MCP servers
18
+ EDITOR_TARGETS: list[dict[str, object]] = [
19
+ {
20
+ "name": "Claude Code (project)",
21
+ "path": Path(".mcp.json"),
22
+ "key": "mcpServers",
23
+ "relative": True,
24
+ },
25
+ {
26
+ "name": "Cursor",
27
+ "path": Path(".cursor/mcp.json"),
28
+ "key": "mcpServers",
29
+ "relative": True,
30
+ },
31
+ {
32
+ "name": "VS Code",
33
+ "path": Path(".vscode/mcp.json"),
34
+ "key": "servers", # VS Code uses "servers" not "mcpServers"
35
+ "relative": True,
36
+ },
37
+ ]
38
+
39
+
40
+ def install_mcp_config(project_root: Path | None = None) -> list[str]:
41
+ """Detect editors and write MCP server config.
42
+
43
+ Only installs into editor directories that already exist (so we
44
+ don't create .cursor/ for someone who doesn't use Cursor), except
45
+ for .mcp.json which is always created for Claude Code.
46
+
47
+ Returns list of paths where config was written.
48
+ """
49
+ root = project_root or Path.cwd()
50
+ installed: list[str] = []
51
+
52
+ for target in EDITOR_TARGETS:
53
+ is_relative = bool(target["relative"])
54
+ target_path = target["path"]
55
+ if not isinstance(target_path, Path):
56
+ continue # pragma: no cover
57
+ target_key = target["key"]
58
+ if not isinstance(target_key, str):
59
+ continue # pragma: no cover
60
+ target_name = target["name"]
61
+ if not isinstance(target_name, str):
62
+ continue # pragma: no cover
63
+
64
+ config_path = root / target_path if is_relative else Path.home() / target_path
65
+
66
+ # Only install if the editor directory already exists
67
+ # (don't create .cursor/ for someone who doesn't use Cursor)
68
+ if is_relative:
69
+ parent = config_path.parent
70
+ if parent.name in (".cursor", ".vscode") and not parent.exists():
71
+ continue
72
+
73
+ try:
74
+ _write_config(config_path, target_key)
75
+ installed.append(str(config_path))
76
+ console.print(
77
+ f" [green]Installed[/green] -> {config_path} ({target_name})"
78
+ )
79
+ except Exception as e:
80
+ console.print(f" [red]Failed[/red] -> {config_path}: {e}")
81
+
82
+ # Always install .mcp.json (Claude Code project-level)
83
+ claude_path = root / ".mcp.json"
84
+ if str(claude_path) not in installed:
85
+ try:
86
+ _write_config(claude_path, "mcpServers")
87
+ installed.append(str(claude_path))
88
+ console.print(f" [green]Installed[/green] -> {claude_path}")
89
+ except Exception as e:
90
+ console.print(f" [red]Failed[/red] -> {claude_path}: {e}")
91
+
92
+ return installed
93
+
94
+
95
+ def _write_config(path: Path, server_key: str) -> None:
96
+ """Write or merge MCP config into a JSON file.
97
+
98
+ Preserves existing server entries -- only adds/updates the
99
+ ``python-checkup`` entry under the appropriate key.
100
+ """
101
+ existing: dict[str, object] = {}
102
+ if path.exists():
103
+ try:
104
+ raw = path.read_text()
105
+ loaded = json.loads(raw)
106
+ if isinstance(loaded, dict):
107
+ existing = loaded
108
+ except (json.JSONDecodeError, OSError):
109
+ existing = {}
110
+
111
+ # Merge -- don't overwrite other servers
112
+ servers = existing.get(server_key)
113
+ if not isinstance(servers, dict):
114
+ servers = {}
115
+ servers["python-checkup"] = MCP_CONFIG_ENTRY
116
+ existing[server_key] = servers
117
+
118
+ path.parent.mkdir(parents=True, exist_ok=True)
119
+ path.write_text(json.dumps(existing, indent=2) + "\n")