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