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
|
@@ -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,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
|