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
File without changes
@@ -0,0 +1,285 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import dataclasses
5
+ import json
6
+ import secrets
7
+ import threading
8
+ from http.server import BaseHTTPRequestHandler, HTTPServer
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any
11
+ from urllib.parse import parse_qs, urlparse
12
+
13
+ from rich.console import Console
14
+
15
+ if TYPE_CHECKING:
16
+ from python_checkup.config import CheckupConfig
17
+ from python_checkup.models import HealthReport
18
+ from python_checkup.plan import ScanPlan
19
+
20
+ err_console = Console(stderr=True)
21
+
22
+
23
+ @dataclasses.dataclass
24
+ class RunContext:
25
+ """Everything needed to re-run analysis from the web UI."""
26
+
27
+ project_root: Path
28
+ config: CheckupConfig
29
+ files: list[Path]
30
+ plan: ScanPlan
31
+ no_cache: bool = False
32
+ diff_base: str | None = None
33
+
34
+
35
+ def _serialize_report(report: HealthReport) -> str:
36
+ """Convert a HealthReport to JSON, handling Path and Enum values."""
37
+
38
+ def _default(obj: object) -> object:
39
+ from enum import Enum
40
+ from pathlib import Path as _Path
41
+
42
+ if isinstance(obj, _Path):
43
+ return str(obj)
44
+ if isinstance(obj, Enum):
45
+ return obj.value
46
+ raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
47
+
48
+ return json.dumps(dataclasses.asdict(report), default=_default)
49
+
50
+
51
+ class ReportServer:
52
+ """Local HTTP server that serves the interactive HTML report."""
53
+
54
+ def __init__(
55
+ self,
56
+ report: HealthReport | None = None,
57
+ port: int = 8765,
58
+ run_context: RunContext | None = None,
59
+ ):
60
+ self.report = report
61
+ self.port = port
62
+ self.token = secrets.token_urlsafe(32)
63
+ self.run_context = run_context
64
+ self._server: HTTPServer | None = None
65
+ self._lock = threading.Lock()
66
+
67
+ @property
68
+ def url(self) -> str:
69
+ return f"http://127.0.0.1:{self.port}?token={self.token}"
70
+
71
+ def start(self, open_browser: bool = True) -> None:
72
+ """Start the server and optionally open a browser."""
73
+ handler = _make_handler(self)
74
+ self._server = HTTPServer(("127.0.0.1", self.port), handler)
75
+
76
+ if open_browser:
77
+ import webbrowser
78
+
79
+ threading.Timer(0.5, webbrowser.open, args=[self.url]).start()
80
+
81
+ err_console.print(
82
+ f"\n[bold green]Report server running at:[/bold green] {self.url}"
83
+ )
84
+ err_console.print("[dim]Press Ctrl+C to stop.[/dim]\n")
85
+
86
+ try:
87
+ self._server.serve_forever()
88
+ except KeyboardInterrupt:
89
+ pass
90
+ finally:
91
+ self._server.server_close()
92
+ err_console.print("\n[dim]Server stopped.[/dim]")
93
+
94
+ def rerun_analysis(self, analyzer_names: list[str]) -> HealthReport:
95
+ """Re-run analysis with the given analyzers and return a new report."""
96
+ if self.run_context is None:
97
+ msg = "No run context available for re-run"
98
+ raise RuntimeError(msg)
99
+
100
+ from python_checkup.analyzer_catalog import ANALYZER_CATALOG
101
+ from python_checkup.models import Category
102
+ from python_checkup.plan import build_scan_plan
103
+ from python_checkup.runner import run_analysis
104
+
105
+ ctx = self.run_context
106
+
107
+ requested = set(analyzer_names)
108
+ skip_analyzers = set(ANALYZER_CATALOG.keys()) - requested
109
+
110
+ # Derive categories from selected analyzers
111
+ selected_categories: set[Category] = set()
112
+ for name in requested:
113
+ if name in ANALYZER_CATALOG:
114
+ selected_categories.update(ANALYZER_CATALOG[name].categories)
115
+
116
+ plan = build_scan_plan(
117
+ profile=ctx.plan.profile,
118
+ only_categories=frozenset(selected_categories),
119
+ skip_categories=frozenset(),
120
+ include_optional=True,
121
+ apply_fixes=ctx.plan.apply_fixes,
122
+ show_fix_suggestions=ctx.plan.show_fix_suggestions,
123
+ diff_mode=ctx.plan.diff_mode,
124
+ type_backend=ctx.plan.type_backend,
125
+ )
126
+
127
+ report = asyncio.run(
128
+ run_analysis(
129
+ project_root=ctx.project_root,
130
+ config=ctx.config,
131
+ files=ctx.files,
132
+ skip_analyzers=skip_analyzers,
133
+ quiet=True,
134
+ no_cache=ctx.no_cache,
135
+ plan=plan,
136
+ diff_base=ctx.diff_base,
137
+ )
138
+ )
139
+
140
+ with self._lock:
141
+ self.report = report
142
+
143
+ return report
144
+
145
+
146
+ def _make_handler(server: ReportServer) -> type[BaseHTTPRequestHandler]:
147
+ """Create a request handler class bound to the server instance."""
148
+
149
+ # Build analyzer catalog info for the UI
150
+ from python_checkup.analyzer_catalog import ANALYZER_CATALOG
151
+
152
+ catalog_info = {
153
+ name: {
154
+ "name": name,
155
+ "categories": [c.value for c in info.categories],
156
+ "optional": info.optional,
157
+ }
158
+ for name, info in ANALYZER_CATALOG.items()
159
+ }
160
+
161
+ class Handler(BaseHTTPRequestHandler):
162
+ def log_message(self, format: str, *args: object) -> None: # noqa: A002
163
+ pass
164
+
165
+ def _check_token(self) -> bool:
166
+ token = self.headers.get("X-Session-Token")
167
+ if token != server.token:
168
+ self._json_error(403, "Invalid or missing session token")
169
+ return False
170
+ return True
171
+
172
+ def _json_response(self, data: dict[str, Any], status: int = 200) -> None:
173
+ body = json.dumps(data, default=str).encode("utf-8")
174
+ self.send_response(status)
175
+ self.send_header("Content-Type", "application/json")
176
+ self.send_header("Content-Length", str(len(body)))
177
+ self.end_headers()
178
+ self.wfile.write(body)
179
+
180
+ def _json_error(self, status: int, message: str) -> None:
181
+ self._json_response({"error": message}, status)
182
+
183
+ def _read_json_body(self) -> dict[str, Any] | None:
184
+ length = int(self.headers.get("Content-Length", 0))
185
+ if length == 0:
186
+ self._json_error(400, "Empty request body")
187
+ return None
188
+ try:
189
+ result: dict[str, Any] = json.loads(self.rfile.read(length))
190
+ return result
191
+ except json.JSONDecodeError:
192
+ self._json_error(400, "Invalid JSON")
193
+ return None
194
+
195
+ def do_GET(self) -> None: # noqa: N802
196
+ parsed = urlparse(self.path)
197
+
198
+ if parsed.path in ("/", ""):
199
+ query = parse_qs(parsed.query)
200
+ url_token = query.get("token", [None])[0]
201
+ if url_token != server.token:
202
+ self.send_response(403)
203
+ self.send_header("Content-Type", "text/plain")
204
+ self.end_headers()
205
+ self.wfile.write(b"Invalid session token")
206
+ return
207
+
208
+ from python_checkup.web.template import render_report_html
209
+
210
+ report_json = (
211
+ _serialize_report(server.report)
212
+ if server.report is not None
213
+ else "null"
214
+ )
215
+ can_rerun = server.run_context is not None
216
+ html = render_report_html(
217
+ report_json,
218
+ server.token,
219
+ catalog_json=json.dumps(catalog_info),
220
+ can_rerun=can_rerun,
221
+ )
222
+ body = html.encode("utf-8")
223
+ self.send_response(200)
224
+ self.send_header("Content-Type", "text/html; charset=utf-8")
225
+ self.send_header("Content-Length", str(len(body)))
226
+ self.end_headers()
227
+ self.wfile.write(body)
228
+ else:
229
+ self.send_response(404)
230
+ self.send_header("Content-Type", "text/plain")
231
+ self.end_headers()
232
+ self.wfile.write(b"Not found")
233
+
234
+ def do_POST(self) -> None: # noqa: N802
235
+ parsed = urlparse(self.path)
236
+
237
+ if not self._check_token():
238
+ return
239
+
240
+ if parsed.path == "/api/rerun":
241
+ if server.run_context is None:
242
+ self._json_error(400, "Re-run not available")
243
+ return
244
+
245
+ body = self._read_json_body()
246
+ if body is None:
247
+ return
248
+
249
+ analyzers = body.get("analyzers", [])
250
+ if not analyzers or not isinstance(analyzers, list):
251
+ self._json_error(400, "Provide a non-empty 'analyzers' list")
252
+ return
253
+
254
+ known = set(catalog_info.keys())
255
+ unknown = set(analyzers) - known
256
+ if unknown:
257
+ self._json_error(
258
+ 400,
259
+ f"Unknown analyzer(s): {', '.join(sorted(unknown))}",
260
+ )
261
+ return
262
+
263
+ try:
264
+ err_console.print(
265
+ f"[bold]Re-running with:[/bold] {', '.join(analyzers)}"
266
+ )
267
+ report = server.rerun_analysis(analyzers)
268
+ report_json = _serialize_report(report)
269
+ self._json_response({"report": json.loads(report_json)})
270
+ except Exception as e:
271
+ self._json_error(500, f"Analysis failed: {e}")
272
+ else:
273
+ self._json_error(404, "Not found")
274
+
275
+ return Handler
276
+
277
+
278
+ def serve_report(
279
+ report: HealthReport | None = None,
280
+ port: int = 8765,
281
+ run_context: RunContext | None = None,
282
+ ) -> None:
283
+ """Serve the interactive HTML report and open it in the browser."""
284
+ server = ReportServer(report, port=port, run_context=run_context)
285
+ server.start(open_browser=True)
File without changes