codedoctor 0.1.0__tar.gz
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-0.1.0/.github/dependabot.yml +12 -0
- codedoctor-0.1.0/.github/workflows/ci.yml +33 -0
- codedoctor-0.1.0/.github/workflows/release.yml +72 -0
- codedoctor-0.1.0/.gitignore +7 -0
- codedoctor-0.1.0/.pre-commit-config.yaml +12 -0
- codedoctor-0.1.0/LICENSE +21 -0
- codedoctor-0.1.0/PKG-INFO +167 -0
- codedoctor-0.1.0/README.md +150 -0
- codedoctor-0.1.0/pyproject.toml +39 -0
- codedoctor-0.1.0/src/codedoctor/__init__.py +0 -0
- codedoctor-0.1.0/src/codedoctor/cli.py +68 -0
- codedoctor-0.1.0/src/codedoctor/report.py +113 -0
- codedoctor-0.1.0/src/codedoctor/runner.py +107 -0
- codedoctor-0.1.0/src/codedoctor/storage.py +37 -0
- codedoctor-0.1.0/tests/test_smoke.py +7 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
push:
|
|
6
|
+
branches: ["main"]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v6
|
|
13
|
+
|
|
14
|
+
- uses: actions/setup-python@v6
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.12"
|
|
17
|
+
|
|
18
|
+
- name: Install
|
|
19
|
+
run: |
|
|
20
|
+
python -m pip install --upgrade pip
|
|
21
|
+
pip install -e ".[dev]"
|
|
22
|
+
|
|
23
|
+
- name: Ruff
|
|
24
|
+
run: ruff check .
|
|
25
|
+
|
|
26
|
+
- name: Black
|
|
27
|
+
run: black . --check
|
|
28
|
+
|
|
29
|
+
- name: MyPy
|
|
30
|
+
run: mypy src
|
|
31
|
+
|
|
32
|
+
- name: Pytest
|
|
33
|
+
run: pytest -q
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
name: Build distributions
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- name: Checkout
|
|
17
|
+
uses: actions/checkout@v6
|
|
18
|
+
|
|
19
|
+
- name: Set up Python
|
|
20
|
+
uses: actions/setup-python@v6
|
|
21
|
+
with:
|
|
22
|
+
python-version: "3.12"
|
|
23
|
+
|
|
24
|
+
- name: Install build tooling
|
|
25
|
+
run: |
|
|
26
|
+
python -m pip install --upgrade pip
|
|
27
|
+
python -m pip install build
|
|
28
|
+
|
|
29
|
+
- name: Build
|
|
30
|
+
run: |
|
|
31
|
+
python -m build
|
|
32
|
+
|
|
33
|
+
- name: Upload dist artifacts
|
|
34
|
+
uses: actions/upload-artifact@v4
|
|
35
|
+
with:
|
|
36
|
+
name: dist
|
|
37
|
+
path: dist/*
|
|
38
|
+
|
|
39
|
+
pypi:
|
|
40
|
+
name: Publish to PyPI (Trusted Publishing)
|
|
41
|
+
needs: build
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
environment: pypi
|
|
44
|
+
permissions:
|
|
45
|
+
id-token: write
|
|
46
|
+
steps:
|
|
47
|
+
- name: Download dist artifacts
|
|
48
|
+
uses: actions/download-artifact@v4
|
|
49
|
+
with:
|
|
50
|
+
name: dist
|
|
51
|
+
path: dist
|
|
52
|
+
|
|
53
|
+
- name: Publish
|
|
54
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
55
|
+
|
|
56
|
+
github-release:
|
|
57
|
+
name: Create GitHub Release + upload artifacts
|
|
58
|
+
needs: build
|
|
59
|
+
runs-on: ubuntu-latest
|
|
60
|
+
permissions:
|
|
61
|
+
contents: write
|
|
62
|
+
steps:
|
|
63
|
+
- name: Download dist artifacts
|
|
64
|
+
uses: actions/download-artifact@v4
|
|
65
|
+
with:
|
|
66
|
+
name: dist
|
|
67
|
+
path: dist
|
|
68
|
+
|
|
69
|
+
- name: Create GitHub Release
|
|
70
|
+
uses: softprops/action-gh-release@v2
|
|
71
|
+
with:
|
|
72
|
+
files: dist/*
|
codedoctor-0.1.0/LICENSE
ADDED
|
@@ -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.
|
|
@@ -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,150 @@
|
|
|
1
|
+
# CodeDoctor - v0.1.0
|
|
2
|
+
|
|
3
|
+
**CodeDoctor** is a beginner-friendly Python CLI that scans a repository and
|
|
4
|
+
summarizes common quality checks (linting, formatting, typing, security, and
|
|
5
|
+
tests) in one readable report.
|
|
6
|
+
|
|
7
|
+
It can also generate a **TL;DR summary** at the top of the report and save
|
|
8
|
+
results to a `.txt` report file so you can compare runs over time.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## What it checks
|
|
13
|
+
|
|
14
|
+
Depending on what you have installed, CodeDoctor can run:
|
|
15
|
+
|
|
16
|
+
- **ruff** — linting (and optional auto-fixes)
|
|
17
|
+
- **black** — formatting (and optional formatting changes)
|
|
18
|
+
- **mypy** — static type checking
|
|
19
|
+
- **bandit** — basic security checks
|
|
20
|
+
- **pytest** — test runner
|
|
21
|
+
|
|
22
|
+
If a tool is missing, CodeDoctor will report it clearly.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
### Option A: Install from PyPI (recommended once published)
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install codedoctor
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Option B: Install from source (for development)
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
git clone https://github.com/BigPattyOG/CodeDoctor.git
|
|
38
|
+
cd CodeDoctor
|
|
39
|
+
python -m venv .venv
|
|
40
|
+
# Windows:
|
|
41
|
+
.venv\Scripts\activate
|
|
42
|
+
# macOS/Linux:
|
|
43
|
+
source .venv/bin/activate
|
|
44
|
+
|
|
45
|
+
python -m pip install -U pip
|
|
46
|
+
python -m pip install -e .
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
To include developer tools (recommended for contributors):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
python -m pip install -e ".[dev]"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
From inside any repo you want to scan:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
codedoctor scan .
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
CodeDoctor prints a report to the terminal and also writes a report file under:
|
|
66
|
+
|
|
67
|
+
- `.codedoctor/report-latest.txt`
|
|
68
|
+
- `.codedoctor/report-prev.txt` (previous run)
|
|
69
|
+
- `.codedoctor/report-YYYYMMDD-HHMMSS.txt` (timestamped history)
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Example output (TL;DR)
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
CodeDoctor Report TL;DR
|
|
77
|
+
----------------------
|
|
78
|
+
Overall: WARN
|
|
79
|
+
Checks: 6 passed / 1 warned / 0 failed / 7 total
|
|
80
|
+
|
|
81
|
+
Warnings:
|
|
82
|
+
- pytest (tests)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Commands
|
|
88
|
+
|
|
89
|
+
### Scan a repo
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
codedoctor scan .
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Apply safe auto-fixes (where supported)
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
codedoctor scan . --fix
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
This may run tools like:
|
|
102
|
+
|
|
103
|
+
- `ruff check . --fix`
|
|
104
|
+
- `black .`
|
|
105
|
+
|
|
106
|
+
### Skip running tests
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
codedoctor scan . --skip-tests
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Exit codes (for CI)
|
|
115
|
+
|
|
116
|
+
CodeDoctor uses simple exit codes so it can be used in CI:
|
|
117
|
+
|
|
118
|
+
- **0** — all checks PASS
|
|
119
|
+
- **1** — at least one WARN, and no FAIL
|
|
120
|
+
- **2** — one or more FAIL
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
124
|
+
## Notes (Windows + pytest warnings)
|
|
125
|
+
|
|
126
|
+
On Windows, `pytest` may sometimes print messages like:
|
|
127
|
+
|
|
128
|
+
- `Exception ignored in atexit callback`
|
|
129
|
+
- `PermissionError: [WinError 5] Access is denied`
|
|
130
|
+
|
|
131
|
+
Even if tests pass, CodeDoctor may classify the result as **WARN** so the run is
|
|
132
|
+
not marked as perfectly clean.
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Contributing
|
|
137
|
+
|
|
138
|
+
PRs welcome. A typical workflow:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
python -m pip install -e ".[dev]"
|
|
142
|
+
pre-commit run --all-files
|
|
143
|
+
pytest -q
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24.0"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "codedoctor"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Beginner-friendly codebase doctor: lint, format, type-check, security and tests."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Patrick Faint", email = "pattymayo3@icloud.com" }]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=8.0.0",
|
|
18
|
+
"ruff>=0.8.0",
|
|
19
|
+
"black>=24.0.0",
|
|
20
|
+
"mypy>=1.10.0",
|
|
21
|
+
"bandit>=1.7.0",
|
|
22
|
+
"pre-commit>=3.7.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.scripts]
|
|
26
|
+
codedoctor = "codedoctor.cli:main"
|
|
27
|
+
|
|
28
|
+
[tool.ruff]
|
|
29
|
+
line-length = 88
|
|
30
|
+
target-version = "py312"
|
|
31
|
+
|
|
32
|
+
[tool.black]
|
|
33
|
+
line-length = 88
|
|
34
|
+
target-version = ["py312"]
|
|
35
|
+
|
|
36
|
+
[tool.mypy]
|
|
37
|
+
python_version = "3.12"
|
|
38
|
+
warn_return_any = true
|
|
39
|
+
warn_unused_configs = true
|
|
File without changes
|
|
@@ -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
|
|
@@ -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()
|
|
@@ -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)
|
|
@@ -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)
|