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,80 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import shutil
6
+ from pathlib import Path
7
+
8
+ from python_checkup.analysis_request import AnalysisRequest
9
+ from python_checkup.models import Category, Diagnostic, Severity
10
+
11
+
12
+ class TyposAnalyzer:
13
+ """Source-code typo detection via typos."""
14
+
15
+ @property
16
+ def name(self) -> str:
17
+ return "typos"
18
+
19
+ @property
20
+ def category(self) -> Category:
21
+ return Category.QUALITY
22
+
23
+ async def is_available(self) -> bool:
24
+ return shutil.which("typos") is not None
25
+
26
+ async def analyze(self, request: AnalysisRequest) -> list[Diagnostic]:
27
+ if not request.files:
28
+ return []
29
+
30
+ cmd = ["typos", "--format", "json", *[str(path) for path in request.files]]
31
+ timeout = request.config.timeout
32
+ proc = await asyncio.create_subprocess_exec(
33
+ *cmd,
34
+ stdout=asyncio.subprocess.PIPE,
35
+ stderr=asyncio.subprocess.PIPE,
36
+ )
37
+ stdout, _stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
38
+
39
+ # typos exits 0 when clean, 2 when typos found.
40
+ if proc.returncode not in (0, 2):
41
+ return []
42
+
43
+ output = stdout.decode().strip()
44
+ if not output:
45
+ return []
46
+
47
+ diagnostics: list[Diagnostic] = []
48
+ for line in output.splitlines():
49
+ try:
50
+ raw = json.loads(line)
51
+ except json.JSONDecodeError:
52
+ continue
53
+
54
+ if not isinstance(raw, dict):
55
+ continue
56
+
57
+ path = Path(str(raw.get("path", "unknown")))
58
+ typo = str(raw.get("typo", ""))
59
+ corrections = raw.get("corrections", [])
60
+ fix: str | None = None
61
+ if isinstance(corrections, list) and corrections:
62
+ fix = (
63
+ f"Replace with: {', '.join(str(item) for item in corrections[:3])}"
64
+ )
65
+
66
+ diagnostics.append(
67
+ Diagnostic(
68
+ file_path=path,
69
+ line=int(raw.get("line_num", 0) or 0),
70
+ column=int(raw.get("byte_offset", 0) or 0),
71
+ severity=Severity.INFO,
72
+ rule_id="TYPOS",
73
+ tool="typos",
74
+ category=Category.QUALITY,
75
+ message=f"Possible typo: {typo}",
76
+ fix=fix,
77
+ )
78
+ )
79
+
80
+ return diagnostics
@@ -0,0 +1,151 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from python_checkup.analysis_request import AnalysisRequest
9
+ from python_checkup.models import Category, Diagnostic, Severity
10
+
11
+ logger = logging.getLogger("python_checkup")
12
+
13
+ # Vulture item types and their human-readable labels
14
+ ITEM_TYPE_LABELS: dict[str, str] = {
15
+ "attribute": "Unused attribute",
16
+ "class": "Unused class",
17
+ "function": "Unused function",
18
+ "import": "Unused import",
19
+ "method": "Unused method",
20
+ "property": "Unused property",
21
+ "variable": "Unused variable",
22
+ "unreachable_code": "Unreachable code",
23
+ }
24
+
25
+ # Map item types to severity
26
+ ITEM_TYPE_SEVERITY: dict[str, Severity] = {
27
+ "unreachable_code": Severity.ERROR, # Definitely dead
28
+ "import": Severity.WARNING, # Likely dead
29
+ "function": Severity.WARNING, # Likely dead
30
+ "method": Severity.WARNING, # Likely dead
31
+ "class": Severity.WARNING, # Likely dead
32
+ "variable": Severity.INFO, # Might be dynamic access
33
+ "attribute": Severity.INFO, # Might be dynamic access
34
+ "property": Severity.INFO, # Might be dynamic access
35
+ }
36
+
37
+
38
+ class VultureAnalyzer:
39
+ """Dead code detection via Vulture."""
40
+
41
+ @property
42
+ def name(self) -> str:
43
+ return "vulture"
44
+
45
+ @property
46
+ def category(self) -> Category:
47
+ return Category.DEAD_CODE
48
+
49
+ async def is_available(self) -> bool:
50
+ """Check if vulture is importable."""
51
+ try:
52
+ import vulture # noqa: F401
53
+
54
+ return True
55
+ except ImportError:
56
+ return False
57
+
58
+ async def analyze(
59
+ self,
60
+ request: AnalysisRequest,
61
+ ) -> list[Diagnostic]:
62
+ """Run Vulture dead code detection.
63
+
64
+ IMPORTANT: Vulture accumulates state across scavenge() calls.
65
+ A fresh Vulture() instance MUST be created for each analysis run.
66
+ """
67
+ files = request.files
68
+ config = request.config_dict()
69
+ if not files:
70
+ return []
71
+
72
+ min_confidence: int = 80
73
+ raw_conf = config.get("min_confidence", 80)
74
+ if isinstance(raw_conf, int | float):
75
+ min_confidence = int(raw_conf)
76
+
77
+ # Run in thread pool -- Vulture's AST parsing is CPU-bound
78
+ loop = asyncio.get_running_loop()
79
+ return await loop.run_in_executor(
80
+ None, self._analyze_sync, files, min_confidence
81
+ )
82
+
83
+ def _analyze_sync(
84
+ self,
85
+ files: list[Path],
86
+ min_confidence: int,
87
+ ) -> list[Diagnostic]:
88
+ """Synchronous analysis -- runs in thread pool."""
89
+ from vulture.core import Vulture
90
+
91
+ # CRITICAL: Fresh instance every time.
92
+ v = Vulture()
93
+
94
+ try:
95
+ v.scavenge([str(f) for f in files])
96
+ except Exception as e:
97
+ logger.error("Vulture scavenge failed: %s", e)
98
+ return []
99
+
100
+ unused_code = v.get_unused_code(min_confidence=min_confidence)
101
+
102
+ diagnostics: list[Diagnostic] = []
103
+ for item in unused_code:
104
+ severity = ITEM_TYPE_SEVERITY.get(item.typ, Severity.INFO)
105
+ type_label = ITEM_TYPE_LABELS.get(item.typ, f"Unused {item.typ}")
106
+
107
+ diagnostics.append(
108
+ Diagnostic(
109
+ file_path=Path(item.filename),
110
+ line=item.first_lineno,
111
+ column=0,
112
+ severity=severity,
113
+ rule_id=f"V-{item.typ}",
114
+ tool="vulture",
115
+ category=Category.DEAD_CODE,
116
+ message=(
117
+ f"{type_label}: '{item.name}' ({item.confidence}% confidence)"
118
+ ),
119
+ fix=_dead_code_fix(item),
120
+ end_line=item.last_lineno,
121
+ )
122
+ )
123
+
124
+ return diagnostics
125
+
126
+
127
+ def _dead_code_fix(item: Any) -> str:
128
+ match item.typ:
129
+ case "import":
130
+ return f"Remove unused import '{item.name}'"
131
+ case "function":
132
+ return (
133
+ f"Remove unused function '{item.name}' or add it to a "
134
+ "whitelist if it's used via dynamic dispatch"
135
+ )
136
+ case "method":
137
+ return (
138
+ f"Remove unused method '{item.name}' or mark it with "
139
+ "@typing.override if it implements an interface"
140
+ )
141
+ case "class":
142
+ return (
143
+ f"Remove unused class '{item.name}' or add it to __all__ "
144
+ "if it's part of the public API"
145
+ )
146
+ case "variable":
147
+ return f"Remove unused variable '{item.name}' or prefix with underscore"
148
+ case "unreachable_code":
149
+ return "Remove unreachable code after return/raise/continue/break"
150
+ case _:
151
+ return f"Remove unused {item.typ} '{item.name}'"
@@ -0,0 +1,244 @@
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
+ from python_checkup import __version__
10
+ from python_checkup.models import Category, Diagnostic, Severity
11
+
12
+ logger = logging.getLogger("python_checkup")
13
+
14
+ # Include the package version so any python-checkup upgrade automatically
15
+ # invalidates the cache (analyzer logic may have changed).
16
+ CACHE_VERSION = f"v1-{__version__}"
17
+ # Maximum age for cache entries before cleanup (7 days)
18
+ CACHE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
19
+ # Maximum total cache size before cleanup (100 MB)
20
+ CACHE_MAX_SIZE_BYTES = 100 * 1024 * 1024
21
+
22
+
23
+ class AnalysisCache:
24
+ """Per-file, per-analyzer cache backed by the local filesystem.
25
+
26
+ Each file's diagnostics are cached independently, keyed on the
27
+ SHA-256 hash of the file's content. When a file changes, its hash
28
+ changes, so the old cache entry is never read again.
29
+
30
+ The cache directory defaults to .python-checkup-cache/ in the project root.
31
+ """
32
+
33
+ def __init__(self, project_root: Path, enabled: bool = True) -> None:
34
+ self.project_root = project_root
35
+ self.enabled = enabled
36
+ self.cache_dir = project_root / ".python-checkup-cache" / CACHE_VERSION
37
+ self._hits = 0
38
+ self._misses = 0
39
+
40
+ if self.enabled:
41
+ self._ensure_cache_dir()
42
+
43
+ def _ensure_cache_dir(self) -> None:
44
+ """Create cache directory and .gitignore if they don't exist."""
45
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
46
+
47
+ gitignore = self.project_root / ".python-checkup-cache" / ".gitignore"
48
+ if not gitignore.exists():
49
+ gitignore.write_text("*\n")
50
+
51
+ def _file_hash(self, path: Path) -> str:
52
+ """Compute truncated SHA-256 hash of file contents.
53
+
54
+ Returns first 16 hex chars (64 bits) of SHA-256.
55
+ Reads file in binary mode to avoid encoding issues.
56
+ """
57
+ h = hashlib.sha256()
58
+ try:
59
+ with open(path, "rb") as f:
60
+ for chunk in iter(lambda: f.read(8192), b""):
61
+ h.update(chunk)
62
+ except OSError:
63
+ return "0" * 16
64
+ return h.hexdigest()[:16]
65
+
66
+ def _cache_key(self, analyzer_name: str, file_path: Path) -> str:
67
+ """Build cache key: {analyzer_name}:{content_hash}."""
68
+ content_hash = self._file_hash(file_path)
69
+ return f"{analyzer_name}:{content_hash}"
70
+
71
+ def _cache_path(self, cache_key: str) -> Path:
72
+ # Replace colons for Windows compatibility
73
+ safe_key = cache_key.replace(":", "_")
74
+ return self.cache_dir / f"{safe_key}.json"
75
+
76
+ def get(self, analyzer_name: str, file_path: Path) -> list[Diagnostic] | None:
77
+ """Return cached diagnostics if file content hasn't changed.
78
+
79
+ Returns None on cache miss. Returns list[Diagnostic] on hit
80
+ (which may be an empty list, meaning "no issues found").
81
+ """
82
+ if not self.enabled:
83
+ return None
84
+
85
+ cache_key = self._cache_key(analyzer_name, file_path)
86
+ cache_file = self._cache_path(cache_key)
87
+
88
+ if not cache_file.exists():
89
+ self._misses += 1
90
+ return None
91
+
92
+ try:
93
+ raw = json.loads(cache_file.read_text())
94
+ diagnostics = [_deserialize_diagnostic(d) for d in raw]
95
+ self._hits += 1
96
+ logger.debug("Cache hit: %s for %s", analyzer_name, file_path.name)
97
+ return diagnostics
98
+ except (json.JSONDecodeError, KeyError, TypeError, ValueError):
99
+ logger.debug("Cache corrupted for %s, removing", cache_key)
100
+ cache_file.unlink(missing_ok=True)
101
+ self._misses += 1
102
+ return None
103
+
104
+ def set(
105
+ self,
106
+ analyzer_name: str,
107
+ file_path: Path,
108
+ diagnostics: list[Diagnostic],
109
+ ) -> None:
110
+ """Cache diagnostics for a file.
111
+
112
+ Stores an empty list for clean files (so we know "no issues"
113
+ vs "not yet analyzed").
114
+ """
115
+ if not self.enabled:
116
+ return
117
+
118
+ cache_key = self._cache_key(analyzer_name, file_path)
119
+ cache_file = self._cache_path(cache_key)
120
+
121
+ try:
122
+ serialized = [_serialize_diagnostic(d) for d in diagnostics]
123
+ cache_file.write_text(json.dumps(serialized, default=str))
124
+ except OSError as e:
125
+ logger.debug("Failed to write cache for %s: %s", cache_key, e)
126
+
127
+ def get_stats(self) -> dict[str, int]:
128
+ total = self._hits + self._misses
129
+ return {
130
+ "hits": self._hits,
131
+ "misses": self._misses,
132
+ "total": total,
133
+ "hit_rate_pct": (round(self._hits / total * 100) if total > 0 else 0),
134
+ }
135
+
136
+ def clear(self) -> int:
137
+ """Delete all cache entries. Returns number of entries deleted."""
138
+ count = 0
139
+ if self.cache_dir.exists():
140
+ for f in self.cache_dir.glob("*.json"):
141
+ f.unlink()
142
+ count += 1
143
+ logger.info("Cleared %d cache entries", count)
144
+ return count
145
+
146
+ def cleanup(self) -> int:
147
+ """Remove stale cache entries older than CACHE_MAX_AGE_SECONDS.
148
+
149
+ Also enforces CACHE_MAX_SIZE_BYTES by removing oldest entries
150
+ first. Returns number of entries removed.
151
+ """
152
+ if not self.cache_dir.exists():
153
+ return 0
154
+
155
+ now = time.time()
156
+ entries: list[tuple[Path, float, int]] = []
157
+ removed = 0
158
+
159
+ for f in self.cache_dir.glob("*.json"):
160
+ stat = f.stat()
161
+ age = now - stat.st_mtime
162
+ if age > CACHE_MAX_AGE_SECONDS:
163
+ f.unlink()
164
+ removed += 1
165
+ else:
166
+ entries.append((f, stat.st_mtime, stat.st_size))
167
+
168
+ # Enforce size limit: remove oldest first
169
+ total_size = sum(size for _, _, size in entries)
170
+ if total_size > CACHE_MAX_SIZE_BYTES:
171
+ entries.sort(key=lambda e: e[1]) # oldest first
172
+ for path, _, size in entries:
173
+ if total_size <= CACHE_MAX_SIZE_BYTES:
174
+ break
175
+ path.unlink()
176
+ total_size -= size
177
+ removed += 1
178
+
179
+ if removed > 0:
180
+ logger.debug("Cache cleanup: removed %d stale entries", removed)
181
+ return removed
182
+
183
+
184
+ def _serialize_diagnostic(d: Diagnostic) -> dict[str, object]:
185
+ return {
186
+ "file_path": str(d.file_path),
187
+ "line": d.line,
188
+ "column": d.column,
189
+ "severity": d.severity.value,
190
+ "rule_id": d.rule_id,
191
+ "tool": d.tool,
192
+ "category": d.category.value,
193
+ "message": d.message,
194
+ "fix": d.fix,
195
+ "help_url": d.help_url,
196
+ "end_line": d.end_line,
197
+ "end_column": d.end_column,
198
+ }
199
+
200
+
201
+ def _deserialize_diagnostic(data: dict[str, object]) -> Diagnostic:
202
+ file_path = data["file_path"]
203
+ line = data["line"]
204
+ column = data["column"]
205
+ severity_val = data["severity"]
206
+ rule_id = data["rule_id"]
207
+ tool = data["tool"]
208
+ category_val = data["category"]
209
+ message = data["message"]
210
+
211
+ if (
212
+ not isinstance(file_path, str)
213
+ or not isinstance(line, int)
214
+ or not isinstance(column, int)
215
+ or not isinstance(severity_val, str)
216
+ or not isinstance(rule_id, str)
217
+ or not isinstance(tool, str)
218
+ or not isinstance(category_val, str)
219
+ or not isinstance(message, str)
220
+ ):
221
+ msg = "Invalid diagnostic data types"
222
+ raise TypeError(msg)
223
+
224
+ fix = data.get("fix")
225
+ help_url = data.get("help_url")
226
+ end_line = data.get("end_line")
227
+ end_column = data.get("end_column")
228
+
229
+ return Diagnostic(
230
+ file_path=Path(file_path),
231
+ line=line,
232
+ column=column,
233
+ severity=Severity(severity_val),
234
+ rule_id=rule_id,
235
+ tool=tool,
236
+ category=Category(category_val),
237
+ message=message,
238
+ fix=str(fix) if fix is not None else None,
239
+ help_url=str(help_url) if help_url is not None else None,
240
+ end_line=int(end_line) if isinstance(end_line, int | float | str) else None,
241
+ end_column=int(end_column)
242
+ if isinstance(end_column, int | float | str)
243
+ else None,
244
+ )