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
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"[![{alt}]({url})]({repo_url})"
29
+ return f"![{alt}]({url})"
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)