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,9 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("python-checkup")
5
+ except PackageNotFoundError:
6
+ # Fallback for local source execution without installed metadata.
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = ["__version__"]
@@ -0,0 +1,3 @@
1
+ from python_checkup.cli import main
2
+
3
+ main()
@@ -0,0 +1,35 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ from python_checkup.config import CheckupConfig
7
+ from python_checkup.models import Category
8
+
9
+
10
+ @dataclass
11
+ class AnalysisRequest:
12
+ """Input contract shared by all analyzers."""
13
+
14
+ project_root: Path
15
+ files: list[Path]
16
+ config: CheckupConfig
17
+ categories: set[Category]
18
+ profile: str
19
+ framework: str | None = None
20
+ diff_base: str | None = None
21
+ quiet: bool = False
22
+ no_cache: bool = False
23
+ metadata: dict[str, object] = field(default_factory=dict)
24
+
25
+ def config_dict(self) -> dict[str, object]:
26
+ """Return a mutable dict representation for legacy integrations."""
27
+ data = self.config.__dict__.copy()
28
+ data["framework"] = self.framework
29
+ data["profile"] = self.profile
30
+ data["categories"] = [
31
+ category.value
32
+ for category in sorted(self.categories, key=lambda c: c.value)
33
+ ]
34
+ data.update(self.metadata)
35
+ return data
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from python_checkup.models import Category
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class AnalyzerInfo:
10
+ """Static metadata about a known analyzer."""
11
+
12
+ name: str
13
+ categories: frozenset[Category]
14
+ default_enabled: bool = True
15
+ optional: bool = False
16
+ requires_network: bool = False
17
+ project_wide: bool = False
18
+ profiles: frozenset[str] = field(
19
+ default_factory=lambda: frozenset({"quick", "default", "full"})
20
+ )
21
+
22
+
23
+ ANALYZER_CATALOG: dict[str, AnalyzerInfo] = {
24
+ "ruff": AnalyzerInfo(
25
+ name="ruff",
26
+ categories=frozenset(
27
+ {
28
+ Category.QUALITY,
29
+ Category.SECURITY,
30
+ Category.COMPLEXITY,
31
+ Category.DEAD_CODE,
32
+ }
33
+ ),
34
+ profiles=frozenset({"quick", "default", "full"}),
35
+ ),
36
+ "mypy": AnalyzerInfo(
37
+ name="mypy",
38
+ categories=frozenset({Category.TYPE_SAFETY}),
39
+ profiles=frozenset({"default", "full"}),
40
+ project_wide=True,
41
+ ),
42
+ "bandit": AnalyzerInfo(
43
+ name="bandit",
44
+ categories=frozenset({Category.SECURITY}),
45
+ profiles=frozenset({"default", "full"}),
46
+ ),
47
+ "radon": AnalyzerInfo(
48
+ name="radon",
49
+ categories=frozenset({Category.COMPLEXITY}),
50
+ profiles=frozenset({"default", "full"}),
51
+ ),
52
+ "vulture": AnalyzerInfo(
53
+ name="vulture",
54
+ categories=frozenset({Category.DEAD_CODE}),
55
+ profiles=frozenset({"default", "full"}),
56
+ project_wide=True,
57
+ ),
58
+ "deptry": AnalyzerInfo(
59
+ name="deptry",
60
+ categories=frozenset({Category.DEPENDENCIES}),
61
+ profiles=frozenset({"quick", "default", "full"}),
62
+ project_wide=True,
63
+ ),
64
+ "dependency-vulns": AnalyzerInfo(
65
+ name="dependency-vulns",
66
+ categories=frozenset({Category.DEPENDENCIES}),
67
+ default_enabled=False,
68
+ optional=True,
69
+ requires_network=True,
70
+ project_wide=True,
71
+ profiles=frozenset({"full"}),
72
+ ),
73
+ "detect-secrets": AnalyzerInfo(
74
+ name="detect-secrets",
75
+ categories=frozenset({Category.SECURITY}),
76
+ default_enabled=False,
77
+ optional=True,
78
+ profiles=frozenset({"full"}),
79
+ ),
80
+ "basedpyright": AnalyzerInfo(
81
+ name="basedpyright",
82
+ categories=frozenset({Category.TYPE_SAFETY}),
83
+ default_enabled=False,
84
+ optional=True,
85
+ project_wide=True,
86
+ profiles=frozenset({"full"}),
87
+ ),
88
+ "typos": AnalyzerInfo(
89
+ name="typos",
90
+ categories=frozenset({Category.QUALITY}),
91
+ default_enabled=False,
92
+ optional=True,
93
+ profiles=frozenset({"full"}),
94
+ ),
95
+ }
96
+
97
+
98
+ def get_analyzer_info(name: str) -> AnalyzerInfo | None:
99
+ """Return catalog metadata for an analyzer name."""
100
+ return ANALYZER_CATALOG.get(name)
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol, runtime_checkable
4
+
5
+ from python_checkup.analysis_request import AnalysisRequest
6
+ from python_checkup.models import Category, Diagnostic
7
+
8
+
9
+ @runtime_checkable
10
+ class Analyzer(Protocol):
11
+ """Protocol that all analyzers must satisfy.
12
+
13
+ Analyzers can be built-in or loaded via entry points.
14
+ Each analyzer:
15
+ - Has a unique name and primary category
16
+ - Can check if its underlying tool is available
17
+ - Runs analysis and returns a list of Diagnostics
18
+ """
19
+
20
+ @property
21
+ def name(self) -> str:
22
+ """Unique identifier, e.g. 'ruff', 'mypy', 'bandit'."""
23
+ ...
24
+
25
+ @property
26
+ def category(self) -> Category:
27
+ """Primary category this analyzer contributes to."""
28
+ ...
29
+
30
+ async def is_available(self) -> bool:
31
+ """Check if the underlying tool is installed and runnable.
32
+
33
+ Returns True if the tool is available, False otherwise.
34
+ This should be fast (check PATH, try import, etc).
35
+ """
36
+ ...
37
+
38
+ async def analyze(
39
+ self,
40
+ request: AnalysisRequest,
41
+ ) -> list[Diagnostic]:
42
+ """Run analysis on the given files.
43
+
44
+ Args:
45
+ request: Structured request describing project context, enabled
46
+ categories, profile, files, and configuration.
47
+
48
+ Returns:
49
+ List of diagnostics found. Empty list means no issues.
50
+
51
+ Raises:
52
+ TimeoutError: If analysis exceeds configured timeout.
53
+ """
54
+ ...
@@ -0,0 +1,158 @@
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
+ # Bandit severity -> python-checkup Severity
15
+ BANDIT_SEVERITY_MAP: dict[str, Severity] = {
16
+ "HIGH": Severity.ERROR,
17
+ "MEDIUM": Severity.WARNING,
18
+ "LOW": Severity.INFO,
19
+ }
20
+
21
+
22
+ class BanditAnalyzer:
23
+ """Security scanning via Bandit."""
24
+
25
+ @property
26
+ def name(self) -> str:
27
+ return "bandit"
28
+
29
+ @property
30
+ def category(self) -> Category:
31
+ return Category.SECURITY
32
+
33
+ async def is_available(self) -> bool:
34
+ """Check if bandit is on PATH."""
35
+ return shutil.which("bandit") is not None
36
+
37
+ async def analyze(
38
+ self,
39
+ request: AnalysisRequest,
40
+ ) -> list[Diagnostic]:
41
+ files = request.files
42
+ config = request.config_dict()
43
+ if not files:
44
+ return []
45
+
46
+ cmd = ["bandit", "-f", "json", "--quiet"]
47
+
48
+ str_paths = [str(f) for f in files]
49
+ cmd.extend(str_paths)
50
+
51
+ timeout: int = 60
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("Bandit timed out")
67
+ return []
68
+ except FileNotFoundError:
69
+ logger.warning("Bandit binary not found")
70
+ return []
71
+
72
+ # Exit codes: 0 = clean, 1 = issues found, 2 = error
73
+ if proc.returncode == 2:
74
+ logger.error("Bandit error: %s", stderr.decode())
75
+ return []
76
+
77
+ output = stdout.decode()
78
+ if not output.strip():
79
+ return []
80
+
81
+ try:
82
+ data = json.loads(output)
83
+ except json.JSONDecodeError:
84
+ logger.error("Failed to parse Bandit JSON output")
85
+ return []
86
+
87
+ results = data.get("results", [])
88
+ if not isinstance(results, list):
89
+ return []
90
+
91
+ diagnostics = [_map_bandit_result(r) for r in results]
92
+
93
+ # Suppress noisy Bandit rules in test files (same approach as
94
+ # Ruff's per-file-ignores). B101 (assert), B108 (temp dirs),
95
+ # B603/B607 (subprocess), B404 (subprocess import), B105/B106
96
+ # (hardcoded passwords) are standard practice in test suites.
97
+ _test_suppressed = {"B101", "B108", "B603", "B607", "B404", "B105", "B106"}
98
+ diagnostics = [
99
+ d
100
+ for d in diagnostics
101
+ if not (d.rule_id in _test_suppressed and _is_test_file(d.file_path))
102
+ ]
103
+
104
+ return diagnostics
105
+
106
+
107
+ def _is_test_file(path: Path) -> bool:
108
+ """Return True if *path* looks like a test file or sits inside a tests/ dir."""
109
+ parts = path.parts
110
+ return (
111
+ any(p in ("tests", "test") for p in parts)
112
+ or path.name.startswith("test_")
113
+ or path.name.endswith("_test.py")
114
+ )
115
+
116
+
117
+ def _safe_int(val: object, default: int = 0) -> int:
118
+ if val is None:
119
+ return default
120
+ if isinstance(val, int):
121
+ return val
122
+ try:
123
+ return int(str(val))
124
+ except (TypeError, ValueError):
125
+ return default
126
+
127
+
128
+ def _map_bandit_result(raw: dict[str, object]) -> Diagnostic:
129
+ severity = BANDIT_SEVERITY_MAP.get(
130
+ str(raw.get("issue_severity", "LOW")),
131
+ Severity.INFO,
132
+ )
133
+
134
+ test_id = str(raw.get("test_id", "B000"))
135
+
136
+ # Build fix suggestion from CWE
137
+ cwe = raw.get("issue_cwe")
138
+ fix_text: str | None = None
139
+ if isinstance(cwe, dict) and cwe.get("id"):
140
+ fix_text = f"CWE-{cwe['id']}"
141
+
142
+ help_url = raw.get("more_info")
143
+
144
+ end_col = raw.get("end_col_offset")
145
+
146
+ return Diagnostic(
147
+ file_path=Path(str(raw.get("filename", "unknown"))),
148
+ line=_safe_int(raw.get("line_number")),
149
+ column=_safe_int(raw.get("col_offset")),
150
+ severity=severity,
151
+ rule_id=test_id,
152
+ tool="bandit",
153
+ category=Category.SECURITY,
154
+ message=str(raw.get("issue_text", "")),
155
+ fix=fix_text,
156
+ help_url=str(help_url) if help_url else None,
157
+ end_column=_safe_int(end_col) if end_col else None,
158
+ )
@@ -0,0 +1,103 @@
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 BasedPyrightAnalyzer:
13
+ """Type checking via basedpyright."""
14
+
15
+ @property
16
+ def name(self) -> str:
17
+ return "basedpyright"
18
+
19
+ @property
20
+ def category(self) -> Category:
21
+ return Category.TYPE_SAFETY
22
+
23
+ async def is_available(self) -> bool:
24
+ return shutil.which("basedpyright") is not None
25
+
26
+ async def analyze(self, request: AnalysisRequest) -> list[Diagnostic]:
27
+ if not request.files:
28
+ return []
29
+
30
+ cmd = [
31
+ "basedpyright",
32
+ "--outputjson",
33
+ "--project",
34
+ str(request.project_root),
35
+ *[str(path) for path in request.files],
36
+ ]
37
+
38
+ timeout = request.config.timeout
39
+ proc = await asyncio.create_subprocess_exec(
40
+ *cmd,
41
+ stdout=asyncio.subprocess.PIPE,
42
+ stderr=asyncio.subprocess.PIPE,
43
+ )
44
+ stdout, _stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
45
+
46
+ if proc.returncode not in (0, 1):
47
+ return []
48
+
49
+ output = stdout.decode().strip()
50
+ if not output:
51
+ return []
52
+
53
+ try:
54
+ data = json.loads(output)
55
+ except json.JSONDecodeError:
56
+ return []
57
+
58
+ diagnostics: list[Diagnostic] = []
59
+ raw_diags = data.get("generalDiagnostics", [])
60
+ if not isinstance(raw_diags, list):
61
+ return []
62
+
63
+ for raw in raw_diags:
64
+ if not isinstance(raw, dict):
65
+ continue
66
+
67
+ severity_raw = str(raw.get("severity", "warning"))
68
+ severity = Severity.ERROR if severity_raw == "error" else Severity.WARNING
69
+ file_path = Path(str(raw.get("file", "unknown")))
70
+ rule = str(raw.get("rule", "basedpyright"))
71
+ message = str(raw.get("message", ""))
72
+
73
+ line = 0
74
+ column = 0
75
+ end_line: int | None = None
76
+ end_column: int | None = None
77
+ range_data = raw.get("range")
78
+ if isinstance(range_data, dict):
79
+ start = range_data.get("start")
80
+ end = range_data.get("end")
81
+ if isinstance(start, dict):
82
+ line = int(start.get("line", 0)) + 1
83
+ column = int(start.get("character", 0)) + 1
84
+ if isinstance(end, dict):
85
+ end_line = int(end.get("line", 0)) + 1
86
+ end_column = int(end.get("character", 0)) + 1
87
+
88
+ diagnostics.append(
89
+ Diagnostic(
90
+ file_path=file_path,
91
+ line=line,
92
+ column=column,
93
+ severity=severity,
94
+ rule_id=f"basedpyright-{rule}",
95
+ tool="basedpyright",
96
+ category=Category.TYPE_SAFETY,
97
+ message=message,
98
+ end_line=end_line,
99
+ end_column=end_column,
100
+ )
101
+ )
102
+
103
+ return diagnostics
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from python_checkup.analysis_request import AnalysisRequest
8
+ from python_checkup.cache import AnalysisCache
9
+ from python_checkup.models import Category, Diagnostic
10
+
11
+ logger = logging.getLogger("python_checkup")
12
+
13
+
14
+ class CachedAnalyzer:
15
+ """Wraps any Analyzer with per-file caching.
16
+
17
+ This is transparent to consumers -- it has the same interface
18
+ as the underlying analyzer. The cache lookup happens per-file,
19
+ so editing one file in a 1000-file project only re-analyzes
20
+ that single file.
21
+ """
22
+
23
+ def __init__(self, analyzer: Any, cache: AnalysisCache) -> None:
24
+ self._analyzer = analyzer
25
+ self._cache = cache
26
+
27
+ @property
28
+ def name(self) -> str:
29
+ return self._analyzer.name # type: ignore[no-any-return]
30
+
31
+ @property
32
+ def category(self) -> Category:
33
+ return self._analyzer.category # type: ignore[no-any-return]
34
+
35
+ async def is_available(self) -> bool:
36
+ return await self._analyzer.is_available() # type: ignore[no-any-return]
37
+
38
+ async def analyze(
39
+ self,
40
+ request: AnalysisRequest,
41
+ ) -> list[Diagnostic]:
42
+ """Analyze files, using cache for unchanged files.
43
+
44
+ 1. For each file, check if we have cached results
45
+ 2. Collect all cache misses into a batch
46
+ 3. Run the underlying analyzer only on missed files
47
+ 4. Cache the new results per-file
48
+ 5. Return all results (cached + fresh)
49
+ """
50
+ files = request.files
51
+ cached_results: list[Diagnostic] = []
52
+ uncached_files = []
53
+
54
+ for f in files:
55
+ cached = self._cache.get(self._analyzer.name, f)
56
+ if cached is not None:
57
+ cached_results.extend(cached)
58
+ else:
59
+ uncached_files.append(f)
60
+
61
+ if not uncached_files:
62
+ logger.debug(
63
+ "%s: all %d files cached",
64
+ self._analyzer.name,
65
+ len(files),
66
+ )
67
+ return cached_results
68
+
69
+ logger.debug(
70
+ "%s: %d/%d files need analysis",
71
+ self._analyzer.name,
72
+ len(uncached_files),
73
+ len(files),
74
+ )
75
+
76
+ # Run analyzer only on uncached files
77
+ uncached_request = AnalysisRequest(
78
+ project_root=request.project_root,
79
+ files=uncached_files,
80
+ config=request.config,
81
+ categories=set(request.categories),
82
+ profile=request.profile,
83
+ framework=request.framework,
84
+ diff_base=request.diff_base,
85
+ quiet=request.quiet,
86
+ no_cache=request.no_cache,
87
+ metadata=dict(request.metadata),
88
+ )
89
+ new_results: list[Diagnostic] = await self._analyzer.analyze(uncached_request)
90
+
91
+ # Group results by file for per-file caching
92
+ results_by_file: dict[Path, list[Diagnostic]] = {f: [] for f in uncached_files}
93
+ for d in new_results:
94
+ if d.file_path in results_by_file:
95
+ results_by_file[d.file_path].append(d)
96
+
97
+ # Cache each file's results (including empty lists for clean files)
98
+ for file_path, file_diagnostics in results_by_file.items():
99
+ self._cache.set(
100
+ self._analyzer.name,
101
+ file_path,
102
+ file_diagnostics,
103
+ )
104
+
105
+ cached_results.extend(new_results)
106
+ return cached_results