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/diff.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("python_checkup")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GitError(Exception):
|
|
11
|
+
"""Raised when a git command fails."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_changed_files(
|
|
15
|
+
project_root: Path,
|
|
16
|
+
*,
|
|
17
|
+
base: str = "main",
|
|
18
|
+
) -> list[Path]:
|
|
19
|
+
"""Get Python files changed between current branch and base.
|
|
20
|
+
|
|
21
|
+
Uses merge-base to find the common ancestor, then lists files
|
|
22
|
+
changed since that point. This matches what a PR would show.
|
|
23
|
+
|
|
24
|
+
Returns empty list if not in a git repo or if git commands fail.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
# Step 1: Verify we're in a git repo
|
|
28
|
+
_run_git(["rev-parse", "--git-dir"], cwd=project_root)
|
|
29
|
+
|
|
30
|
+
# Step 2: Get current branch name (for logging)
|
|
31
|
+
current_branch = _run_git(
|
|
32
|
+
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
33
|
+
cwd=project_root,
|
|
34
|
+
).strip()
|
|
35
|
+
logger.debug("Current branch: %s", current_branch)
|
|
36
|
+
|
|
37
|
+
# Step 3: Find merge-base (common ancestor)
|
|
38
|
+
try:
|
|
39
|
+
merge_base = _run_git(
|
|
40
|
+
["merge-base", base, "HEAD"],
|
|
41
|
+
cwd=project_root,
|
|
42
|
+
).strip()
|
|
43
|
+
logger.debug("Merge base with %s: %s", base, merge_base[:10])
|
|
44
|
+
except GitError:
|
|
45
|
+
logger.warning(
|
|
46
|
+
"Could not find merge-base with '%s'. Comparing directly against '%s'.",
|
|
47
|
+
base,
|
|
48
|
+
base,
|
|
49
|
+
)
|
|
50
|
+
merge_base = base
|
|
51
|
+
|
|
52
|
+
# Step 4: Get changed files (committed)
|
|
53
|
+
# --diff-filter=ACMR: Added, Copied, Modified, Renamed
|
|
54
|
+
output = _run_git(
|
|
55
|
+
[
|
|
56
|
+
"diff",
|
|
57
|
+
"--name-only",
|
|
58
|
+
"--diff-filter=ACMR",
|
|
59
|
+
"--relative",
|
|
60
|
+
merge_base,
|
|
61
|
+
],
|
|
62
|
+
cwd=project_root,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Also include uncommitted changes (staged + unstaged)
|
|
66
|
+
uncommitted = _run_git(
|
|
67
|
+
[
|
|
68
|
+
"diff",
|
|
69
|
+
"--name-only",
|
|
70
|
+
"--diff-filter=ACMR",
|
|
71
|
+
"--relative",
|
|
72
|
+
"HEAD",
|
|
73
|
+
],
|
|
74
|
+
cwd=project_root,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Combine and deduplicate
|
|
78
|
+
all_files = set(output.strip().split("\n")) | set(
|
|
79
|
+
uncommitted.strip().split("\n")
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Step 5: Filter to Python files that exist
|
|
83
|
+
python_files: list[Path] = []
|
|
84
|
+
for f in sorted(all_files):
|
|
85
|
+
f = f.strip()
|
|
86
|
+
if not f or not f.endswith(".py"):
|
|
87
|
+
continue
|
|
88
|
+
full_path = project_root / f
|
|
89
|
+
if full_path.is_file():
|
|
90
|
+
python_files.append(full_path)
|
|
91
|
+
|
|
92
|
+
logger.info(
|
|
93
|
+
"Found %d changed Python files (branch: %s, base: %s)",
|
|
94
|
+
len(python_files),
|
|
95
|
+
current_branch,
|
|
96
|
+
base,
|
|
97
|
+
)
|
|
98
|
+
return python_files
|
|
99
|
+
|
|
100
|
+
except GitError as e:
|
|
101
|
+
logger.warning("Git diff failed: %s. Falling back to full scan.", e)
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _run_git(args: list[str], cwd: Path) -> str:
|
|
106
|
+
"""Run a git command and return stdout.
|
|
107
|
+
|
|
108
|
+
Raises GitError if the command fails.
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
result = subprocess.run(
|
|
112
|
+
["git", *args],
|
|
113
|
+
cwd=cwd,
|
|
114
|
+
capture_output=True,
|
|
115
|
+
text=True,
|
|
116
|
+
timeout=10,
|
|
117
|
+
)
|
|
118
|
+
except FileNotFoundError as exc:
|
|
119
|
+
raise GitError("git is not installed or not on PATH") from exc
|
|
120
|
+
except subprocess.TimeoutExpired as exc:
|
|
121
|
+
raise GitError(f"git {args[0]} timed out") from exc
|
|
122
|
+
|
|
123
|
+
if result.returncode != 0:
|
|
124
|
+
raise GitError(
|
|
125
|
+
f"git {' '.join(args)} failed "
|
|
126
|
+
f"(exit {result.returncode}): "
|
|
127
|
+
f"{result.stderr.strip()}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return result.stdout
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import subprocess
|
|
5
|
+
from fnmatch import fnmatch
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("python_checkup")
|
|
9
|
+
|
|
10
|
+
# Directories always ignored, regardless of config.
|
|
11
|
+
DEFAULT_IGNORE_DIRS: frozenset[str] = frozenset(
|
|
12
|
+
{
|
|
13
|
+
".venv",
|
|
14
|
+
"venv",
|
|
15
|
+
".env",
|
|
16
|
+
"env",
|
|
17
|
+
"__pycache__",
|
|
18
|
+
".git",
|
|
19
|
+
"node_modules",
|
|
20
|
+
".tox",
|
|
21
|
+
".nox",
|
|
22
|
+
".mypy_cache",
|
|
23
|
+
".ruff_cache",
|
|
24
|
+
".pytest_cache",
|
|
25
|
+
".eggs",
|
|
26
|
+
"build",
|
|
27
|
+
"dist",
|
|
28
|
+
".hatch",
|
|
29
|
+
".pixi",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def discover_python_files(
|
|
35
|
+
root: Path,
|
|
36
|
+
ignore_patterns: list[str] | None = None,
|
|
37
|
+
) -> list[Path]:
|
|
38
|
+
"""Find all Python files in root, respecting .gitignore and config.
|
|
39
|
+
|
|
40
|
+
Strategy:
|
|
41
|
+
1. If inside a git repo, use `git ls-files` -- this automatically
|
|
42
|
+
respects .gitignore, .git/info/exclude, and global gitignore.
|
|
43
|
+
2. If not in a git repo, fall back to rglob with default ignores.
|
|
44
|
+
|
|
45
|
+
In both cases, apply user-configured ignore patterns on top.
|
|
46
|
+
"""
|
|
47
|
+
ignore_patterns = ignore_patterns or []
|
|
48
|
+
|
|
49
|
+
# Try git-based discovery first
|
|
50
|
+
files = _git_ls_files(root)
|
|
51
|
+
if files is None:
|
|
52
|
+
# Not a git repo -- fall back to rglob
|
|
53
|
+
files = _rglob_discovery(root)
|
|
54
|
+
|
|
55
|
+
if ignore_patterns:
|
|
56
|
+
files = _apply_ignore_patterns(files, root, ignore_patterns)
|
|
57
|
+
|
|
58
|
+
return sorted(files)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _git_ls_files(root: Path) -> list[Path] | None:
|
|
62
|
+
"""Use git ls-files to find tracked + untracked Python files.
|
|
63
|
+
|
|
64
|
+
Returns None if not in a git repo.
|
|
65
|
+
|
|
66
|
+
git ls-files --cached --others --exclude-standard gives us:
|
|
67
|
+
- All tracked files (--cached)
|
|
68
|
+
- Untracked files that aren't gitignored (--others --exclude-standard)
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
subprocess.run( # noqa: S603
|
|
72
|
+
["git", "rev-parse", "--git-dir"],
|
|
73
|
+
cwd=root,
|
|
74
|
+
capture_output=True,
|
|
75
|
+
check=True,
|
|
76
|
+
timeout=5,
|
|
77
|
+
)
|
|
78
|
+
except (
|
|
79
|
+
subprocess.CalledProcessError,
|
|
80
|
+
FileNotFoundError,
|
|
81
|
+
subprocess.TimeoutExpired,
|
|
82
|
+
):
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
result = subprocess.run( # noqa: S603
|
|
87
|
+
[
|
|
88
|
+
"git",
|
|
89
|
+
"ls-files",
|
|
90
|
+
"--cached",
|
|
91
|
+
"--others",
|
|
92
|
+
"--exclude-standard",
|
|
93
|
+
"-z",
|
|
94
|
+
"*.py",
|
|
95
|
+
],
|
|
96
|
+
cwd=root,
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
timeout=10,
|
|
100
|
+
)
|
|
101
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
if result.returncode != 0:
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
files: list[Path] = []
|
|
108
|
+
for entry in result.stdout.split("\0"):
|
|
109
|
+
entry = entry.strip()
|
|
110
|
+
if not entry:
|
|
111
|
+
continue
|
|
112
|
+
full_path = (root / entry).resolve()
|
|
113
|
+
if full_path.is_file():
|
|
114
|
+
files.append(full_path)
|
|
115
|
+
|
|
116
|
+
logger.debug("git ls-files found %d Python files", len(files))
|
|
117
|
+
return files
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _rglob_discovery(root: Path) -> list[Path]:
|
|
121
|
+
"""Fallback file discovery using rglob with default ignores."""
|
|
122
|
+
files: list[Path] = []
|
|
123
|
+
for py_file in root.rglob("*.py"):
|
|
124
|
+
try:
|
|
125
|
+
relative = py_file.relative_to(root)
|
|
126
|
+
except ValueError:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
parts = relative.parts
|
|
130
|
+
if any(part in DEFAULT_IGNORE_DIRS for part in parts):
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# Skip egg-info directories
|
|
134
|
+
if any(part.endswith(".egg-info") for part in parts):
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
files.append(py_file)
|
|
138
|
+
|
|
139
|
+
logger.debug("rglob found %d Python files", len(files))
|
|
140
|
+
return files
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _apply_ignore_patterns(
|
|
144
|
+
files: list[Path],
|
|
145
|
+
root: Path,
|
|
146
|
+
patterns: list[str],
|
|
147
|
+
) -> list[Path]:
|
|
148
|
+
"""Apply user-configured ignore patterns.
|
|
149
|
+
|
|
150
|
+
Supports simple glob patterns:
|
|
151
|
+
- "tests/**" -- ignore everything under tests/
|
|
152
|
+
- "*.generated.py" -- ignore files matching pattern
|
|
153
|
+
- "migrations/" -- ignore directory by name
|
|
154
|
+
"""
|
|
155
|
+
filtered: list[Path] = []
|
|
156
|
+
for f in files:
|
|
157
|
+
try:
|
|
158
|
+
relative = str(f.relative_to(root))
|
|
159
|
+
except ValueError:
|
|
160
|
+
relative = str(f)
|
|
161
|
+
|
|
162
|
+
if any(fnmatch(relative, pat) for pat in patterns):
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
# Also check directory components
|
|
166
|
+
try:
|
|
167
|
+
parts = f.relative_to(root).parts
|
|
168
|
+
except ValueError:
|
|
169
|
+
parts = ()
|
|
170
|
+
|
|
171
|
+
if any(fnmatch(part, pat.rstrip("/")) for part in parts for pat in patterns):
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
filtered.append(f)
|
|
175
|
+
|
|
176
|
+
skipped = len(files) - len(filtered)
|
|
177
|
+
if skipped > 0:
|
|
178
|
+
logger.debug("Ignore patterns filtered out %d files", skipped)
|
|
179
|
+
|
|
180
|
+
return filtered
|
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def generate_badge_url(score: int) -> str:
|
|
7
|
+
"""Generate a shields.io badge URL for the given score.
|
|
8
|
+
|
|
9
|
+
Example output:
|
|
10
|
+
https://img.shields.io/badge/py--checkup-87%2F100-brightgreen
|
|
11
|
+
"""
|
|
12
|
+
color = _badge_color(score)
|
|
13
|
+
# shields.io uses single hyphens as field separators (label-value-color),
|
|
14
|
+
# so literal hyphens in label/value must be doubled to "--"
|
|
15
|
+
label = "python--checkup"
|
|
16
|
+
value = quote(f"{score}/100")
|
|
17
|
+
return f"https://img.shields.io/badge/{label}-{value}-{color}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def generate_badge_markdown(score: int, repo_url: str = "") -> str:
|
|
21
|
+
"""Generate complete markdown for a README badge.
|
|
22
|
+
|
|
23
|
+
If repo_url is provided, the badge links to it.
|
|
24
|
+
"""
|
|
25
|
+
url = generate_badge_url(score)
|
|
26
|
+
alt = f"python-checkup: {score}/100"
|
|
27
|
+
if repo_url:
|
|
28
|
+
return f"[]({repo_url})"
|
|
29
|
+
return f""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _badge_color(score: int) -> str:
|
|
33
|
+
if score >= 75:
|
|
34
|
+
return "brightgreen"
|
|
35
|
+
elif score >= 50:
|
|
36
|
+
return "yellow"
|
|
37
|
+
else:
|
|
38
|
+
return "red"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from python_checkup.models import HealthReport
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def format_json(report: HealthReport) -> str:
|
|
12
|
+
data = asdict(report)
|
|
13
|
+
return json.dumps(data, indent=2, default=_json_serializer)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _json_serializer(obj: Any) -> Any:
|
|
17
|
+
if isinstance(obj, Path):
|
|
18
|
+
return str(obj)
|
|
19
|
+
if hasattr(obj, "value"): # Enum
|
|
20
|
+
return obj.value
|
|
21
|
+
msg = f"Object of type {type(obj)} is not JSON serializable"
|
|
22
|
+
raise TypeError(msg)
|