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,298 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import logging
6
+ import time
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+
11
+ from python_checkup.analysis_request import AnalysisRequest
12
+ from python_checkup.dependencies.discovery import (
13
+ DependencySource,
14
+ LockedPackage,
15
+ discover_dependency_source,
16
+ )
17
+ from python_checkup.models import Category, Diagnostic, Severity
18
+
19
+ logger = logging.getLogger("python_checkup")
20
+
21
+ OSV_QUERY_BATCH_URL = "https://api.osv.dev/v1/querybatch"
22
+
23
+ # Advisory cache lives in a separate namespace from the per-file cache.
24
+ ADVISORY_CACHE_DIR = "v2/advisories"
25
+ # Cache entries are valid for 24 hours.
26
+ ADVISORY_CACHE_TTL_SECONDS = 24 * 60 * 60
27
+
28
+
29
+ class AdvisoryCache:
30
+ """Local filesystem cache for OSV advisory responses.
31
+
32
+ Each entry is keyed by ``{package_name}:{version}`` and stores the
33
+ raw list of vulnerability dicts returned by OSV. Entries older than
34
+ ``ADVISORY_CACHE_TTL_SECONDS`` are treated as stale and ignored.
35
+
36
+ Cache directory: ``.python-checkup-cache/v2/advisories/``
37
+ """
38
+
39
+ def __init__(self, project_root: Path, *, enabled: bool = True) -> None:
40
+ self.enabled = enabled
41
+ self.cache_dir = project_root / ".python-checkup-cache" / ADVISORY_CACHE_DIR
42
+ self._hits = 0
43
+ self._misses = 0
44
+ if self.enabled:
45
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
46
+
47
+ # -- key helpers --------------------------------------------------
48
+
49
+ @staticmethod
50
+ def _safe_key(name: str, version: str) -> str:
51
+ """Deterministic, filesystem-safe key for a package+version."""
52
+ raw = f"{name}:{version}"
53
+ return hashlib.sha256(raw.encode()).hexdigest()[:24]
54
+
55
+ def _path(self, name: str, version: str) -> Path:
56
+ return self.cache_dir / f"{self._safe_key(name, version)}.json"
57
+
58
+ # -- public API ---------------------------------------------------
59
+
60
+ def get(self, name: str, version: str) -> list[dict[str, object]] | None:
61
+ """Return cached advisory list or ``None`` on miss / stale."""
62
+ if not self.enabled:
63
+ return None
64
+ path = self._path(name, version)
65
+ if not path.exists():
66
+ self._misses += 1
67
+ return None
68
+ try:
69
+ data = json.loads(path.read_text())
70
+ ts = data.get("ts", 0)
71
+ if time.time() - ts > ADVISORY_CACHE_TTL_SECONDS:
72
+ path.unlink(missing_ok=True)
73
+ self._misses += 1
74
+ return None
75
+ self._hits += 1
76
+ vulns: list[dict[str, object]] = data.get("vulns", [])
77
+ return vulns
78
+ except (json.JSONDecodeError, OSError, KeyError, TypeError):
79
+ path.unlink(missing_ok=True)
80
+ self._misses += 1
81
+ return None
82
+
83
+ def set(self, name: str, version: str, vulns: list[dict[str, object]]) -> None:
84
+ """Persist advisory list for ``name==version``."""
85
+ if not self.enabled:
86
+ return
87
+ path = self._path(name, version)
88
+ try:
89
+ path.write_text(json.dumps({"ts": time.time(), "vulns": vulns}))
90
+ except OSError as exc:
91
+ logger.debug("advisory cache write failed: %s", exc)
92
+
93
+ def stats(self) -> dict[str, int]:
94
+ total = self._hits + self._misses
95
+ return {
96
+ "hits": self._hits,
97
+ "misses": self._misses,
98
+ "total": total,
99
+ "hit_rate_pct": round(self._hits / total * 100) if total else 0,
100
+ }
101
+
102
+
103
+ def _extract_fix_versions(vuln: dict[str, object]) -> list[str]:
104
+ """Extract fix version strings from an OSV vulnerability dict.
105
+
106
+ OSV stores fix info in ``affected[].ranges[].events`` where each event
107
+ is either ``{"introduced": "..."}`` or ``{"fixed": "..."}``. We collect
108
+ all unique ``fixed`` values across all affected entries and ranges.
109
+ """
110
+ fixes: list[str] = []
111
+ seen: set[str] = set()
112
+ affected_list = vuln.get("affected", [])
113
+ if not isinstance(affected_list, list):
114
+ return fixes
115
+ for affected in affected_list:
116
+ if not isinstance(affected, dict):
117
+ continue
118
+ for rng in affected.get("ranges", []):
119
+ if not isinstance(rng, dict):
120
+ continue
121
+ for event in rng.get("events", []):
122
+ if not isinstance(event, dict):
123
+ continue
124
+ fixed = event.get("fixed")
125
+ if fixed and isinstance(fixed, str) and fixed not in seen:
126
+ seen.add(fixed)
127
+ fixes.append(fixed)
128
+ return fixes
129
+
130
+
131
+ class DependencyVulnerabilityAnalyzer:
132
+ """Scan dependency versions from the target project against OSV."""
133
+
134
+ @property
135
+ def name(self) -> str:
136
+ return "dependency-vulns"
137
+
138
+ @property
139
+ def category(self) -> Category:
140
+ return Category.DEPENDENCIES
141
+
142
+ async def is_available(self) -> bool:
143
+ try:
144
+ import httpx as _httpx # noqa: F401
145
+
146
+ return True
147
+ except ImportError:
148
+ return False
149
+
150
+ async def analyze(self, request: AnalysisRequest) -> list[Diagnostic]:
151
+ source = discover_dependency_source(request.project_root)
152
+ if source is None or not source.packages:
153
+ request.metadata["dependency_vulns_note"] = (
154
+ "no lockfile or dependency manifest with version data"
155
+ )
156
+ return []
157
+
158
+ request.metadata["dependency_vulns_source"] = source.kind
159
+ request.metadata["dependency_vulns_locked"] = source.is_locked
160
+ request.metadata["dependency_vulns_package_count"] = len(source.packages)
161
+
162
+ pinned = [pkg for pkg in source.packages if pkg.version]
163
+ if not pinned:
164
+ request.metadata["dependency_vulns_note"] = (
165
+ "dependencies are declared but not pinned"
166
+ )
167
+ return []
168
+
169
+ cache = AdvisoryCache(request.project_root, enabled=not request.no_cache)
170
+
171
+ # Split packages into cached vs uncached
172
+ cached_vulns: dict[int, list[dict[str, object]]] = {}
173
+ uncached_indices: list[int] = []
174
+ for i, pkg in enumerate(pinned):
175
+ if pkg.version is None: # guaranteed False by filter above
176
+ continue
177
+ hit = cache.get(pkg.name, pkg.version)
178
+ if hit is not None:
179
+ cached_vulns[i] = hit
180
+ else:
181
+ uncached_indices.append(i)
182
+
183
+ # Query OSV only for uncached packages
184
+ remote_vulns = await self._query_osv(
185
+ pinned, uncached_indices, cache, request.config.timeout
186
+ )
187
+
188
+ # Merge cached + remote and build diagnostics
189
+ all_vulns = {**cached_vulns, **remote_vulns}
190
+ diagnostics = self._build_vulnerability_diagnostics(pinned, all_vulns, source)
191
+
192
+ # Store cache stats for reporting
193
+ stats = cache.stats()
194
+ if stats["total"] > 0:
195
+ logger.info(
196
+ "Advisory cache: %d hits, %d misses (%d%% hit rate)",
197
+ stats["hits"],
198
+ stats["misses"],
199
+ stats["hit_rate_pct"],
200
+ )
201
+
202
+ return diagnostics
203
+
204
+ async def _query_osv(
205
+ self,
206
+ pinned: list[LockedPackage],
207
+ uncached_indices: list[int],
208
+ cache: AdvisoryCache,
209
+ timeout: float,
210
+ ) -> dict[int, list[dict[str, object]]]:
211
+ """Call the OSV batch API for *uncached_indices* and update *cache*.
212
+
213
+ Returns a mapping from pinned-list index to vulnerability dicts.
214
+ """
215
+ if not uncached_indices:
216
+ return {}
217
+
218
+ payload = {
219
+ "queries": [
220
+ {
221
+ "package": {
222
+ "ecosystem": "PyPI",
223
+ "name": pinned[i].name,
224
+ },
225
+ "version": pinned[i].version,
226
+ }
227
+ for i in uncached_indices
228
+ ]
229
+ }
230
+ async with httpx.AsyncClient(timeout=timeout) as client:
231
+ response = await client.post(OSV_QUERY_BATCH_URL, json=payload)
232
+ response.raise_for_status()
233
+ data = response.json()
234
+
235
+ remote_vulns: dict[int, list[dict[str, object]]] = {}
236
+ results = data.get("results", [])
237
+ if not isinstance(results, list):
238
+ return remote_vulns
239
+
240
+ for j, result in enumerate(results):
241
+ if j >= len(uncached_indices):
242
+ break
243
+ idx = uncached_indices[j]
244
+ pkg = pinned[idx]
245
+ vulns: list[dict[str, object]] = []
246
+ if isinstance(result, dict):
247
+ raw = result.get("vulns", [])
248
+ if isinstance(raw, list):
249
+ vulns = raw
250
+ remote_vulns[idx] = vulns
251
+ cache.set(pkg.name, pkg.version, vulns)
252
+
253
+ return remote_vulns
254
+
255
+ @staticmethod
256
+ def _build_vulnerability_diagnostics(
257
+ pinned: list[LockedPackage],
258
+ all_vulns: dict[int, list[dict[str, object]]],
259
+ source: DependencySource,
260
+ ) -> list[Diagnostic]:
261
+ """Build :class:`Diagnostic` objects from aggregated vulnerability data."""
262
+ diagnostics: list[Diagnostic] = []
263
+ for i, pkg in enumerate(pinned):
264
+ vulns = all_vulns.get(i, [])
265
+ for vuln in vulns:
266
+ if not isinstance(vuln, dict):
267
+ continue
268
+ vuln_id = str(vuln.get("id", "UNKNOWN"))
269
+ fix_versions = _extract_fix_versions(vuln)
270
+ if fix_versions:
271
+ fix_text = (
272
+ f"Upgrade {pkg.name} to {' or '.join(fix_versions)} "
273
+ f"and refresh your lockfile."
274
+ )
275
+ else:
276
+ fix_text = (
277
+ "Upgrade the package to a non-vulnerable version "
278
+ "and refresh your lockfile."
279
+ )
280
+ diagnostics.append(
281
+ Diagnostic(
282
+ file_path=source.path,
283
+ line=0,
284
+ column=0,
285
+ severity=Severity.ERROR,
286
+ rule_id=vuln_id,
287
+ tool="dependency-vulns",
288
+ category=Category.DEPENDENCIES,
289
+ message=(
290
+ f"{pkg.name}=={pkg.version} has known vulnerability "
291
+ f"{vuln_id}"
292
+ ),
293
+ fix=fix_text,
294
+ help_url=f"https://osv.dev/vulnerability/{vuln_id}",
295
+ )
296
+ )
297
+
298
+ return diagnostics
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ from python_checkup.analysis_request import AnalysisRequest
10
+ from python_checkup.models import Category, Diagnostic, Severity
11
+
12
+ logger = logging.getLogger("python_checkup")
13
+
14
+ # Error code descriptions and severity mappings
15
+ DEPTRY_CODES: dict[str, tuple[str, Severity]] = {
16
+ "DEP001": ("Missing dependency", Severity.ERROR),
17
+ "DEP002": ("Unused dependency", Severity.WARNING),
18
+ "DEP003": ("Transitive dependency", Severity.WARNING),
19
+ "DEP004": ("Misplaced dev dependency", Severity.WARNING),
20
+ }
21
+
22
+
23
+ class DeptryAnalyzer:
24
+ """Check for unused, missing, and transitive dependencies."""
25
+
26
+ @property
27
+ def name(self) -> str:
28
+ return "deptry"
29
+
30
+ @property
31
+ def category(self) -> Category:
32
+ return Category.DEPENDENCIES
33
+
34
+ async def is_available(self) -> bool:
35
+ """Check if deptry is on PATH."""
36
+ return shutil.which("deptry") is not None
37
+
38
+ async def analyze(
39
+ self,
40
+ request: AnalysisRequest,
41
+ ) -> list[Diagnostic]:
42
+ """Run deptry with JSON output.
43
+
44
+ deptry analyzes the project root, not individual files.
45
+ """
46
+ config = request.config_dict()
47
+ project_root = str(request.project_root)
48
+
49
+ cmd = ["deptry", project_root, "--json-output", "-"]
50
+
51
+ timeout: int = 30
52
+ if "timeout" in config and isinstance(config["timeout"], int | float):
53
+ timeout = int(config["timeout"])
54
+
55
+ try:
56
+ proc = await asyncio.create_subprocess_exec(
57
+ *cmd,
58
+ stdout=asyncio.subprocess.PIPE,
59
+ stderr=asyncio.subprocess.PIPE,
60
+ )
61
+ stdout, stderr = await asyncio.wait_for(
62
+ proc.communicate(),
63
+ timeout=timeout,
64
+ )
65
+ except asyncio.TimeoutError:
66
+ logger.warning("deptry timed out")
67
+ return []
68
+ except FileNotFoundError:
69
+ logger.warning("deptry not found")
70
+ return []
71
+
72
+ # deptry writes JSON to stdout when --json-output - is used
73
+ output = stdout.decode()
74
+ if not output.strip():
75
+ return []
76
+
77
+ try:
78
+ issues: list[dict[str, object]] = json.loads(output)
79
+ except json.JSONDecodeError:
80
+ logger.error("Failed to parse deptry JSON")
81
+ return []
82
+
83
+ diagnostics: list[Diagnostic] = []
84
+ for issue in issues:
85
+ if not isinstance(issue, dict):
86
+ continue
87
+
88
+ error = issue.get("error", {})
89
+ if not isinstance(error, dict):
90
+ error = {}
91
+ code = str(error.get("code", "DEP000"))
92
+
93
+ location = issue.get("location", {})
94
+ if not isinstance(location, dict):
95
+ location = {}
96
+ module = str(issue.get("module", "unknown"))
97
+
98
+ desc, severity = DEPTRY_CODES.get(
99
+ code, ("Dependency issue", Severity.WARNING)
100
+ )
101
+
102
+ file_path = str(location.get("file", "pyproject.toml"))
103
+ line_val = location.get("line")
104
+ col_val = location.get("column")
105
+
106
+ diagnostics.append(
107
+ Diagnostic(
108
+ file_path=Path(file_path),
109
+ line=int(line_val) if isinstance(line_val, int | float) else 0,
110
+ column=int(col_val) if isinstance(col_val, int | float) else 0,
111
+ severity=severity,
112
+ rule_id=code,
113
+ tool="deptry",
114
+ category=Category.DEPENDENCIES,
115
+ message=f"{desc}: {module}",
116
+ fix=_fix_suggestion(code, module),
117
+ )
118
+ )
119
+
120
+ return diagnostics
121
+
122
+
123
+ def _fix_suggestion(code: str, module: str) -> str:
124
+ match code:
125
+ case "DEP001":
126
+ return f"Add '{module}' to your project dependencies in pyproject.toml"
127
+ case "DEP002":
128
+ return (
129
+ f"Remove '{module}' from your project dependencies (it's not imported)"
130
+ )
131
+ case "DEP003":
132
+ return (
133
+ f"Add '{module}' as a direct dependency instead of "
134
+ "relying on it transitively"
135
+ )
136
+ case "DEP004":
137
+ return (
138
+ f"Move '{module}' from dev dependencies to main dependencies "
139
+ "(it's imported in non-dev code)"
140
+ )
141
+ case _:
142
+ return f"Review the dependency '{module}'"
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from python_checkup.analysis_request import AnalysisRequest
8
+ from python_checkup.models import Category, Diagnostic, Severity
9
+
10
+ logger = logging.getLogger("python_checkup")
11
+
12
+
13
+ class DetectSecretsAnalyzer:
14
+ """Find hardcoded secrets using entropy and pattern analysis."""
15
+
16
+ @property
17
+ def name(self) -> str:
18
+ return "detect-secrets"
19
+
20
+ @property
21
+ def category(self) -> Category:
22
+ return Category.SECURITY
23
+
24
+ async def is_available(self) -> bool:
25
+ """Check if detect-secrets is importable."""
26
+ try:
27
+ from detect_secrets.core.secrets_collection import (
28
+ SecretsCollection, # noqa: F401
29
+ )
30
+
31
+ return True
32
+ except ImportError:
33
+ return False
34
+
35
+ async def analyze(
36
+ self,
37
+ request: AnalysisRequest,
38
+ ) -> list[Diagnostic]:
39
+ """Scan files for hardcoded secrets.
40
+
41
+ Uses the detect-secrets library API with default settings.
42
+ Runs in a thread because the scan is CPU-bound.
43
+ """
44
+ files = request.files
45
+ if not files:
46
+ return []
47
+ return await asyncio.to_thread(self._analyze_sync, files)
48
+
49
+ def _analyze_sync(self, files: list[Path]) -> list[Diagnostic]:
50
+ """Synchronous scan — called via ``asyncio.to_thread``."""
51
+ try:
52
+ from detect_secrets.core.secrets_collection import SecretsCollection
53
+ from detect_secrets.settings import (
54
+ default_settings,
55
+ )
56
+ except ImportError:
57
+ return []
58
+
59
+ secrets = SecretsCollection()
60
+
61
+ with default_settings():
62
+ for f in files:
63
+ try:
64
+ secrets.scan_file(str(f))
65
+ except Exception as exc:
66
+ logger.debug("detect-secrets failed on %s: %s", f, exc)
67
+ continue
68
+
69
+ diagnostics: list[Diagnostic] = []
70
+ for filename, secret_list in secrets.json().items():
71
+ if not isinstance(secret_list, list):
72
+ continue
73
+ for secret in secret_list:
74
+ if not isinstance(secret, dict):
75
+ continue
76
+ secret_type = str(secret.get("type", "unknown"))
77
+ rule_id = f"SECRET-{secret_type.upper().replace(' ', '-')}"
78
+
79
+ line_number = secret.get("line_number", 0)
80
+ if not isinstance(line_number, int):
81
+ line_number = 0
82
+
83
+ diagnostics.append(
84
+ Diagnostic(
85
+ file_path=Path(str(filename)),
86
+ line=line_number,
87
+ column=0,
88
+ severity=Severity.ERROR,
89
+ rule_id=rule_id,
90
+ tool="detect-secrets",
91
+ category=Category.SECURITY,
92
+ message=f"Potential {secret_type} detected",
93
+ fix=(
94
+ "Move this secret to an environment variable or a "
95
+ "secrets manager (e.g., AWS Secrets Manager, HashiCorp "
96
+ "Vault, or a .env file excluded from version control)."
97
+ ),
98
+ )
99
+ )
100
+
101
+ return diagnostics