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,95 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from collections.abc import Generator
5
+ from contextlib import contextmanager
6
+
7
+ from rich.console import Console
8
+ from rich.progress import (
9
+ BarColumn,
10
+ Progress,
11
+ SpinnerColumn,
12
+ TaskID,
13
+ TextColumn,
14
+ TimeElapsedColumn,
15
+ )
16
+
17
+
18
+ @contextmanager
19
+ def analysis_progress(
20
+ analyzer_names: list[str],
21
+ *,
22
+ quiet: bool = False,
23
+ ) -> Generator[AnalysisTracker, None, None]:
24
+ """Context manager that shows a progress display during analysis.
25
+
26
+ Args:
27
+ analyzer_names: Names of analyzers that will run.
28
+ quiet: If True, suppress all output (for --score and --json).
29
+ """
30
+ if quiet:
31
+ yield AnalysisTracker(progress=None, tasks={})
32
+ return
33
+
34
+ console = Console(file=sys.stderr)
35
+ progress = Progress(
36
+ SpinnerColumn(),
37
+ TextColumn("[bold blue]{task.description}[/bold blue]"),
38
+ BarColumn(bar_width=20),
39
+ TextColumn("[dim]{task.fields[status]}[/dim]"),
40
+ TimeElapsedColumn(),
41
+ console=console,
42
+ transient=True,
43
+ )
44
+
45
+ with progress:
46
+ tasks: dict[str, TaskID] = {}
47
+ for name in analyzer_names:
48
+ task_id = progress.add_task(f" {name}", total=1, status="waiting...")
49
+ tasks[name] = task_id
50
+
51
+ yield AnalysisTracker(progress=progress, tasks=tasks)
52
+
53
+
54
+ class AnalysisTracker:
55
+ """Tracks progress of individual analyzers."""
56
+
57
+ def __init__(
58
+ self,
59
+ progress: Progress | None,
60
+ tasks: dict[str, TaskID],
61
+ ) -> None:
62
+ self._progress = progress
63
+ self._tasks = tasks
64
+
65
+ def start(self, analyzer_name: str) -> None:
66
+ if self._progress and analyzer_name in self._tasks:
67
+ self._progress.update(
68
+ self._tasks[analyzer_name],
69
+ status="running...",
70
+ )
71
+
72
+ def complete(self, analyzer_name: str, issue_count: int) -> None:
73
+ if self._progress and analyzer_name in self._tasks:
74
+ status = f"done ({issue_count} issues)" if issue_count else "done"
75
+ self._progress.update(
76
+ self._tasks[analyzer_name],
77
+ completed=1,
78
+ status=status,
79
+ )
80
+
81
+ def fail(self, analyzer_name: str, error: str) -> None:
82
+ if self._progress and analyzer_name in self._tasks:
83
+ self._progress.update(
84
+ self._tasks[analyzer_name],
85
+ completed=1,
86
+ status=f"[red]failed: {error}[/red]",
87
+ )
88
+
89
+ def skip(self, analyzer_name: str) -> None:
90
+ if self._progress and analyzer_name in self._tasks:
91
+ self._progress.update(
92
+ self._tasks[analyzer_name],
93
+ completed=1,
94
+ status="[dim]skipped[/dim]",
95
+ )
@@ -0,0 +1,438 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from python_checkup.analysis_request import AnalysisRequest
11
+ from python_checkup.analyzer_catalog import ANALYZER_CATALOG, get_analyzer_info
12
+ from python_checkup.analyzers.cached import CachedAnalyzer
13
+ from python_checkup.analyzers.registry import discover_analyzers
14
+ from python_checkup.cache import AnalysisCache
15
+ from python_checkup.config import CheckupConfig
16
+ from python_checkup.dedup import deduplicate
17
+ from python_checkup.detection import detect_framework
18
+ from python_checkup.discovery import discover_python_files
19
+ from python_checkup.models import (
20
+ Category,
21
+ CategoryCoverage,
22
+ CoverageInfo,
23
+ Diagnostic,
24
+ HealthReport,
25
+ ProjectInfo,
26
+ )
27
+ from python_checkup.plan import PROFILE_DEFAULT, ScanPlan, build_scan_plan
28
+ from python_checkup.progress import analysis_progress
29
+ from python_checkup.scoring.engine import compute_health_report
30
+
31
+ if TYPE_CHECKING:
32
+ from python_checkup.analyzers import Analyzer
33
+
34
+ logger = logging.getLogger("python_checkup")
35
+
36
+ # Analyzers that benefit from per-file caching.
37
+ # mypy is excluded because it needs project-wide context
38
+ # (it relies on its own .mypy_cache/ instead).
39
+ CACHEABLE_ANALYZERS = {"ruff", "bandit", "radon", "vulture"}
40
+
41
+
42
+ def _detect_project_framework(
43
+ project_root: Path,
44
+ ) -> tuple[str | None, str | None]:
45
+ """Detect the project framework and return (name, label)."""
46
+ framework_info = detect_framework(project_root)
47
+ if not framework_info:
48
+ return None, None
49
+ name = framework_info.name
50
+ label = (
51
+ f"{framework_info.name}-{framework_info.version}"
52
+ if framework_info.version
53
+ else framework_info.name
54
+ )
55
+ return name, label
56
+
57
+
58
+ def _filter_analyzers(
59
+ all_analyzers: list[Analyzer],
60
+ skip_analyzers: set[str],
61
+ plan: ScanPlan,
62
+ cache: AnalysisCache,
63
+ ) -> tuple[list[Analyzer | CachedAnalyzer], list[str], list[str]]:
64
+ """Filter analyzers based on skip list, plan, and cache settings.
65
+
66
+ Returns (active, skipped_names, optional_unavailable).
67
+ """
68
+ active: list[Analyzer | CachedAnalyzer] = []
69
+ skipped_names = [a.name for a in all_analyzers if a.name in skip_analyzers]
70
+ optional_unavailable: list[str] = []
71
+
72
+ for a in all_analyzers:
73
+ if a.name in skip_analyzers:
74
+ continue
75
+ if _should_skip_analyzer(a.name, plan, skipped_names, optional_unavailable):
76
+ continue
77
+ if a.name in CACHEABLE_ANALYZERS and cache.enabled:
78
+ active.append(CachedAnalyzer(a, cache))
79
+ else:
80
+ active.append(a)
81
+
82
+ return active, skipped_names, optional_unavailable
83
+
84
+
85
+ def _should_skip_analyzer(
86
+ name: str,
87
+ plan: ScanPlan,
88
+ skipped_names: list[str],
89
+ optional_unavailable: list[str],
90
+ ) -> bool:
91
+ """Check if an analyzer should be skipped based on catalog info and plan."""
92
+ info = get_analyzer_info(name)
93
+ if info is None:
94
+ return False
95
+ if not info.categories.intersection(plan.categories):
96
+ skipped_names.append(name)
97
+ return True
98
+ if plan.profile not in info.profiles:
99
+ skipped_names.append(name)
100
+ return True
101
+ if info.optional and not plan.include_optional:
102
+ optional_unavailable.append(name)
103
+ skipped_names.append(name)
104
+ return True
105
+ return False
106
+
107
+
108
+ def _collect_results(
109
+ results: list[tuple[str, list[Diagnostic] | None, dict[str, object]]],
110
+ ) -> tuple[list[Diagnostic], list[str], list[float], list[str]]:
111
+ """Collect diagnostics, used analyzers, MI scores, and failed names from results."""
112
+ all_diagnostics: list[Diagnostic] = []
113
+ analyzers_used: list[str] = []
114
+ mi_scores: list[float] = []
115
+ failed_names: list[str] = []
116
+
117
+ for name, result, cfg in results:
118
+ if result is not None:
119
+ all_diagnostics.extend(result)
120
+ analyzers_used.append(name)
121
+ if name == "radon" and "_radon_mi_scores" in cfg:
122
+ raw_mi = cfg["_radon_mi_scores"]
123
+ if isinstance(raw_mi, list):
124
+ mi_scores = raw_mi
125
+ else:
126
+ failed_names.append(name)
127
+
128
+ return all_diagnostics, analyzers_used, mi_scores, failed_names
129
+
130
+
131
+ def _handle_cache_stats(cache: AnalysisCache) -> dict[str, int] | None:
132
+ """Collect cache statistics and run cleanup if cache is enabled."""
133
+ if not cache.enabled:
134
+ return None
135
+ cache_stats = cache.get_stats()
136
+ logger.info(
137
+ "Cache: %d hits, %d misses (%d%% hit rate)",
138
+ cache_stats["hits"],
139
+ cache_stats["misses"],
140
+ cache_stats["hit_rate_pct"],
141
+ )
142
+ cache.cleanup()
143
+ return cache_stats
144
+
145
+
146
+ async def run_analysis(
147
+ project_root: Path,
148
+ config: CheckupConfig,
149
+ files: list[Path] | None = None,
150
+ skip_analyzers: set[str] | None = None,
151
+ quiet: bool = False,
152
+ no_cache: bool = False,
153
+ plan: ScanPlan | None = None,
154
+ diff_base: str | None = None,
155
+ ) -> HealthReport:
156
+ """Run all available analyzers with progress feedback.
157
+
158
+ Args:
159
+ project_root: Root directory to analyze.
160
+ config: Resolved configuration.
161
+ files: Specific files to analyze. If None, discovers all.
162
+ skip_analyzers: Set of analyzer names to skip.
163
+ quiet: Suppress progress output (for --score and --json).
164
+ no_cache: If True, skip the cache entirely (fresh run).
165
+ """
166
+ start = time.monotonic()
167
+ skip_analyzers = skip_analyzers or set()
168
+ plan = plan or build_scan_plan(profile=PROFILE_DEFAULT)
169
+
170
+ if files is None:
171
+ files = discover_python_files(project_root, config.ignore_files)
172
+
173
+ if not files:
174
+ return _empty_report(start)
175
+
176
+ cache = AnalysisCache(project_root, enabled=not no_cache)
177
+ framework_name, framework_label = _detect_project_framework(project_root)
178
+
179
+ all_analyzers = await discover_analyzers()
180
+ active, skipped_names, optional_unavailable = _filter_analyzers(
181
+ all_analyzers, skip_analyzers, plan, cache
182
+ )
183
+
184
+ if not active:
185
+ logger.warning("No analyzers available after filtering")
186
+ return _empty_report(start)
187
+
188
+ # Run with progress tracking
189
+ with analysis_progress([a.name for a in active], quiet=quiet) as tracker:
190
+
191
+ async def run_one(
192
+ analyzer: Analyzer | CachedAnalyzer,
193
+ request: AnalysisRequest,
194
+ ) -> tuple[str, list[Diagnostic] | None, dict[str, object]]:
195
+ tracker.start(analyzer.name)
196
+ try:
197
+ result = await analyzer.analyze(request)
198
+ tracker.complete(analyzer.name, len(result))
199
+ return analyzer.name, result, request.metadata
200
+ except asyncio.TimeoutError:
201
+ tracker.fail(analyzer.name, "timed out")
202
+ logger.warning("%s timed out", analyzer.name)
203
+ return analyzer.name, None, request.metadata
204
+ except Exception as e:
205
+ error_str = str(e)[:50]
206
+ tracker.fail(analyzer.name, error_str)
207
+ logger.warning(
208
+ "%s failed: %s: %s",
209
+ analyzer.name,
210
+ type(e).__name__,
211
+ e,
212
+ )
213
+ return analyzer.name, None, request.metadata
214
+
215
+ tasks = []
216
+ for a in active:
217
+ request = AnalysisRequest(
218
+ project_root=project_root,
219
+ files=files,
220
+ config=config,
221
+ categories=set(plan.categories),
222
+ profile=plan.profile,
223
+ framework=framework_name,
224
+ diff_base=diff_base,
225
+ quiet=quiet,
226
+ no_cache=no_cache,
227
+ metadata={},
228
+ )
229
+ tasks.append(run_one(a, request))
230
+ results = await asyncio.gather(*tasks)
231
+
232
+ all_diagnostics, analyzers_used, mi_scores, failed_names = _collect_results(results)
233
+ skipped_names.extend(failed_names)
234
+
235
+ cache_stats = _handle_cache_stats(cache)
236
+
237
+ # Deduplicate (Ruff/Bandit overlap)
238
+ all_diagnostics = deduplicate(all_diagnostics)
239
+
240
+ if config.ignore_rules:
241
+ all_diagnostics = [
242
+ d for d in all_diagnostics if d.rule_id not in config.ignore_rules
243
+ ]
244
+
245
+ total_lines = sum(_count_lines(f) for f in files)
246
+ project = ProjectInfo(
247
+ python_version=_detect_python_version(),
248
+ framework=framework_label,
249
+ total_files=len(files),
250
+ total_lines=total_lines,
251
+ )
252
+
253
+ duration_ms = int((time.monotonic() - start) * 1000)
254
+
255
+ coverage = _build_coverage(
256
+ plan=plan,
257
+ analyzers_used=analyzers_used,
258
+ analyzers_skipped=skipped_names,
259
+ optional_unavailable=optional_unavailable,
260
+ request_metadata={
261
+ key: value
262
+ for _name, _result, metadata in results
263
+ for key, value in metadata.items()
264
+ },
265
+ )
266
+
267
+ return compute_health_report(
268
+ diagnostics=all_diagnostics,
269
+ project=project,
270
+ config=config,
271
+ duration_ms=duration_ms,
272
+ analyzers_used=analyzers_used,
273
+ analyzers_skipped=skipped_names,
274
+ mi_scores=mi_scores if mi_scores else None,
275
+ cache_stats=cache_stats,
276
+ coverage=coverage,
277
+ )
278
+
279
+
280
+ def _count_lines(path: Path) -> int:
281
+ """Count lines in a file, handling encoding errors."""
282
+ try:
283
+ return len(path.read_text(errors="ignore").splitlines())
284
+ except OSError:
285
+ return 0
286
+
287
+
288
+ def _detect_python_version() -> str:
289
+ return f"{sys.version_info.major}.{sys.version_info.minor}"
290
+
291
+
292
+ def _empty_report(start: float) -> HealthReport:
293
+ return HealthReport(
294
+ score=100,
295
+ label="Healthy",
296
+ category_scores=[],
297
+ diagnostics=[],
298
+ project=ProjectInfo(
299
+ python_version=_detect_python_version(),
300
+ framework=None,
301
+ total_files=0,
302
+ total_lines=0,
303
+ ),
304
+ duration_ms=int((time.monotonic() - start) * 1000),
305
+ coverage=CoverageInfo(profile=PROFILE_DEFAULT, confidence="limited"),
306
+ )
307
+
308
+
309
+ def _categorize_coverage(
310
+ plan: ScanPlan,
311
+ used_set: set[str],
312
+ optional_unavailable: list[str],
313
+ ) -> tuple[set[Category], list[CategoryCoverage], list[str]]:
314
+ """Classify each planned category as scored, partial, or unavailable.
315
+
316
+ Returns (scored_categories, category_coverage, partial_reasons).
317
+ """
318
+ scored_categories: set[Category] = set()
319
+ category_coverage: list[CategoryCoverage] = []
320
+ partial_reasons: list[str] = []
321
+
322
+ for category in sorted(plan.categories, key=lambda c: c.value):
323
+ category_analyzers = [
324
+ info.name
325
+ for info in ANALYZER_CATALOG.values()
326
+ if category in info.categories
327
+ and plan.profile in info.profiles
328
+ and (plan.include_optional or not info.optional)
329
+ ]
330
+ used_for_category = [name for name in category_analyzers if name in used_set]
331
+ if used_for_category:
332
+ status, reason = _category_status(category, optional_unavailable)
333
+ category_coverage.append(
334
+ CategoryCoverage(
335
+ category=category,
336
+ status=status,
337
+ analyzers=used_for_category,
338
+ reason=reason,
339
+ )
340
+ )
341
+ scored_categories.add(category)
342
+ if reason:
343
+ partial_reasons.append(f"{category.value}: {reason}")
344
+ else:
345
+ category_coverage.append(
346
+ CategoryCoverage(
347
+ category=category,
348
+ status="unavailable",
349
+ analyzers=[],
350
+ reason="no analyzer ran for this category",
351
+ )
352
+ )
353
+
354
+ # Add entries for categories explicitly skipped by the user
355
+ for category in sorted(plan.skipped_categories, key=lambda c: c.value):
356
+ category_coverage.append(
357
+ CategoryCoverage(
358
+ category=category,
359
+ status="skipped_by_user",
360
+ analyzers=[],
361
+ reason="excluded via --skip",
362
+ )
363
+ )
364
+
365
+ return scored_categories, category_coverage, partial_reasons
366
+
367
+
368
+ def _category_status(
369
+ category: Category,
370
+ optional_unavailable: list[str],
371
+ ) -> tuple[str, str]:
372
+ """Return (status, reason) for a category that has at least one active analyzer."""
373
+ if category == Category.SECURITY and "detect-secrets" in optional_unavailable:
374
+ return "partial", "optional secret scan not installed"
375
+ if category == Category.DEPENDENCIES and "dependency-vulns" in optional_unavailable:
376
+ return "partial", "optional dependency vulnerability scan not installed"
377
+ return "scored", ""
378
+
379
+
380
+ def _build_provenance(request_metadata: dict[str, object]) -> list[str]:
381
+ """Build provenance notes from request metadata."""
382
+ provenance: list[str] = []
383
+ dep_source = request_metadata.get("dependency_vulns_source")
384
+ dep_count = request_metadata.get("dependency_vulns_package_count")
385
+ dep_note = request_metadata.get("dependency_vulns_note")
386
+ if isinstance(dep_source, str) and isinstance(dep_count, int):
387
+ provenance.append(
388
+ f"Dependency vulnerabilities: scanned from {dep_source} "
389
+ f"({dep_count} packages)"
390
+ )
391
+ elif isinstance(dep_note, str):
392
+ provenance.append(f"Dependency vulnerabilities: skipped ({dep_note})")
393
+ return provenance
394
+
395
+
396
+ def _determine_confidence(
397
+ partial_reasons: list[str],
398
+ scored_categories: set[Category],
399
+ plan: ScanPlan,
400
+ ) -> str:
401
+ """Determine the confidence level based on coverage gaps."""
402
+ if len(scored_categories) < len(plan.categories):
403
+ return "limited"
404
+ if partial_reasons:
405
+ return "partial"
406
+ return "full"
407
+
408
+
409
+ def _build_coverage(
410
+ *,
411
+ plan: ScanPlan,
412
+ analyzers_used: list[str],
413
+ analyzers_skipped: list[str],
414
+ optional_unavailable: list[str],
415
+ request_metadata: dict[str, object] | None = None,
416
+ ) -> CoverageInfo:
417
+ used_set = set(analyzers_used)
418
+ request_metadata = request_metadata or {}
419
+
420
+ scored_categories, category_coverage, partial_reasons = _categorize_coverage(
421
+ plan, used_set, optional_unavailable
422
+ )
423
+
424
+ confidence = _determine_confidence(partial_reasons, scored_categories, plan)
425
+ provenance = _build_provenance(request_metadata)
426
+
427
+ return CoverageInfo(
428
+ profile=plan.profile,
429
+ confidence=confidence,
430
+ requested_categories=sorted(plan.categories, key=lambda c: c.value),
431
+ scored_categories=sorted(scored_categories, key=lambda c: c.value),
432
+ category_coverage=category_coverage,
433
+ analyzers_used=analyzers_used,
434
+ analyzers_missing=[],
435
+ analyzers_optional_unavailable=optional_unavailable,
436
+ partial_reasons=partial_reasons,
437
+ provenance=provenance,
438
+ )
File without changes