codedoctor 0.1.0__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.
codedoctor/__init__.py ADDED
File without changes
codedoctor/cli.py ADDED
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from codedoctor.runner import scan_repo
7
+ from codedoctor.storage import get_report_paths, rotate_latest_to_previous
8
+
9
+
10
+ def build_parser() -> argparse.ArgumentParser:
11
+ parser = argparse.ArgumentParser(
12
+ prog="codedoctor",
13
+ description="Beginner-friendly checks for Python repositories.",
14
+ )
15
+ subparsers = parser.add_subparsers(dest="command", required=True)
16
+
17
+ scan = subparsers.add_parser("scan", help="Scan a repository.")
18
+ scan.add_argument("path", nargs="?", default=".", help="Repo path (default: .)")
19
+ scan.add_argument(
20
+ "--fix", action="store_true", help="Apple safe auto_fixes (ruff --fix, black)."
21
+ )
22
+ scan.add_argument("--skip-tests", action="store_true", help="Skip running pytest.")
23
+ scan.add_argument(
24
+ "--report-dir",
25
+ default=".codedoctor",
26
+ help="Directory (relative to repo) to store reports.",
27
+ )
28
+
29
+ return parser
30
+
31
+
32
+ def main(argv: list[str] | None = None) -> int:
33
+ args = build_parser().parse_args(argv)
34
+ repo_path = Path(args.path).resolve()
35
+
36
+ if args.command == "scan":
37
+ report = scan_repo(
38
+ repo_path=repo_path,
39
+ apply_fixes=bool(args.fix),
40
+ skip_tests=bool(args.skip_tests),
41
+ )
42
+
43
+ report_root = repo_path / args.report_dir
44
+ paths = get_report_paths(repo_path=repo_path)
45
+
46
+ paths = paths.__class__(
47
+ directory=report_root,
48
+ latest=report_root / "report-latest.txt",
49
+ previous=report_root / "report-prev.txt",
50
+ timestamped=report_root / paths.timestamped.name,
51
+ )
52
+
53
+ report_root.mkdir(parents=True, exist_ok=True)
54
+ rotate_latest_to_previous(latest=paths.latest, previous=paths.previous)
55
+
56
+ text = report.to_full_text()
57
+ paths.latest.write_text(text, encoding="utf-8")
58
+ paths.timestamped.write_text(text, encoding="utf-8")
59
+
60
+ print(text)
61
+ print(f"\nWrote: {paths.latest}")
62
+ print(f"Wrote: {paths.timestamped}")
63
+ if paths.previous.exists():
64
+ print(f"Previous: {paths.previous}")
65
+
66
+ return report.exit_code
67
+
68
+ return 1
codedoctor/report.py ADDED
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+
7
+ class CheckStatus(str, Enum):
8
+ PASS = "PASS" # nosec B105
9
+ WARN = "WARN"
10
+ FAIL = "FAIL"
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class CheckResult:
15
+ name: str
16
+ command: list[str]
17
+ returncode: int
18
+ output: str
19
+ status: CheckStatus
20
+
21
+ @property
22
+ def ok(self) -> bool:
23
+ return self.status in {CheckStatus.PASS, CheckStatus.WARN}
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ScanReport:
28
+ repo: str
29
+ results: list[CheckResult]
30
+
31
+ @property
32
+ def ok(self) -> bool:
33
+ return all(r.ok for r in self.results)
34
+
35
+ @property
36
+ def has_failures(self) -> bool:
37
+ return any(r.status == CheckStatus.FAIL for r in self.results)
38
+
39
+ @property
40
+ def has_warnings(self) -> bool:
41
+ return any(r.status == CheckStatus.WARN for r in self.results)
42
+
43
+ @property
44
+ def overall_status(self) -> CheckStatus:
45
+ if self.has_failures:
46
+ return CheckStatus.FAIL
47
+ if self.has_warnings:
48
+ return CheckStatus.WARN
49
+ return CheckStatus.PASS
50
+
51
+ @property
52
+ def exit_code(self) -> int:
53
+ if self.has_failures:
54
+ return 2
55
+ if self.has_warnings:
56
+ return 1
57
+ return 0
58
+
59
+ def to_tldr(self) -> str:
60
+ total = len(self.results)
61
+ passed = sum(1 for r in self.results if r.status == CheckStatus.PASS)
62
+ warned = sum(1 for r in self.results if r.status == CheckStatus.WARN)
63
+ failed = sum(1 for r in self.results if r.status == CheckStatus.FAIL)
64
+
65
+ lines: list[str] = []
66
+ lines.append("CodeDoctor Report TL;DR")
67
+ lines.append("----------------------")
68
+ lines.append(f"Overall: {self.overall_status.value}")
69
+ lines.append(
70
+ f"Checks: {passed} passed / {warned} warned / {failed} failed / "
71
+ f"{total} total"
72
+ )
73
+ lines.append("")
74
+
75
+ if failed:
76
+ lines.append("Failures:")
77
+ for r in self.results:
78
+ if r.status == CheckStatus.FAIL:
79
+ lines.append(f" - {r.name}")
80
+ lines.append("")
81
+
82
+ if warned:
83
+ lines.append("Warnings:")
84
+ for r in self.results:
85
+ if r.status == CheckStatus.WARN:
86
+ lines.append(f" - {r.name}")
87
+ lines.append("")
88
+
89
+ return "\n".join(lines)
90
+
91
+ def to_full_text(self) -> str:
92
+ lines: list[str] = []
93
+ lines.append(self.to_tldr())
94
+ lines.append(f"Repository: {self.repo}")
95
+ lines.append("")
96
+ lines.append("Details")
97
+ lines.append("-------")
98
+ lines.append("")
99
+
100
+ for r in self.results:
101
+ lines.append(f"== {r.name} : {r.status.value} ==")
102
+ if r.command:
103
+ lines.append(f"$ {' '.join(r.command)}")
104
+ lines.append(r.output if r.output else "(no output)")
105
+ lines.append("")
106
+
107
+ lines.append("Next steps:")
108
+ lines.append(" - Re-run with safe auto-fixes: codedoctor scan . --fix")
109
+ lines.append("")
110
+ return "\n".join(lines)
111
+
112
+ def to_human_text(self) -> str:
113
+ return self.to_full_text()
codedoctor/runner.py ADDED
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess # nosec B404
5
+ from pathlib import Path
6
+
7
+ from codedoctor.report import CheckResult, CheckStatus, ScanReport
8
+
9
+
10
+ def tool_exists(tool: str) -> bool:
11
+ return shutil.which(tool) is not None
12
+
13
+
14
+ def classify_status(name: str, returncode: int, output: str) -> CheckStatus:
15
+ if returncode != 0:
16
+ return CheckStatus.FAIL
17
+
18
+ # Pytest can return 0 but still print nasty shutdown exceptions on Windows.
19
+ warning_signatures = [
20
+ "Exception ignored in atexit callback",
21
+ "PermissionError: [WinError 5]",
22
+ "Traceback (most recent call last):",
23
+ ]
24
+ if name.startswith("pytest") and any(s in output for s in warning_signatures):
25
+ return CheckStatus.WARN
26
+
27
+ return CheckStatus.PASS
28
+
29
+
30
+ def run_command(display_name: str, cmd: list[str], cwd: Path) -> CheckResult:
31
+ proc = subprocess.run( # nosec B603
32
+ cmd,
33
+ cwd=str(cwd),
34
+ capture_output=True,
35
+ text=True,
36
+ )
37
+ output = ((proc.stdout or "") + (proc.stderr or "")).strip()
38
+ status = classify_status(
39
+ name=display_name, returncode=proc.returncode, output=output
40
+ )
41
+
42
+ return CheckResult(
43
+ name=display_name,
44
+ command=cmd,
45
+ returncode=proc.returncode,
46
+ output=output,
47
+ status=status,
48
+ )
49
+
50
+
51
+ def build_checks(apply_fixes: bool, skip_tests: bool) -> list[tuple[str, list[str]]]:
52
+ checks: list[tuple[str, list[str]]] = []
53
+
54
+ # Ruff
55
+ if tool_exists("ruff"):
56
+ if apply_fixes:
57
+ checks.append(("ruff (auto-fix)", ["ruff", "check", ".", "--fix"]))
58
+ checks.append(("ruff (lint)", ["ruff", "check", "."]))
59
+ else:
60
+ checks.append(("ruff (missing)", []))
61
+
62
+ # Black
63
+ if tool_exists("black"):
64
+ if apply_fixes:
65
+ checks.append(("black (format)", ["black", "."]))
66
+ checks.append(("black (check)", ["black", ".", "--check"]))
67
+ else:
68
+ checks.append(("black (missing)", []))
69
+
70
+ # MyPy
71
+ if tool_exists("mypy"):
72
+ checks.append(("mypy (types)", ["mypy", "src"]))
73
+
74
+ # Bandit
75
+ if tool_exists("bandit"):
76
+ checks.append(("bandit (security)", ["bandit", "-r", "src"]))
77
+
78
+ # Pytest
79
+ if not skip_tests and tool_exists("pytest"):
80
+ checks.append(("pytest (tests)", ["pytest", "-q"]))
81
+
82
+ return checks
83
+
84
+
85
+ def scan_repo(repo_path: Path, apply_fixes: bool, skip_tests: bool) -> ScanReport:
86
+ results: list[CheckResult] = []
87
+
88
+ for name, cmd in build_checks(apply_fixes=apply_fixes, skip_tests=skip_tests):
89
+ if not cmd:
90
+ tool = name.split(" ", 1)[0]
91
+ results.append(
92
+ CheckResult(
93
+ name=name,
94
+ command=[],
95
+ returncode=127,
96
+ output=(
97
+ f"{tool} is not installed or not on PATH.\n"
98
+ f"Install it with: python -m pip install {tool}"
99
+ ),
100
+ status=CheckStatus.FAIL,
101
+ )
102
+ )
103
+ continue
104
+
105
+ results.append(run_command(display_name=name, cmd=cmd, cwd=repo_path))
106
+
107
+ return ScanReport(repo=str(repo_path), results=results)
codedoctor/storage.py ADDED
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ReportPaths:
10
+ directory: Path
11
+ latest: Path
12
+ previous: Path
13
+ timestamped: Path
14
+
15
+
16
+ def get_report_paths(repo_path: Path) -> ReportPaths:
17
+ report_dir = repo_path / ".codedoctor"
18
+ latest = report_dir / "report-latest.txt"
19
+ previous = report_dir / "report-prev.txt"
20
+
21
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
22
+ timestamped = report_dir / f"report-{ts}.txt"
23
+
24
+ return ReportPaths(
25
+ directory=report_dir,
26
+ latest=latest,
27
+ previous=previous,
28
+ timestamped=timestamped,
29
+ )
30
+
31
+
32
+ def rotate_latest_to_previous(latest: Path, previous: Path) -> None:
33
+ if latest.exists():
34
+ previous.parent.mkdir(parents=True, exist_ok=True)
35
+ if previous.exists():
36
+ previous.unlink()
37
+ latest.replace(previous)
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: codedoctor
3
+ Version: 0.1.0
4
+ Summary: Beginner-friendly codebase doctor: lint, format, type-check, security and tests.
5
+ Author-email: Patrick Faint <pattymayo3@icloud.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.12
9
+ Provides-Extra: dev
10
+ Requires-Dist: bandit>=1.7.0; extra == 'dev'
11
+ Requires-Dist: black>=24.0.0; extra == 'dev'
12
+ Requires-Dist: mypy>=1.10.0; extra == 'dev'
13
+ Requires-Dist: pre-commit>=3.7.0; extra == 'dev'
14
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
15
+ Requires-Dist: ruff>=0.8.0; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # CodeDoctor - v0.1.0
19
+
20
+ **CodeDoctor** is a beginner-friendly Python CLI that scans a repository and
21
+ summarizes common quality checks (linting, formatting, typing, security, and
22
+ tests) in one readable report.
23
+
24
+ It can also generate a **TL;DR summary** at the top of the report and save
25
+ results to a `.txt` report file so you can compare runs over time.
26
+
27
+ ---
28
+
29
+ ## What it checks
30
+
31
+ Depending on what you have installed, CodeDoctor can run:
32
+
33
+ - **ruff** — linting (and optional auto-fixes)
34
+ - **black** — formatting (and optional formatting changes)
35
+ - **mypy** — static type checking
36
+ - **bandit** — basic security checks
37
+ - **pytest** — test runner
38
+
39
+ If a tool is missing, CodeDoctor will report it clearly.
40
+
41
+ ---
42
+
43
+ ## Installation
44
+
45
+ ### Option A: Install from PyPI (recommended once published)
46
+
47
+ ```bash
48
+ pip install codedoctor
49
+ ```
50
+
51
+ ### Option B: Install from source (for development)
52
+
53
+ ```bash
54
+ git clone https://github.com/BigPattyOG/CodeDoctor.git
55
+ cd CodeDoctor
56
+ python -m venv .venv
57
+ # Windows:
58
+ .venv\Scripts\activate
59
+ # macOS/Linux:
60
+ source .venv/bin/activate
61
+
62
+ python -m pip install -U pip
63
+ python -m pip install -e .
64
+ ```
65
+
66
+ To include developer tools (recommended for contributors):
67
+
68
+ ```bash
69
+ python -m pip install -e ".[dev]"
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Quick start
75
+
76
+ From inside any repo you want to scan:
77
+
78
+ ```bash
79
+ codedoctor scan .
80
+ ```
81
+
82
+ CodeDoctor prints a report to the terminal and also writes a report file under:
83
+
84
+ - `.codedoctor/report-latest.txt`
85
+ - `.codedoctor/report-prev.txt` (previous run)
86
+ - `.codedoctor/report-YYYYMMDD-HHMMSS.txt` (timestamped history)
87
+
88
+ ---
89
+
90
+ ## Example output (TL;DR)
91
+
92
+ ```text
93
+ CodeDoctor Report TL;DR
94
+ ----------------------
95
+ Overall: WARN
96
+ Checks: 6 passed / 1 warned / 0 failed / 7 total
97
+
98
+ Warnings:
99
+ - pytest (tests)
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Commands
105
+
106
+ ### Scan a repo
107
+
108
+ ```bash
109
+ codedoctor scan .
110
+ ```
111
+
112
+ ### Apply safe auto-fixes (where supported)
113
+
114
+ ```bash
115
+ codedoctor scan . --fix
116
+ ```
117
+
118
+ This may run tools like:
119
+
120
+ - `ruff check . --fix`
121
+ - `black .`
122
+
123
+ ### Skip running tests
124
+
125
+ ```bash
126
+ codedoctor scan . --skip-tests
127
+ ```
128
+
129
+ ---
130
+
131
+ ## Exit codes (for CI)
132
+
133
+ CodeDoctor uses simple exit codes so it can be used in CI:
134
+
135
+ - **0** — all checks PASS
136
+ - **1** — at least one WARN, and no FAIL
137
+ - **2** — one or more FAIL
138
+
139
+ ---
140
+
141
+ ## Notes (Windows + pytest warnings)
142
+
143
+ On Windows, `pytest` may sometimes print messages like:
144
+
145
+ - `Exception ignored in atexit callback`
146
+ - `PermissionError: [WinError 5] Access is denied`
147
+
148
+ Even if tests pass, CodeDoctor may classify the result as **WARN** so the run is
149
+ not marked as perfectly clean.
150
+
151
+ ---
152
+
153
+ ## Contributing
154
+
155
+ PRs welcome. A typical workflow:
156
+
157
+ ```bash
158
+ python -m pip install -e ".[dev]"
159
+ pre-commit run --all-files
160
+ pytest -q
161
+ ```
162
+
163
+ ---
164
+
165
+ ## License
166
+
167
+ MIT — see [LICENSE](./LICENSE).
@@ -0,0 +1,10 @@
1
+ codedoctor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ codedoctor/cli.py,sha256=zeB9i7Rrz9Nzn2Hf7CzEFNca0UvjGni7duKyDpb2VPw,2190
3
+ codedoctor/report.py,sha256=dmiBrC3GsqbsS1BgWoW8Wyuihpvs70pMDxdnNpQ58aY,3163
4
+ codedoctor/runner.py,sha256=6aizE98Uey-4l_zcU8tv5WGnVRAa-_sh8VETGpiAeqI,3181
5
+ codedoctor/storage.py,sha256=slHvv0AtW6H70LVSyW0DM2PV_JNk79NpfvQ9DTQBNp0,953
6
+ codedoctor-0.1.0.dist-info/METADATA,sha256=BpgWLND7MEUBMG5wnUcfZXkrkbAkja5CkIVWzdZ2qzo,3290
7
+ codedoctor-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
8
+ codedoctor-0.1.0.dist-info/entry_points.txt,sha256=uSBQkck94mA7M3g9Ae9VYaUVlYpB15Vl4fLZ1G0SeAE,51
9
+ codedoctor-0.1.0.dist-info/licenses/LICENSE,sha256=ZzI8Mmsq793d2u4MNeZ17v5hPzfk4qT7nlH-Po_7c1s,1069
10
+ codedoctor-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ codedoctor = codedoctor.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Faint
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.