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.
- python_checkup/__init__.py +9 -0
- python_checkup/__main__.py +3 -0
- python_checkup/analysis_request.py +35 -0
- python_checkup/analyzer_catalog.py +100 -0
- python_checkup/analyzers/__init__.py +54 -0
- python_checkup/analyzers/bandit.py +158 -0
- python_checkup/analyzers/basedpyright.py +103 -0
- python_checkup/analyzers/cached.py +106 -0
- python_checkup/analyzers/dependency_vulns.py +298 -0
- python_checkup/analyzers/deptry.py +142 -0
- python_checkup/analyzers/detect_secrets.py +101 -0
- python_checkup/analyzers/mypy.py +217 -0
- python_checkup/analyzers/radon.py +150 -0
- python_checkup/analyzers/registry.py +69 -0
- python_checkup/analyzers/ruff.py +256 -0
- python_checkup/analyzers/typos.py +80 -0
- python_checkup/analyzers/vulture.py +151 -0
- python_checkup/cache.py +244 -0
- python_checkup/cli.py +763 -0
- python_checkup/config.py +87 -0
- python_checkup/dedup.py +119 -0
- python_checkup/dependencies/discovery.py +192 -0
- python_checkup/detection.py +298 -0
- python_checkup/diff.py +130 -0
- python_checkup/discovery.py +180 -0
- python_checkup/formatters/__init__.py +0 -0
- python_checkup/formatters/badge.py +38 -0
- python_checkup/formatters/json_fmt.py +22 -0
- python_checkup/formatters/terminal.py +396 -0
- python_checkup/mcp/__init__.py +3 -0
- python_checkup/mcp/installer.py +119 -0
- python_checkup/mcp/server.py +411 -0
- python_checkup/models.py +114 -0
- python_checkup/plan.py +109 -0
- python_checkup/progress.py +95 -0
- python_checkup/runner.py +438 -0
- python_checkup/scoring/__init__.py +0 -0
- python_checkup/scoring/engine.py +397 -0
- python_checkup/skills/SKILL.md +416 -0
- python_checkup/skills/__init__.py +0 -0
- python_checkup/skills/agents.py +98 -0
- python_checkup/skills/installer.py +248 -0
- python_checkup/skills/rule_db.py +806 -0
- python_checkup/web/__init__.py +0 -0
- python_checkup/web/server.py +285 -0
- python_checkup/web/static/__init__.py +0 -0
- python_checkup/web/static/index.html +959 -0
- python_checkup/web/template.py +26 -0
- python_checkup-0.0.1.dist-info/METADATA +250 -0
- python_checkup-0.0.1.dist-info/RECORD +53 -0
- python_checkup-0.0.1.dist-info/WHEEL +4 -0
- python_checkup-0.0.1.dist-info/entry_points.txt +14 -0
- 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
|