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
python_checkup/config.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import tomllib
|
|
7
|
+
|
|
8
|
+
from python_checkup.models import Category
|
|
9
|
+
|
|
10
|
+
DEFAULT_WEIGHTS: dict[str, int] = {
|
|
11
|
+
"quality": 25,
|
|
12
|
+
"types": 20,
|
|
13
|
+
"security": 20,
|
|
14
|
+
"complexity": 15,
|
|
15
|
+
"dead_code": 10,
|
|
16
|
+
"dependencies": 10,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
DEFAULT_THRESHOLDS: dict[str, int] = {
|
|
20
|
+
"healthy": 75,
|
|
21
|
+
"needs_work": 50,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# Map config weight keys to Category enum
|
|
25
|
+
WEIGHT_KEY_TO_CATEGORY: dict[str, Category] = {
|
|
26
|
+
"quality": Category.QUALITY,
|
|
27
|
+
"types": Category.TYPE_SAFETY,
|
|
28
|
+
"security": Category.SECURITY,
|
|
29
|
+
"complexity": Category.COMPLEXITY,
|
|
30
|
+
"dead_code": Category.DEAD_CODE,
|
|
31
|
+
"dependencies": Category.DEPENDENCIES,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class CheckupConfig:
|
|
37
|
+
"""Resolved configuration for a python-checkup run."""
|
|
38
|
+
|
|
39
|
+
weights: dict[str, int] = field(default_factory=lambda: dict(DEFAULT_WEIGHTS))
|
|
40
|
+
thresholds: dict[str, int] = field(default_factory=lambda: dict(DEFAULT_THRESHOLDS))
|
|
41
|
+
ignore_rules: list[str] = field(default_factory=list)
|
|
42
|
+
ignore_files: list[str] = field(default_factory=list)
|
|
43
|
+
timeout: int = 60 # seconds, per analyzer
|
|
44
|
+
min_confidence: int = 80 # Vulture confidence threshold
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_config(project_root: Path) -> CheckupConfig:
|
|
48
|
+
"""Load [tool.python-checkup] from pyproject.toml, with defaults.
|
|
49
|
+
|
|
50
|
+
Config discovery: looks for pyproject.toml in project_root,
|
|
51
|
+
then walks up parent directories (matching Ruff/mypy behavior).
|
|
52
|
+
"""
|
|
53
|
+
config_path = _find_pyproject(project_root)
|
|
54
|
+
if config_path is None:
|
|
55
|
+
return CheckupConfig()
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
with open(config_path, "rb") as f:
|
|
59
|
+
data = tomllib.load(f)
|
|
60
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
61
|
+
return CheckupConfig()
|
|
62
|
+
|
|
63
|
+
user_config = data.get("tool", {}).get("python-checkup", {})
|
|
64
|
+
if not user_config:
|
|
65
|
+
return CheckupConfig()
|
|
66
|
+
|
|
67
|
+
return CheckupConfig(
|
|
68
|
+
weights=user_config.get("weights", dict(DEFAULT_WEIGHTS)),
|
|
69
|
+
thresholds=user_config.get("thresholds", dict(DEFAULT_THRESHOLDS)),
|
|
70
|
+
ignore_rules=user_config.get("ignore", {}).get("rules", []),
|
|
71
|
+
ignore_files=user_config.get("ignore", {}).get("files", []),
|
|
72
|
+
timeout=user_config.get("timeout", 60),
|
|
73
|
+
min_confidence=user_config.get("min_confidence", 80),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _find_pyproject(start: Path) -> Path | None:
|
|
78
|
+
"""Walk up from start directory to find pyproject.toml."""
|
|
79
|
+
current = start.resolve()
|
|
80
|
+
while True:
|
|
81
|
+
candidate = current / "pyproject.toml"
|
|
82
|
+
if candidate.is_file():
|
|
83
|
+
return candidate
|
|
84
|
+
parent = current.parent
|
|
85
|
+
if parent == current:
|
|
86
|
+
return None
|
|
87
|
+
current = parent
|
python_checkup/dedup.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from python_checkup.models import Diagnostic
|
|
6
|
+
|
|
7
|
+
# Mapping of Ruff S-prefix rules to their Bandit B-prefix equivalents.
|
|
8
|
+
RUFF_TO_BANDIT: dict[str, str] = {
|
|
9
|
+
"S101": "B101",
|
|
10
|
+
"S102": "B102",
|
|
11
|
+
"S103": "B103",
|
|
12
|
+
"S104": "B104",
|
|
13
|
+
"S105": "B105",
|
|
14
|
+
"S106": "B106",
|
|
15
|
+
"S107": "B107",
|
|
16
|
+
"S108": "B108",
|
|
17
|
+
"S110": "B110",
|
|
18
|
+
"S112": "B112",
|
|
19
|
+
"S113": "B113",
|
|
20
|
+
"S301": "B301",
|
|
21
|
+
"S302": "B302",
|
|
22
|
+
"S303": "B303",
|
|
23
|
+
"S304": "B304",
|
|
24
|
+
"S305": "B305",
|
|
25
|
+
"S306": "B306",
|
|
26
|
+
"S307": "B307",
|
|
27
|
+
"S308": "B308",
|
|
28
|
+
"S310": "B310",
|
|
29
|
+
"S311": "B311",
|
|
30
|
+
"S312": "B312",
|
|
31
|
+
"S313": "B313",
|
|
32
|
+
"S314": "B314",
|
|
33
|
+
"S315": "B315",
|
|
34
|
+
"S316": "B316",
|
|
35
|
+
"S317": "B317",
|
|
36
|
+
"S318": "B318",
|
|
37
|
+
"S319": "B319",
|
|
38
|
+
"S320": "B320",
|
|
39
|
+
"S321": "B321",
|
|
40
|
+
"S323": "B323",
|
|
41
|
+
"S324": "B324",
|
|
42
|
+
"S501": "B501",
|
|
43
|
+
"S502": "B502",
|
|
44
|
+
"S503": "B503",
|
|
45
|
+
"S504": "B504",
|
|
46
|
+
"S505": "B505",
|
|
47
|
+
"S506": "B506",
|
|
48
|
+
"S507": "B507",
|
|
49
|
+
"S508": "B508",
|
|
50
|
+
"S509": "B509",
|
|
51
|
+
"S601": "B601",
|
|
52
|
+
"S602": "B602",
|
|
53
|
+
"S603": "B603",
|
|
54
|
+
"S604": "B604",
|
|
55
|
+
"S605": "B605",
|
|
56
|
+
"S606": "B606",
|
|
57
|
+
"S607": "B607",
|
|
58
|
+
"S608": "B608",
|
|
59
|
+
"S609": "B609",
|
|
60
|
+
"S610": "B610",
|
|
61
|
+
"S611": "B611",
|
|
62
|
+
"S612": "B612",
|
|
63
|
+
"S701": "B701",
|
|
64
|
+
"S702": "B702",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
BANDIT_TO_RUFF: dict[str, str] = {v: k for k, v in RUFF_TO_BANDIT.items()}
|
|
68
|
+
|
|
69
|
+
# Tool priority order: lower index = higher priority
|
|
70
|
+
TOOL_PRIORITY: dict[str, int] = {
|
|
71
|
+
"ruff": 0,
|
|
72
|
+
"bandit": 1,
|
|
73
|
+
"mypy": 2,
|
|
74
|
+
"radon": 3,
|
|
75
|
+
"vulture": 4,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def deduplicate(diagnostics: list[Diagnostic]) -> list[Diagnostic]:
|
|
80
|
+
"""Remove duplicate findings from overlapping tools.
|
|
81
|
+
|
|
82
|
+
Rules:
|
|
83
|
+
1. Ruff takes priority over Bandit for overlapping rules
|
|
84
|
+
2. Same tool, same file, same line, same rule = deduplicate
|
|
85
|
+
3. Bandit-unique rules (not in BANDIT_TO_RUFF) are never deduplicated
|
|
86
|
+
|
|
87
|
+
Dedup key: (file_path, line, normalized_rule_id)
|
|
88
|
+
"""
|
|
89
|
+
# Sort by tool priority -- Ruff findings are processed first
|
|
90
|
+
prioritized = sorted(
|
|
91
|
+
diagnostics,
|
|
92
|
+
key=lambda d: TOOL_PRIORITY.get(d.tool, 99),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
seen: set[tuple[Path, int, str]] = set()
|
|
96
|
+
result: list[Diagnostic] = []
|
|
97
|
+
|
|
98
|
+
for d in prioritized:
|
|
99
|
+
# Normalize the rule ID so S101 and B101 map to the same key
|
|
100
|
+
normalized = _normalize_rule(d.tool, d.rule_id)
|
|
101
|
+
key = (d.file_path, d.line, normalized)
|
|
102
|
+
|
|
103
|
+
if key not in seen:
|
|
104
|
+
seen.add(key)
|
|
105
|
+
result.append(d)
|
|
106
|
+
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _normalize_rule(tool: str, rule_id: str) -> str:
|
|
111
|
+
"""Normalize a rule ID for deduplication.
|
|
112
|
+
|
|
113
|
+
Maps Bandit B-prefix rules to their Ruff S-prefix equivalents
|
|
114
|
+
so that S101 and B101 produce the same dedup key.
|
|
115
|
+
Bandit-unique rules keep their original ID.
|
|
116
|
+
"""
|
|
117
|
+
if tool == "bandit" and rule_id in BANDIT_TO_RUFF:
|
|
118
|
+
return BANDIT_TO_RUFF[rule_id]
|
|
119
|
+
return rule_id
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import tomllib
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class LockedPackage:
|
|
12
|
+
"""A concrete package version found in a dependency source."""
|
|
13
|
+
|
|
14
|
+
name: str
|
|
15
|
+
version: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class DependencySource:
|
|
20
|
+
"""A project dependency source, preferably lockfile-backed."""
|
|
21
|
+
|
|
22
|
+
kind: str
|
|
23
|
+
path: Path
|
|
24
|
+
is_locked: bool
|
|
25
|
+
packages: list[LockedPackage]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def discover_dependency_source(project_root: Path) -> DependencySource | None:
|
|
29
|
+
"""Discover the strongest available dependency source for a project.
|
|
30
|
+
|
|
31
|
+
Priority: uv.lock > poetry.lock > Pipfile.lock > requirements.txt > pyproject.toml
|
|
32
|
+
"""
|
|
33
|
+
uv_lock = project_root / "uv.lock"
|
|
34
|
+
if uv_lock.is_file():
|
|
35
|
+
packages = _parse_uv_lock(uv_lock)
|
|
36
|
+
return DependencySource("uv-lock", uv_lock, True, packages)
|
|
37
|
+
|
|
38
|
+
poetry_lock = project_root / "poetry.lock"
|
|
39
|
+
if poetry_lock.is_file():
|
|
40
|
+
packages = _parse_poetry_lock(poetry_lock)
|
|
41
|
+
if packages:
|
|
42
|
+
return DependencySource("poetry-lock", poetry_lock, True, packages)
|
|
43
|
+
|
|
44
|
+
pipfile_lock = project_root / "Pipfile.lock"
|
|
45
|
+
if pipfile_lock.is_file():
|
|
46
|
+
packages = _parse_pipfile_lock(pipfile_lock)
|
|
47
|
+
if packages:
|
|
48
|
+
return DependencySource("pipfile-lock", pipfile_lock, True, packages)
|
|
49
|
+
|
|
50
|
+
requirements = project_root / "requirements.txt"
|
|
51
|
+
if requirements.is_file():
|
|
52
|
+
packages = _parse_requirements(requirements)
|
|
53
|
+
if packages:
|
|
54
|
+
return DependencySource("requirements", requirements, True, packages)
|
|
55
|
+
|
|
56
|
+
pyproject = project_root / "pyproject.toml"
|
|
57
|
+
if pyproject.is_file():
|
|
58
|
+
packages = _parse_pyproject_dependencies(pyproject)
|
|
59
|
+
if packages:
|
|
60
|
+
return DependencySource("pyproject", pyproject, False, packages)
|
|
61
|
+
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_uv_lock(path: Path) -> list[LockedPackage]:
|
|
66
|
+
with path.open("rb") as handle:
|
|
67
|
+
data = tomllib.load(handle)
|
|
68
|
+
|
|
69
|
+
packages: list[LockedPackage] = []
|
|
70
|
+
raw = data.get("package", [])
|
|
71
|
+
if not isinstance(raw, list):
|
|
72
|
+
return packages
|
|
73
|
+
|
|
74
|
+
for item in raw:
|
|
75
|
+
if not isinstance(item, dict):
|
|
76
|
+
continue
|
|
77
|
+
source = item.get("source")
|
|
78
|
+
if isinstance(source, dict) and source.get("virtual") == ".":
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
name = item.get("name")
|
|
82
|
+
version = item.get("version")
|
|
83
|
+
if isinstance(name, str) and isinstance(version, str):
|
|
84
|
+
packages.append(LockedPackage(name=name, version=version))
|
|
85
|
+
|
|
86
|
+
return packages
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _parse_requirements(path: Path) -> list[LockedPackage]:
|
|
90
|
+
packages: list[LockedPackage] = []
|
|
91
|
+
for line in path.read_text(errors="ignore").splitlines():
|
|
92
|
+
stripped = line.strip()
|
|
93
|
+
if not stripped or stripped.startswith("#"):
|
|
94
|
+
continue
|
|
95
|
+
if "==" not in stripped:
|
|
96
|
+
continue
|
|
97
|
+
name, version = stripped.split("==", 1)
|
|
98
|
+
name = name.strip()
|
|
99
|
+
version = version.strip()
|
|
100
|
+
if name and version:
|
|
101
|
+
packages.append(LockedPackage(name=name, version=version))
|
|
102
|
+
return packages
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_pyproject_dependencies(path: Path) -> list[LockedPackage]:
|
|
106
|
+
"""Parse direct dependencies from pyproject.toml when no lockfile exists."""
|
|
107
|
+
with path.open("rb") as handle:
|
|
108
|
+
data = tomllib.load(handle)
|
|
109
|
+
|
|
110
|
+
project = data.get("project", {})
|
|
111
|
+
if not isinstance(project, dict):
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
raw_dependencies = project.get("dependencies", [])
|
|
115
|
+
if not isinstance(raw_dependencies, list):
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
packages: list[LockedPackage] = []
|
|
119
|
+
for item in raw_dependencies:
|
|
120
|
+
if not isinstance(item, str):
|
|
121
|
+
continue
|
|
122
|
+
name = item.split(";", 1)[0].strip()
|
|
123
|
+
for separator in ("==", ">=", "<=", "~=", "!=", ">", "<"):
|
|
124
|
+
if separator in name:
|
|
125
|
+
pkg, version = name.split(separator, 1)
|
|
126
|
+
packages.append(
|
|
127
|
+
LockedPackage(name=pkg.strip(), version=version.strip())
|
|
128
|
+
)
|
|
129
|
+
break
|
|
130
|
+
else:
|
|
131
|
+
packages.append(LockedPackage(name=name, version=""))
|
|
132
|
+
|
|
133
|
+
return packages
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_poetry_lock(path: Path) -> list[LockedPackage]:
|
|
137
|
+
"""Parse packages from a poetry.lock file.
|
|
138
|
+
|
|
139
|
+
poetry.lock is TOML with ``[[package]]`` entries, each having
|
|
140
|
+
``name`` and ``version`` string fields.
|
|
141
|
+
"""
|
|
142
|
+
with path.open("rb") as handle:
|
|
143
|
+
data = tomllib.load(handle)
|
|
144
|
+
|
|
145
|
+
packages: list[LockedPackage] = []
|
|
146
|
+
raw = data.get("package", [])
|
|
147
|
+
if not isinstance(raw, list):
|
|
148
|
+
return packages
|
|
149
|
+
|
|
150
|
+
for item in raw:
|
|
151
|
+
if not isinstance(item, dict):
|
|
152
|
+
continue
|
|
153
|
+
name = item.get("name")
|
|
154
|
+
version = item.get("version")
|
|
155
|
+
if isinstance(name, str) and isinstance(version, str):
|
|
156
|
+
packages.append(LockedPackage(name=name, version=version))
|
|
157
|
+
|
|
158
|
+
return packages
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_pipfile_lock(path: Path) -> list[LockedPackage]:
|
|
162
|
+
"""Parse packages from a Pipfile.lock file.
|
|
163
|
+
|
|
164
|
+
Pipfile.lock is JSON with ``"default"`` and optional ``"develop"``
|
|
165
|
+
sections. Each key is a package name and the value contains a
|
|
166
|
+
``"version"`` field like ``"==2.31.0"``.
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
data = json.loads(path.read_text(errors="ignore"))
|
|
170
|
+
except (json.JSONDecodeError, OSError):
|
|
171
|
+
return []
|
|
172
|
+
|
|
173
|
+
if not isinstance(data, dict):
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
packages: list[LockedPackage] = []
|
|
177
|
+
for section in ("default", "develop"):
|
|
178
|
+
deps = data.get(section)
|
|
179
|
+
if not isinstance(deps, dict):
|
|
180
|
+
continue
|
|
181
|
+
for name, info in deps.items():
|
|
182
|
+
if not isinstance(info, dict):
|
|
183
|
+
continue
|
|
184
|
+
version_str = info.get("version", "")
|
|
185
|
+
if isinstance(version_str, str) and version_str.startswith("=="):
|
|
186
|
+
version = version_str[2:]
|
|
187
|
+
else:
|
|
188
|
+
version = str(version_str) if version_str else ""
|
|
189
|
+
if name and version:
|
|
190
|
+
packages.append(LockedPackage(name=name, version=version))
|
|
191
|
+
|
|
192
|
+
return packages
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("python_checkup")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class FrameworkInfo:
|
|
14
|
+
"""Detected framework with version and confidence."""
|
|
15
|
+
|
|
16
|
+
name: str # "django", "fastapi", "flask"
|
|
17
|
+
version: str | None
|
|
18
|
+
confidence: float # 0.0 - 1.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Directories to skip during file sampling and detection scans.
|
|
22
|
+
_EXCLUDED_DIRS = frozenset(
|
|
23
|
+
{
|
|
24
|
+
".venv",
|
|
25
|
+
"venv",
|
|
26
|
+
".env",
|
|
27
|
+
"__pycache__",
|
|
28
|
+
".git",
|
|
29
|
+
"node_modules",
|
|
30
|
+
".tox",
|
|
31
|
+
".mypy_cache",
|
|
32
|
+
".ruff_cache",
|
|
33
|
+
"site-packages",
|
|
34
|
+
".eggs",
|
|
35
|
+
"dist",
|
|
36
|
+
"build",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Confidence threshold — below this we don't activate framework rules.
|
|
41
|
+
_CONFIDENCE_THRESHOLD = 0.5
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def detect_framework(project_root: Path) -> FrameworkInfo | None:
|
|
45
|
+
"""Detect the primary web framework using multiple signals.
|
|
46
|
+
|
|
47
|
+
Signals are weighted by reliability:
|
|
48
|
+
- Installed package (strongest): +0.5
|
|
49
|
+
- Config files/patterns: +0.2-0.3
|
|
50
|
+
- Import analysis: +0.1
|
|
51
|
+
- Dependency declarations: +0.3
|
|
52
|
+
|
|
53
|
+
Returns None if no framework has confidence >= 0.5.
|
|
54
|
+
"""
|
|
55
|
+
candidates: dict[str, float] = {}
|
|
56
|
+
versions: dict[str, str | None] = {}
|
|
57
|
+
|
|
58
|
+
for name, detector in _FRAMEWORK_DETECTORS.items():
|
|
59
|
+
score, version = detector(project_root)
|
|
60
|
+
if score > 0:
|
|
61
|
+
candidates[name] = score
|
|
62
|
+
versions[name] = version
|
|
63
|
+
|
|
64
|
+
if not candidates:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
# Pick the highest-confidence framework
|
|
68
|
+
best = max(candidates, key=lambda k: candidates[k])
|
|
69
|
+
if candidates[best] < _CONFIDENCE_THRESHOLD:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
version = versions.get(best)
|
|
73
|
+
version_str = f"{best}-{version}" if version else best
|
|
74
|
+
logger.info(
|
|
75
|
+
"Detected framework: %s (confidence: %.1f)", version_str, candidates[best]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return FrameworkInfo(
|
|
79
|
+
name=best, version=version, confidence=min(candidates[best], 1.0)
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Individual framework detectors
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _detect_django(root: Path) -> tuple[float, str | None]:
|
|
87
|
+
score = 0.0
|
|
88
|
+
version: str | None = None
|
|
89
|
+
|
|
90
|
+
# Signal 1: Installed package (strongest — 0.5)
|
|
91
|
+
try:
|
|
92
|
+
version = importlib.metadata.version("django")
|
|
93
|
+
score += 0.5
|
|
94
|
+
except importlib.metadata.PackageNotFoundError:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
# Signal 2: manage.py exists (0.3)
|
|
98
|
+
if (root / "manage.py").exists():
|
|
99
|
+
score += 0.3
|
|
100
|
+
|
|
101
|
+
# Signal 3: settings.py pattern (0.1)
|
|
102
|
+
settings_files = [f for f in root.rglob("settings.py") if not _is_excluded(f, root)]
|
|
103
|
+
if settings_files:
|
|
104
|
+
score += 0.1
|
|
105
|
+
|
|
106
|
+
# Signal 4: DJANGO_SETTINGS_MODULE in any Python file (0.2)
|
|
107
|
+
for py_file in _sample_py_files(root, limit=20):
|
|
108
|
+
try:
|
|
109
|
+
content = py_file.read_text(errors="ignore")
|
|
110
|
+
if "DJANGO_SETTINGS_MODULE" in content:
|
|
111
|
+
score += 0.2
|
|
112
|
+
break
|
|
113
|
+
except OSError:
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
# Signal 5: In requirements/pyproject dependencies (0.3)
|
|
117
|
+
if _in_dependency_files(root, "django"):
|
|
118
|
+
score += 0.3
|
|
119
|
+
|
|
120
|
+
# Signal 6: Import analysis (sample files) (0.1)
|
|
121
|
+
if _has_imports(root, ["from django", "import django"]):
|
|
122
|
+
score += 0.1
|
|
123
|
+
|
|
124
|
+
return score, version
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _detect_fastapi(root: Path) -> tuple[float, str | None]:
|
|
128
|
+
score = 0.0
|
|
129
|
+
version: str | None = None
|
|
130
|
+
|
|
131
|
+
# Signal 1: Installed package (0.5)
|
|
132
|
+
try:
|
|
133
|
+
version = importlib.metadata.version("fastapi")
|
|
134
|
+
score += 0.5
|
|
135
|
+
except importlib.metadata.PackageNotFoundError:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
# Signal 2: uvicorn installed (common companion) (0.2)
|
|
139
|
+
try:
|
|
140
|
+
importlib.metadata.version("uvicorn")
|
|
141
|
+
score += 0.2
|
|
142
|
+
except importlib.metadata.PackageNotFoundError:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
# Signal 3: main.py/app.py with FastAPI() (0.3)
|
|
146
|
+
for name in ("main.py", "app.py", "api.py"):
|
|
147
|
+
f = root / name
|
|
148
|
+
if f.exists():
|
|
149
|
+
try:
|
|
150
|
+
content = f.read_text(errors="ignore")
|
|
151
|
+
if "FastAPI()" in content or "from fastapi" in content:
|
|
152
|
+
score += 0.3
|
|
153
|
+
break
|
|
154
|
+
except OSError:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
# Signal 4: In dependency files (0.3)
|
|
158
|
+
if _in_dependency_files(root, "fastapi"):
|
|
159
|
+
score += 0.3
|
|
160
|
+
|
|
161
|
+
# Signal 5: Import analysis (0.1)
|
|
162
|
+
if _has_imports(root, ["from fastapi", "import fastapi"]):
|
|
163
|
+
score += 0.1
|
|
164
|
+
|
|
165
|
+
return score, version
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _detect_flask(root: Path) -> tuple[float, str | None]:
|
|
169
|
+
score = 0.0
|
|
170
|
+
version: str | None = None
|
|
171
|
+
|
|
172
|
+
# Signal 1: Installed package (0.5)
|
|
173
|
+
try:
|
|
174
|
+
version = importlib.metadata.version("flask")
|
|
175
|
+
score += 0.5
|
|
176
|
+
except importlib.metadata.PackageNotFoundError:
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
# Signal 2: .flaskenv or .env with FLASK_APP (0.3)
|
|
180
|
+
for env_file in (".flaskenv", ".env"):
|
|
181
|
+
path = root / env_file
|
|
182
|
+
if path.exists():
|
|
183
|
+
try:
|
|
184
|
+
content = path.read_text(errors="ignore")
|
|
185
|
+
if "FLASK_APP" in content:
|
|
186
|
+
score += 0.3
|
|
187
|
+
break
|
|
188
|
+
except OSError:
|
|
189
|
+
continue
|
|
190
|
+
|
|
191
|
+
# Signal 3: app.py with Flask(__name__) (0.3)
|
|
192
|
+
for name in ("app.py", "application.py", "wsgi.py"):
|
|
193
|
+
f = root / name
|
|
194
|
+
if f.exists():
|
|
195
|
+
try:
|
|
196
|
+
content = f.read_text(errors="ignore")
|
|
197
|
+
if "Flask(__name__)" in content or "from flask" in content:
|
|
198
|
+
score += 0.3
|
|
199
|
+
break
|
|
200
|
+
except OSError:
|
|
201
|
+
continue
|
|
202
|
+
|
|
203
|
+
# Signal 4: In dependency files (0.3)
|
|
204
|
+
if _in_dependency_files(root, "flask"):
|
|
205
|
+
score += 0.3
|
|
206
|
+
|
|
207
|
+
# Signal 5: Import analysis (0.1)
|
|
208
|
+
if _has_imports(root, ["from flask", "import flask"]):
|
|
209
|
+
score += 0.1
|
|
210
|
+
|
|
211
|
+
return score, version
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# Detector registry
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
_FRAMEWORK_DETECTORS: dict[str, Callable[[Path], tuple[float, str | None]]] = {
|
|
218
|
+
"django": _detect_django,
|
|
219
|
+
"fastapi": _detect_fastapi,
|
|
220
|
+
"flask": _detect_flask,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# Helpers
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _is_excluded(path: Path, root: Path) -> bool:
|
|
228
|
+
try:
|
|
229
|
+
parts = path.relative_to(root).parts
|
|
230
|
+
except ValueError:
|
|
231
|
+
return True
|
|
232
|
+
return any(part in _EXCLUDED_DIRS for part in parts)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _sample_py_files(root: Path, limit: int = 20) -> list[Path]:
|
|
236
|
+
files: list[Path] = []
|
|
237
|
+
for py_file in root.rglob("*.py"):
|
|
238
|
+
if _is_excluded(py_file, root):
|
|
239
|
+
continue
|
|
240
|
+
files.append(py_file)
|
|
241
|
+
if len(files) >= limit:
|
|
242
|
+
break
|
|
243
|
+
return files
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _in_dependency_files(root: Path, package: str) -> bool:
|
|
247
|
+
"""Check if package appears in requirements.txt or pyproject.toml."""
|
|
248
|
+
pkg_lower = package.lower()
|
|
249
|
+
|
|
250
|
+
# requirements.txt variants
|
|
251
|
+
for req_name in (
|
|
252
|
+
"requirements.txt",
|
|
253
|
+
"requirements/base.txt",
|
|
254
|
+
"requirements/main.txt",
|
|
255
|
+
"requirements/prod.txt",
|
|
256
|
+
):
|
|
257
|
+
req_path = root / req_name
|
|
258
|
+
if req_path.exists():
|
|
259
|
+
try:
|
|
260
|
+
content = req_path.read_text(errors="ignore").lower()
|
|
261
|
+
if pkg_lower in content:
|
|
262
|
+
return True
|
|
263
|
+
except OSError:
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
# pyproject.toml
|
|
267
|
+
pyproject = root / "pyproject.toml"
|
|
268
|
+
if pyproject.exists():
|
|
269
|
+
try:
|
|
270
|
+
content = pyproject.read_text(errors="ignore").lower()
|
|
271
|
+
if f'"{pkg_lower}' in content or f"'{pkg_lower}" in content:
|
|
272
|
+
return True
|
|
273
|
+
except OSError:
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
# Pipfile
|
|
277
|
+
pipfile = root / "Pipfile"
|
|
278
|
+
if pipfile.exists():
|
|
279
|
+
try:
|
|
280
|
+
content = pipfile.read_text(errors="ignore").lower()
|
|
281
|
+
if pkg_lower in content:
|
|
282
|
+
return True
|
|
283
|
+
except OSError:
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _has_imports(root: Path, patterns: list[str]) -> bool:
|
|
290
|
+
for py_file in _sample_py_files(root, limit=20):
|
|
291
|
+
try:
|
|
292
|
+
content = py_file.read_text(errors="ignore")
|
|
293
|
+
for pattern in patterns:
|
|
294
|
+
if pattern in content:
|
|
295
|
+
return True
|
|
296
|
+
except OSError:
|
|
297
|
+
continue
|
|
298
|
+
return False
|