pydoctor-cli 2.1.0__tar.gz → 2.2.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.
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/PKG-INFO +27 -3
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/README.md +24 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/__init__.py +1 -1
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/analysis/health_score.py +48 -5
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/cache/cache_manager.py +8 -1
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/cli/main.py +107 -23
- pydoctor_cli-2.2.0/pydoctor/core/__init__.py +17 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/core/analyzer.py +67 -1
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/core/project.py +105 -78
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/reports/json_formatter.py +44 -15
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/reports/table_formatter.py +7 -2
- pydoctor_cli-2.2.0/pydoctor/scanners/ci_scanner.py +319 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/scanners/dependency_scanner.py +54 -9
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/scanners/env_scanner.py +30 -12
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/scanners/outdated_package_scanner.py +40 -14
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/scanners/unused_package_scanner.py +57 -8
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/scanners/vulnerability_scanner.py +10 -2
- pydoctor_cli-2.2.0/pydoctor/security/cvss.py +77 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/security/osv_client.py +140 -61
- pydoctor_cli-2.2.0/pydoctor/security/secrets.py +296 -0
- pydoctor_cli-2.2.0/pydoctor/utils/env_probe.py +145 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/utils/file_utils.py +4 -2
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/utils/parser_utils.py +24 -14
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/utils/pip_utils.py +40 -29
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/PKG-INFO +27 -3
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/SOURCES.txt +6 -3
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pyproject.toml +3 -3
- pydoctor_cli-2.2.0/tests/test_ci_scanner.py +196 -0
- pydoctor_cli-2.2.0/tests/test_cli_ux.py +72 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/tests/test_env_scanner.py +27 -14
- pydoctor_cli-2.2.0/tests/test_environment.py +197 -0
- pydoctor_cli-2.2.0/tests/test_osv_parsing.py +181 -0
- pydoctor_cli-2.2.0/tests/test_report_output.py +72 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/tests/test_unused_scanner.py +19 -0
- pydoctor_cli-2.1.0/pydoctor/core/__init__.py +0 -7
- pydoctor_cli-2.1.0/pydoctor/performance/__init__.py +0 -5
- pydoctor_cli-2.1.0/pydoctor/performance/executor.py +0 -132
- pydoctor_cli-2.1.0/pydoctor/performance/parallel.py +0 -40
- pydoctor_cli-2.1.0/pydoctor/scanners/ci_scanner.py +0 -141
- pydoctor_cli-2.1.0/tests/test_ci_scanner.py +0 -39
- pydoctor_cli-2.1.0/tests/test_cli_ux.py +0 -38
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/LICENSE +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/analysis/__init__.py +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/cache/__init__.py +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/cli/__init__.py +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/config/__init__.py +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/config/settings.py +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/core/report.py +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/reports/__init__.py +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/reports/terminal_colors.py +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/scanners/__init__.py +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/security/__init__.py +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/utils/__init__.py +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/utils/subprocess_utils.py +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/dependency_links.txt +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/entry_points.txt +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/requires.txt +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/top_level.txt +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/setup.cfg +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/tests/conftest.py +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/tests/test_dependency_scanner.py +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/tests/test_fix_command.py +0 -0
- {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/tests/test_vulnerability_scanner.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pydoctor-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Premium Python environment diagnostic CLI — your project's personal physician.
|
|
5
5
|
Author-email: Favour Bamgboye <demiladebamgboye@gmail.com>
|
|
6
6
|
Maintainer-email: Favour Bamgboye <demiladebamgboye@gmail.com>
|
|
@@ -206,9 +206,9 @@ License: Apache License
|
|
|
206
206
|
See the License for the specific language governing permissions and
|
|
207
207
|
limitations under the License.
|
|
208
208
|
|
|
209
|
-
Project-URL: Homepage, https://pydoctor.
|
|
209
|
+
Project-URL: Homepage, https://pydoctor-docs.thally.app
|
|
210
210
|
Project-URL: Repository, https://github.com/iamAgbaCoder/pydoctor-cli
|
|
211
|
-
Project-URL: Documentation, https://pydoctor.
|
|
211
|
+
Project-URL: Documentation, https://pydoctor-docs.thally.app
|
|
212
212
|
Project-URL: Bug Tracker, https://github.com/iamAgbaCoder/pydoctor-cli/issues
|
|
213
213
|
Project-URL: Changelog, https://github.com/iamAgbaCoder/pydoctor-cli/releases
|
|
214
214
|
Keywords: pydoctor,diagnosis,dependency-checker,vulnerability-scanner,python-health,cli-tool,security-audit,static-analysis
|
|
@@ -253,6 +253,7 @@ Dynamic: license-file
|
|
|
253
253
|
|
|
254
254
|
**PyDoctor** is a standalone, developer-friendly diagnostic assistant for Python environments. It acts like a personal physician for your code — scanning your project and environment to prescribe fixes for misconfigurations, dependencies, and security vulnerabilities.
|
|
255
255
|
|
|
256
|
+
[](https://pepy.tech/projects/pydoctor-cli)
|
|
256
257
|
[](https://github.com/iamAgbaCoder/pydoctor-cli/actions)
|
|
257
258
|
[](https://pypi.org/project/pydoctor-cli/)
|
|
258
259
|
[](https://pypi.org/project/pydoctor-cli/)
|
|
@@ -299,6 +300,16 @@ pipx install pydoctor-cli
|
|
|
299
300
|
pip install pydoctor-cli
|
|
300
301
|
```
|
|
301
302
|
|
|
303
|
+
> Install `pydoctor-cli`, not `pydoctor`: that is an unrelated project (Twisted's API doc generator) that installs a command with the same name.
|
|
304
|
+
|
|
305
|
+
PyDoctor scans your **project's** Python, wherever PyDoctor itself is installed. It uses the active virtualenv, or a `.venv`/`venv` in the project, and tells you which interpreter it chose. To choose one yourself:
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
pydoctor diagnose --python .venv/bin/python # or set PYDOCTOR_PYTHON
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
📖 Full documentation: **https://pydoctor-docs.thally.app**
|
|
312
|
+
|
|
302
313
|
---
|
|
303
314
|
|
|
304
315
|
## ⚡ Usage
|
|
@@ -323,6 +334,19 @@ pydoctor fix
|
|
|
323
334
|
|
|
324
335
|
## Release History
|
|
325
336
|
|
|
337
|
+
### v2.2.0
|
|
338
|
+
Results you can trust: every fix in this release removes a case where PyDoctor reported a problem as fine, or got a severity wrong.
|
|
339
|
+
|
|
340
|
+
- **Scans the right environment:** facts come from your project's interpreter, not PyDoctor's. Under pipx or `uv tool`, and in uv environments (which have no pip), 2.1.0 saw 0 packages and reported everything as OK. Works without pip (package metadata, `uv pip list --outdated`). New `--python` / `PYDOCTOR_PYTHON`; the report shows which interpreter was scanned and why.
|
|
341
|
+
- **Fails loudly:** `ENV_PACKAGES_UNAVAILABLE` (error) when the environment can't be read, `ENV_DEPS_NOT_INSTALLED` when the scanned Python has none of your dependencies, and outdated checks that can't run say so instead of "all up to date".
|
|
342
|
+
- **Virtualenv detection works:** it was always "active". The `diagnose` no-venv prompt now appears, and never blocks CI or piped runs.
|
|
343
|
+
- **Real CVSS scores:** severity is computed from the CVSS v3 vector. Previously a 3.1 (Low) and a 9.8 (Critical) vulnerability were both shown as critical. GitHub's `MODERATE` is now medium.
|
|
344
|
+
- **Right fix version:** for advisories with one fix per release line, `fix` targets your line (Django 5.1.2 → 5.1.4, not 4.2.17).
|
|
345
|
+
- **CI guard finds real leaks:** scans every committed text file, adds Stripe, OpenAI, Anthropic, Slack, Google, private-key, database-URL and `SECRET_KEY` rules, flags committed `.env` files, and checks workflows for `pull_request_target` "pwn requests", script injection and `write-all` permissions. Findings have line numbers; add `# pydoctor: ignore` to silence one. Zero findings on Flask, Django, requests, httpx, FastAPI, DRF and pip.
|
|
346
|
+
- **A score that moves:** a package's advisories count once, categories have diminishing returns, and any critical issue keeps the score below "Good". Expect different (usually higher) scores than 2.1.0.
|
|
347
|
+
- **JSON:** includes `health`; `scan-security --json` reports advisory IDs, aliases, fixed version and CVSS score.
|
|
348
|
+
- Smaller: database drivers, hashers and cache clients picked by Django settings count as used; faster advisory fetching; `--no-cache` no longer wipes the cache; circular import fixed; `github <repo>` and `docker` describe what they actually do.
|
|
349
|
+
|
|
326
350
|
### v2.1.0
|
|
327
351
|
- **`pydoctor fix` reports what really happened:** upgrades are verified against the installed version. If a pin in `pyproject.toml` blocks an upgrade, pydoctor says so and shows the constraint instead of printing "fixed".
|
|
328
352
|
- **One prompt per package:** vulnerable packages are offered once, with the advisory count, and upgraded to the lowest version that fixes every advisory.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**PyDoctor** is a standalone, developer-friendly diagnostic assistant for Python environments. It acts like a personal physician for your code — scanning your project and environment to prescribe fixes for misconfigurations, dependencies, and security vulnerabilities.
|
|
6
6
|
|
|
7
|
+
[](https://pepy.tech/projects/pydoctor-cli)
|
|
7
8
|
[](https://github.com/iamAgbaCoder/pydoctor-cli/actions)
|
|
8
9
|
[](https://pypi.org/project/pydoctor-cli/)
|
|
9
10
|
[](https://pypi.org/project/pydoctor-cli/)
|
|
@@ -50,6 +51,16 @@ pipx install pydoctor-cli
|
|
|
50
51
|
pip install pydoctor-cli
|
|
51
52
|
```
|
|
52
53
|
|
|
54
|
+
> Install `pydoctor-cli`, not `pydoctor`: that is an unrelated project (Twisted's API doc generator) that installs a command with the same name.
|
|
55
|
+
|
|
56
|
+
PyDoctor scans your **project's** Python, wherever PyDoctor itself is installed. It uses the active virtualenv, or a `.venv`/`venv` in the project, and tells you which interpreter it chose. To choose one yourself:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pydoctor diagnose --python .venv/bin/python # or set PYDOCTOR_PYTHON
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
📖 Full documentation: **https://pydoctor-docs.thally.app**
|
|
63
|
+
|
|
53
64
|
---
|
|
54
65
|
|
|
55
66
|
## ⚡ Usage
|
|
@@ -74,6 +85,19 @@ pydoctor fix
|
|
|
74
85
|
|
|
75
86
|
## Release History
|
|
76
87
|
|
|
88
|
+
### v2.2.0
|
|
89
|
+
Results you can trust: every fix in this release removes a case where PyDoctor reported a problem as fine, or got a severity wrong.
|
|
90
|
+
|
|
91
|
+
- **Scans the right environment:** facts come from your project's interpreter, not PyDoctor's. Under pipx or `uv tool`, and in uv environments (which have no pip), 2.1.0 saw 0 packages and reported everything as OK. Works without pip (package metadata, `uv pip list --outdated`). New `--python` / `PYDOCTOR_PYTHON`; the report shows which interpreter was scanned and why.
|
|
92
|
+
- **Fails loudly:** `ENV_PACKAGES_UNAVAILABLE` (error) when the environment can't be read, `ENV_DEPS_NOT_INSTALLED` when the scanned Python has none of your dependencies, and outdated checks that can't run say so instead of "all up to date".
|
|
93
|
+
- **Virtualenv detection works:** it was always "active". The `diagnose` no-venv prompt now appears, and never blocks CI or piped runs.
|
|
94
|
+
- **Real CVSS scores:** severity is computed from the CVSS v3 vector. Previously a 3.1 (Low) and a 9.8 (Critical) vulnerability were both shown as critical. GitHub's `MODERATE` is now medium.
|
|
95
|
+
- **Right fix version:** for advisories with one fix per release line, `fix` targets your line (Django 5.1.2 → 5.1.4, not 4.2.17).
|
|
96
|
+
- **CI guard finds real leaks:** scans every committed text file, adds Stripe, OpenAI, Anthropic, Slack, Google, private-key, database-URL and `SECRET_KEY` rules, flags committed `.env` files, and checks workflows for `pull_request_target` "pwn requests", script injection and `write-all` permissions. Findings have line numbers; add `# pydoctor: ignore` to silence one. Zero findings on Flask, Django, requests, httpx, FastAPI, DRF and pip.
|
|
97
|
+
- **A score that moves:** a package's advisories count once, categories have diminishing returns, and any critical issue keeps the score below "Good". Expect different (usually higher) scores than 2.1.0.
|
|
98
|
+
- **JSON:** includes `health`; `scan-security --json` reports advisory IDs, aliases, fixed version and CVSS score.
|
|
99
|
+
- Smaller: database drivers, hashers and cache clients picked by Django settings count as used; faster advisory fetching; `--no-cache` no longer wipes the cache; circular import fixed; `github <repo>` and `docker` describe what they actually do.
|
|
100
|
+
|
|
77
101
|
### v2.1.0
|
|
78
102
|
- **`pydoctor fix` reports what really happened:** upgrades are verified against the installed version. If a pin in `pyproject.toml` blocks an upgrade, pydoctor says so and shows the constraint instead of printing "fixed".
|
|
79
103
|
- **One prompt per package:** vulnerable packages are offered once, with the advisory count, and upgraded to the lowest version that fixes every advisory.
|
|
@@ -6,6 +6,7 @@ Calculates the numerical project health score and verdict.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import math
|
|
9
10
|
from dataclasses import dataclass
|
|
10
11
|
|
|
11
12
|
from pydoctor.core.report import DiagnosisReport, Issue
|
|
@@ -18,20 +19,62 @@ class HealthMetrics:
|
|
|
18
19
|
message: str
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
# Each category can cost at most this many points. Within a category every
|
|
23
|
+
# extra issue still lowers the score, but by less (diminishing returns), so
|
|
24
|
+
# fixing issues in a project with dozens of them visibly moves the score.
|
|
25
|
+
CATEGORY_CAPS: dict[str, float] = {
|
|
26
|
+
"security": 60,
|
|
27
|
+
"ci": 50,
|
|
28
|
+
"dependencies": 40,
|
|
29
|
+
"environment": 30,
|
|
30
|
+
"outdated": 15,
|
|
31
|
+
"unused": 10,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# A project with any critical issue can't be rated Good or Excellent.
|
|
35
|
+
CRITICAL_CEILING = 69
|
|
36
|
+
|
|
37
|
+
# Issues that mean part of the scan didn't run.
|
|
38
|
+
INCOMPLETE_CODES = {"ENV_PACKAGES_UNAVAILABLE", "SEC_SCAN_INCOMPLETE"}
|
|
39
|
+
|
|
40
|
+
|
|
21
41
|
def calculate_health(report: DiagnosisReport) -> HealthMetrics:
|
|
22
42
|
"""
|
|
23
43
|
Calculate project health score (0-100) based on identified issues.
|
|
24
44
|
"""
|
|
25
45
|
score = _compute_score(report)
|
|
26
|
-
categories = list({i.category for i in report.issues})
|
|
27
|
-
|
|
46
|
+
categories = list({i.category for i in report.issues if i.code not in INCOMPLETE_CODES})
|
|
47
|
+
metrics = _get_verdict(score, categories)
|
|
48
|
+
if any(i.code in INCOMPLETE_CODES for i in report.issues):
|
|
49
|
+
metrics.verdict = "Incomplete"
|
|
50
|
+
metrics.message = (
|
|
51
|
+
"Some checks couldn't run, so this score only covers what was checked. "
|
|
52
|
+
"See the errors above."
|
|
53
|
+
)
|
|
54
|
+
return metrics
|
|
28
55
|
|
|
29
56
|
|
|
30
57
|
def _compute_score(report: DiagnosisReport) -> int:
|
|
31
|
-
|
|
58
|
+
raw: dict[str, float] = {}
|
|
59
|
+
# One upgrade usually fixes every advisory for a package, so a package
|
|
60
|
+
# costs the penalty of its worst advisory, not one penalty per advisory.
|
|
61
|
+
per_package: dict[str, int] = {}
|
|
32
62
|
for issue in report.issues:
|
|
33
|
-
|
|
34
|
-
|
|
63
|
+
penalty = _calculate_issue_penalty(issue)
|
|
64
|
+
if issue.code == "SEC_VULNERABILITY" and issue.package:
|
|
65
|
+
per_package[issue.package] = max(per_package.get(issue.package, 0), penalty)
|
|
66
|
+
continue
|
|
67
|
+
raw[issue.category] = raw.get(issue.category, 0) + penalty
|
|
68
|
+
if per_package:
|
|
69
|
+
raw["security"] = raw.get("security", 0) + sum(per_package.values())
|
|
70
|
+
lost = 0.0
|
|
71
|
+
for category, points in raw.items():
|
|
72
|
+
cap = CATEGORY_CAPS.get(category)
|
|
73
|
+
lost += cap * (1 - math.exp(-points / cap)) if cap else points
|
|
74
|
+
score = max(0, round(100 - lost))
|
|
75
|
+
if any(i.severity.lower() == "critical" for i in report.issues):
|
|
76
|
+
score = min(score, CRITICAL_CEILING)
|
|
77
|
+
return score
|
|
35
78
|
|
|
36
79
|
|
|
37
80
|
def _calculate_issue_penalty(issue: Issue) -> int:
|
|
@@ -79,7 +79,7 @@ class CacheManager:
|
|
|
79
79
|
|
|
80
80
|
return entry.get("value")
|
|
81
81
|
|
|
82
|
-
def set(self, key: str, value: Any) -> None:
|
|
82
|
+
def set(self, key: str, value: Any, save: bool = True) -> None:
|
|
83
83
|
"""
|
|
84
84
|
Store a value in the cache with the current timestamp.
|
|
85
85
|
|
|
@@ -87,8 +87,15 @@ class CacheManager:
|
|
|
87
87
|
----------
|
|
88
88
|
key: Cache key.
|
|
89
89
|
value: Any JSON-serialisable value.
|
|
90
|
+
save: Write to disk now. Pass False when setting many keys, then call
|
|
91
|
+
``save()`` once (writing the whole file per key is quadratic).
|
|
90
92
|
"""
|
|
91
93
|
self._data[key] = {"_ts": time.time(), "value": value}
|
|
94
|
+
if save:
|
|
95
|
+
self._save()
|
|
96
|
+
|
|
97
|
+
def save(self) -> None:
|
|
98
|
+
"""Persist the cache to disk."""
|
|
92
99
|
self._save()
|
|
93
100
|
|
|
94
101
|
def invalidate(self, key: str) -> None:
|
|
@@ -84,8 +84,8 @@ _HELP_EPILOG = """
|
|
|
84
84
|
|
|
85
85
|
[section]🏥 COMMUNITY & SUPPORT[/]
|
|
86
86
|
|
|
87
|
-
[label]Docs:[/][pkg] https://pydoctor.
|
|
88
|
-
[label]Version:[/][info] v2.
|
|
87
|
+
[label]Docs:[/][pkg] https://pydoctor-docs.thally.app[/]
|
|
88
|
+
[label]Version:[/][info] v2.2.0 (Premium Kernel Edition)[/]
|
|
89
89
|
|
|
90
90
|
[kernel]Healthy code leads to healthy deployments. Keep your environment sterile.[/]
|
|
91
91
|
"""
|
|
@@ -122,6 +122,12 @@ _PATH_ARG = typer.Option(..., "--path", "-p", help="Project directory to scan.")
|
|
|
122
122
|
_JSON_ARG = typer.Option(..., "--json", "-j", help="Output results as JSON.")
|
|
123
123
|
_VERBOSE_ARG = typer.Option(..., "--verbose", "-v", help="Show detailed output and timing.")
|
|
124
124
|
_NO_CACHE_ARG = typer.Option(..., "--no-cache", help="Bypass the local vulnerability cache.")
|
|
125
|
+
_PYTHON_ARG = typer.Option(
|
|
126
|
+
...,
|
|
127
|
+
"--python",
|
|
128
|
+
help="Python interpreter of the project to scan (default: auto-detect). "
|
|
129
|
+
"Also settable with PYDOCTOR_PYTHON.",
|
|
130
|
+
)
|
|
125
131
|
|
|
126
132
|
|
|
127
133
|
# ──────────────────────────────────────────────────────────────
|
|
@@ -137,11 +143,10 @@ def _run_scan(
|
|
|
137
143
|
as_json: bool = False,
|
|
138
144
|
ci_mode: bool = False,
|
|
139
145
|
command: str | None = None,
|
|
146
|
+
python: str | None = None,
|
|
147
|
+
ctx: ProjectContext | None = None,
|
|
140
148
|
) -> DiagnosisReport: # noqa: C901
|
|
141
149
|
"""Run the Analyzer with Rich progress indicators."""
|
|
142
|
-
if no_cache:
|
|
143
|
-
CacheManager().clear()
|
|
144
|
-
|
|
145
150
|
if not as_json:
|
|
146
151
|
console.print("[section]Scanning project...[/]")
|
|
147
152
|
|
|
@@ -158,7 +163,14 @@ def _run_scan(
|
|
|
158
163
|
console.print(f"[kernel]»[/] Checking {label.lower().replace(' ', ' ')}")
|
|
159
164
|
|
|
160
165
|
if as_json:
|
|
161
|
-
analyzer = Analyzer(
|
|
166
|
+
analyzer = Analyzer(
|
|
167
|
+
project_path=path,
|
|
168
|
+
scanners=scanners,
|
|
169
|
+
verbose=verbose,
|
|
170
|
+
python=python,
|
|
171
|
+
ctx=ctx,
|
|
172
|
+
no_cache=no_cache,
|
|
173
|
+
)
|
|
162
174
|
report = analyzer.run(on_progress=None)
|
|
163
175
|
report.command = command
|
|
164
176
|
return report
|
|
@@ -175,6 +187,9 @@ def _run_scan(
|
|
|
175
187
|
project_path=path,
|
|
176
188
|
scanners=scanners,
|
|
177
189
|
verbose=verbose,
|
|
190
|
+
python=python,
|
|
191
|
+
ctx=ctx,
|
|
192
|
+
no_cache=no_cache,
|
|
178
193
|
)
|
|
179
194
|
report = analyzer.run(on_progress=progress_callback)
|
|
180
195
|
report.command = command
|
|
@@ -182,6 +197,14 @@ def _run_scan(
|
|
|
182
197
|
return report
|
|
183
198
|
|
|
184
199
|
|
|
200
|
+
def _is_interactive() -> bool:
|
|
201
|
+
"""True when a person can answer prompts (not CI, not piped input)."""
|
|
202
|
+
try:
|
|
203
|
+
return sys.stdin.isatty()
|
|
204
|
+
except (AttributeError, ValueError):
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
|
|
185
208
|
def _output(report: DiagnosisReport, as_json: bool, verbose: bool = False) -> None:
|
|
186
209
|
if as_json:
|
|
187
210
|
render_json(report)
|
|
@@ -213,23 +236,37 @@ def _exit_code(report: DiagnosisReport, path: str = ".") -> int:
|
|
|
213
236
|
@app.command(rich_help_panel="CORE COMMANDS")
|
|
214
237
|
def diagnose(
|
|
215
238
|
path: Annotated[str, _PATH_ARG] = ".",
|
|
239
|
+
python: Annotated[str | None, _PYTHON_ARG] = None,
|
|
216
240
|
json: Annotated[bool, _JSON_ARG] = False,
|
|
217
241
|
verbose: Annotated[bool, _VERBOSE_ARG] = False,
|
|
218
242
|
no_cache: Annotated[bool, _NO_CACHE_ARG] = False,
|
|
219
243
|
) -> None: # noqa: C901
|
|
220
244
|
"""🩺 Run a **full** diagnosis of your Python project."""
|
|
245
|
+
ctx = None
|
|
221
246
|
if not json:
|
|
222
|
-
ctx = ProjectContext.from_path(path)
|
|
223
|
-
if not ctx.in_virtualenv:
|
|
224
|
-
console.print(
|
|
225
|
-
|
|
247
|
+
ctx = ProjectContext.from_path(path, python=python)
|
|
248
|
+
if not ctx.env_error and not ctx.in_virtualenv:
|
|
249
|
+
console.print(
|
|
250
|
+
"[warning]⚠ No virtual environment detected. Scanning "
|
|
251
|
+
f"{ctx.python_executable} ({ctx.python_source}).[/]"
|
|
252
|
+
)
|
|
253
|
+
# Never block a non-interactive run (CI, pipes) on a prompt.
|
|
254
|
+
if _is_interactive() and not Confirm.ask(
|
|
255
|
+
"Do you want to proceed using global system packages?"
|
|
256
|
+
):
|
|
226
257
|
console.print(
|
|
227
258
|
"\n[info]ℹ Cancelled. Please activate a virtual environment and try again.[/]"
|
|
228
259
|
)
|
|
229
260
|
raise typer.Exit()
|
|
230
261
|
|
|
231
262
|
report = _run_scan(
|
|
232
|
-
path=path,
|
|
263
|
+
path=path,
|
|
264
|
+
verbose=verbose,
|
|
265
|
+
no_cache=no_cache,
|
|
266
|
+
as_json=json,
|
|
267
|
+
command="diagnose",
|
|
268
|
+
python=python,
|
|
269
|
+
ctx=ctx,
|
|
233
270
|
)
|
|
234
271
|
_output(report, as_json=json, verbose=verbose)
|
|
235
272
|
raise typer.Exit(code=_exit_code(report, path))
|
|
@@ -238,12 +275,15 @@ def diagnose(
|
|
|
238
275
|
@app.command(rich_help_panel="CORE COMMANDS")
|
|
239
276
|
def check(
|
|
240
277
|
path: Annotated[str, _PATH_ARG] = ".",
|
|
278
|
+
python: Annotated[str | None, _PYTHON_ARG] = None,
|
|
241
279
|
ci: bool = typer.Option(False, "--ci", help="Run in CI/CD guard mode."),
|
|
242
280
|
json: Annotated[bool, _JSON_ARG] = False,
|
|
243
281
|
verbose: Annotated[bool, _VERBOSE_ARG] = False,
|
|
244
282
|
) -> None:
|
|
245
283
|
"""🛡️ Perform security and health checks. Use --ci for pipeline shielding."""
|
|
246
|
-
report = _run_scan(
|
|
284
|
+
report = _run_scan(
|
|
285
|
+
path=path, ci_mode=ci, verbose=verbose, as_json=json, command="check", python=python
|
|
286
|
+
)
|
|
247
287
|
_output(report, as_json=json, verbose=verbose)
|
|
248
288
|
raise typer.Exit(code=_exit_code(report, path))
|
|
249
289
|
|
|
@@ -253,7 +293,7 @@ def docker(
|
|
|
253
293
|
image: Annotated[str | None, typer.Argument(help="Docker image to scan (optional).")] = None,
|
|
254
294
|
path: Annotated[str, _PATH_ARG] = ".",
|
|
255
295
|
) -> None:
|
|
256
|
-
"""🐳
|
|
296
|
+
"""🐳 [Preview] Check that a Dockerfile exists and uses a Python base image."""
|
|
257
297
|
console.print(Panel("[section]🐳 Docker Environment Doctor[/]", border_style="rule"))
|
|
258
298
|
console.print("[kernel]Scanning for Dockerfile and container runtime...[/]")
|
|
259
299
|
dockerfile = Path(path) / "Dockerfile"
|
|
@@ -268,17 +308,26 @@ def docker(
|
|
|
268
308
|
)
|
|
269
309
|
else:
|
|
270
310
|
console.print("[ok]✔[/] Python base image identified.")
|
|
271
|
-
console.print(
|
|
311
|
+
console.print(
|
|
312
|
+
"\n[info]ℹ Preview: this only checks the Dockerfile. It doesn't inspect images or "
|
|
313
|
+
"containers yet.[/]"
|
|
314
|
+
)
|
|
272
315
|
|
|
273
316
|
|
|
274
317
|
@app.command(rich_help_panel="SECURITY & CI/CD")
|
|
275
318
|
def github(
|
|
276
319
|
repo: Annotated[str | None, typer.Argument(help="GitHub repository URL (optional).")] = None,
|
|
277
320
|
path: Annotated[str, _PATH_ARG] = ".",
|
|
321
|
+
python: Annotated[str | None, _PYTHON_ARG] = None,
|
|
278
322
|
json: Annotated[bool, _JSON_ARG] = False,
|
|
279
323
|
verbose: Annotated[bool, _VERBOSE_ARG] = False,
|
|
280
324
|
) -> None:
|
|
281
|
-
"""🐙
|
|
325
|
+
"""🐙 Run the CI/CD guard on a local git checkout."""
|
|
326
|
+
if repo:
|
|
327
|
+
err_console.print(
|
|
328
|
+
"[warning]⚠ Scanning remote repositories isn't supported yet; "
|
|
329
|
+
f"ignoring {repo} and scanning {path}.[/]"
|
|
330
|
+
)
|
|
282
331
|
if not json:
|
|
283
332
|
console.print(Panel("[section]🐙 GitHub Repository Doctor[/]", border_style="rule"))
|
|
284
333
|
if not repo:
|
|
@@ -295,7 +344,9 @@ def github(
|
|
|
295
344
|
console.print("[ok]✔[/] Local git repository detected.")
|
|
296
345
|
console.print("\n[info]ℹ Running pydoctor on the current branch...[/]")
|
|
297
346
|
|
|
298
|
-
report = _run_scan(
|
|
347
|
+
report = _run_scan(
|
|
348
|
+
path=path, ci_mode=True, verbose=verbose, as_json=json, command="github", python=python
|
|
349
|
+
)
|
|
299
350
|
_output(report, as_json=json, verbose=verbose)
|
|
300
351
|
raise typer.Exit(code=_exit_code(report, path))
|
|
301
352
|
|
|
@@ -306,12 +357,18 @@ def github(
|
|
|
306
357
|
@app.command(name="scan-security", rich_help_panel="SECURITY & CI/CD")
|
|
307
358
|
def scan_security(
|
|
308
359
|
path: Annotated[str, _PATH_ARG] = ".",
|
|
360
|
+
python: Annotated[str | None, _PYTHON_ARG] = None,
|
|
309
361
|
json: Annotated[bool, _JSON_ARG] = False,
|
|
310
362
|
verbose: Annotated[bool, _VERBOSE_ARG] = False,
|
|
311
363
|
) -> None:
|
|
312
364
|
"""🔒 Scan installed packages for known vulnerabilities (OSV)."""
|
|
313
365
|
report = _run_scan(
|
|
314
|
-
scanners=["security"],
|
|
366
|
+
scanners=["security"],
|
|
367
|
+
path=path,
|
|
368
|
+
verbose=verbose,
|
|
369
|
+
as_json=json,
|
|
370
|
+
command="scan-security",
|
|
371
|
+
python=python,
|
|
315
372
|
)
|
|
316
373
|
_output(report, as_json=json, verbose=verbose)
|
|
317
374
|
raise typer.Exit(code=_exit_code(report, path))
|
|
@@ -325,12 +382,18 @@ def scan_security(
|
|
|
325
382
|
@app.command(name="scan-unused", rich_help_panel="RESOURCE HYGIENE")
|
|
326
383
|
def scan_unused(
|
|
327
384
|
path: Annotated[str, _PATH_ARG] = ".",
|
|
385
|
+
python: Annotated[str | None, _PYTHON_ARG] = None,
|
|
328
386
|
json: Annotated[bool, _JSON_ARG] = False,
|
|
329
387
|
verbose: Annotated[bool, _VERBOSE_ARG] = False,
|
|
330
388
|
) -> None:
|
|
331
389
|
"""🧹 Detect unused packages via AST analysis."""
|
|
332
390
|
report = _run_scan(
|
|
333
|
-
scanners=["unused"],
|
|
391
|
+
scanners=["unused"],
|
|
392
|
+
path=path,
|
|
393
|
+
verbose=verbose,
|
|
394
|
+
as_json=json,
|
|
395
|
+
command="scan-unused",
|
|
396
|
+
python=python,
|
|
334
397
|
)
|
|
335
398
|
_output(report, as_json=json, verbose=verbose)
|
|
336
399
|
raise typer.Exit(code=_exit_code(report, path))
|
|
@@ -339,12 +402,18 @@ def scan_unused(
|
|
|
339
402
|
@app.command(name="scan-deps", rich_help_panel="RESOURCE HYGIENE")
|
|
340
403
|
def scan_deps(
|
|
341
404
|
path: Annotated[str, _PATH_ARG] = ".",
|
|
405
|
+
python: Annotated[str | None, _PYTHON_ARG] = None,
|
|
342
406
|
json: Annotated[bool, _JSON_ARG] = False,
|
|
343
407
|
verbose: Annotated[bool, _VERBOSE_ARG] = False,
|
|
344
408
|
) -> None:
|
|
345
409
|
"""📦 Scan for dependency version conflicts."""
|
|
346
410
|
report = _run_scan(
|
|
347
|
-
scanners=["dependencies"],
|
|
411
|
+
scanners=["dependencies"],
|
|
412
|
+
path=path,
|
|
413
|
+
verbose=verbose,
|
|
414
|
+
as_json=json,
|
|
415
|
+
command="scan-deps",
|
|
416
|
+
python=python,
|
|
348
417
|
)
|
|
349
418
|
_output(report, as_json=json, verbose=verbose)
|
|
350
419
|
raise typer.Exit(code=_exit_code(report, path))
|
|
@@ -353,12 +422,18 @@ def scan_deps(
|
|
|
353
422
|
@app.command(name="check-env", rich_help_panel="RESOURCE HYGIENE")
|
|
354
423
|
def check_env(
|
|
355
424
|
path: Annotated[str, _PATH_ARG] = ".",
|
|
425
|
+
python: Annotated[str | None, _PYTHON_ARG] = None,
|
|
356
426
|
json: Annotated[bool, _JSON_ARG] = False,
|
|
357
427
|
verbose: Annotated[bool, _VERBOSE_ARG] = False,
|
|
358
428
|
) -> None:
|
|
359
429
|
"""🌍 Verify Python and virtual environment health."""
|
|
360
430
|
report = _run_scan(
|
|
361
|
-
scanners=["environment"],
|
|
431
|
+
scanners=["environment"],
|
|
432
|
+
path=path,
|
|
433
|
+
verbose=verbose,
|
|
434
|
+
as_json=json,
|
|
435
|
+
command="check-env",
|
|
436
|
+
python=python,
|
|
362
437
|
)
|
|
363
438
|
_output(report, as_json=json, verbose=verbose)
|
|
364
439
|
raise typer.Exit(code=_exit_code(report, path))
|
|
@@ -373,6 +448,7 @@ def fix(
|
|
|
373
448
|
list[str] | None, typer.Argument(help="Specific packages to fix (optional).")
|
|
374
449
|
] = None,
|
|
375
450
|
path: Annotated[str, _PATH_ARG] = ".",
|
|
451
|
+
python: Annotated[str | None, _PYTHON_ARG] = None,
|
|
376
452
|
safe: bool = typer.Option(True, "--safe/--no-safe", help="Confirm each action."),
|
|
377
453
|
upgrade: bool = typer.Option(True, "--upgrade/--no-upgrade", help="Upgrade outdated."),
|
|
378
454
|
remove: bool = typer.Option(False, "--remove/--no-remove", help="Remove unused."),
|
|
@@ -383,8 +459,8 @@ def fix(
|
|
|
383
459
|
"[section]🔧 PyDoctor Auto-Fix[/]\n\nScanning for fixable issues …", border_style="rule"
|
|
384
460
|
)
|
|
385
461
|
)
|
|
386
|
-
report_data = _run_scan(path=path)
|
|
387
|
-
ctx =
|
|
462
|
+
report_data = _run_scan(path=path, python=python)
|
|
463
|
+
ctx = report_data.ctx # reuse the scan's context instead of probing again
|
|
388
464
|
if packages:
|
|
389
465
|
_filter_issues_by_targets(report_data, packages)
|
|
390
466
|
actions = 0
|
|
@@ -463,8 +539,16 @@ def _norm(name: str) -> str:
|
|
|
463
539
|
|
|
464
540
|
def _installed_version(ctx, pkg: str) -> str | None:
|
|
465
541
|
"""Return the version of ``pkg`` installed in the project's environment."""
|
|
542
|
+
from pydoctor.utils.env_probe import probe_environment
|
|
543
|
+
|
|
544
|
+
probe, _ = probe_environment(ctx.project_python)
|
|
545
|
+
packages = (
|
|
546
|
+
probe.packages
|
|
547
|
+
if probe
|
|
548
|
+
else get_installed_packages(python_executable=ctx.project_python) # fallback: pip
|
|
549
|
+
)
|
|
466
550
|
target = _norm(pkg)
|
|
467
|
-
for name, version in
|
|
551
|
+
for name, version in packages.items():
|
|
468
552
|
if _norm(name) == target:
|
|
469
553
|
return version
|
|
470
554
|
return None
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""pydoctor/core/__init__.py"""
|
|
2
|
+
|
|
3
|
+
from pydoctor.core.project import ProjectContext
|
|
4
|
+
from pydoctor.core.report import DiagnosisReport, Issue
|
|
5
|
+
|
|
6
|
+
__all__ = ["Issue", "DiagnosisReport", "ProjectContext", "Analyzer"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def __getattr__(name: str):
|
|
10
|
+
# Imported lazily: Analyzer imports every scanner, and scanners import
|
|
11
|
+
# pydoctor.core, so an eager import here made
|
|
12
|
+
# `import pydoctor.scanners.vulnerability_scanner` fail with a circular import.
|
|
13
|
+
if name == "Analyzer":
|
|
14
|
+
from pydoctor.core.analyzer import Analyzer
|
|
15
|
+
|
|
16
|
+
return Analyzer
|
|
17
|
+
raise AttributeError(name)
|
|
@@ -48,6 +48,64 @@ SCANNER_REGISTRY: dict[str, Callable[[ProjectContext], list[Issue]]] = {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
# Scanners that read the installed environment (everything except "ci").
|
|
52
|
+
_ENV_SCANNERS = {"environment", "dependencies", "outdated", "security", "unused"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def context_issues(ctx: ProjectContext, scanner_keys: list[str]) -> list[Issue]:
|
|
56
|
+
"""
|
|
57
|
+
Problems with the scan itself, reported once regardless of which scanners run.
|
|
58
|
+
|
|
59
|
+
These exist so that a scan which couldn't see the project's packages fails
|
|
60
|
+
loudly instead of returning a clean bill of health.
|
|
61
|
+
"""
|
|
62
|
+
if not _ENV_SCANNERS.intersection(scanner_keys):
|
|
63
|
+
return []
|
|
64
|
+
if ctx.env_error:
|
|
65
|
+
return [
|
|
66
|
+
Issue(
|
|
67
|
+
category="environment",
|
|
68
|
+
code="ENV_PACKAGES_UNAVAILABLE",
|
|
69
|
+
severity=Severity.ERROR,
|
|
70
|
+
title="Couldn't read the project's installed packages",
|
|
71
|
+
description=(
|
|
72
|
+
f"PyDoctor tried {ctx.project_python} ({ctx.python_source}) and got: "
|
|
73
|
+
f"{ctx.env_error}. Security, dependency, outdated and unused checks "
|
|
74
|
+
"were not run."
|
|
75
|
+
),
|
|
76
|
+
recommendation=(
|
|
77
|
+
"Activate the project's virtualenv, or point PyDoctor at its interpreter "
|
|
78
|
+
"with --python path/to/python (or PYDOCTOR_PYTHON)."
|
|
79
|
+
),
|
|
80
|
+
extra={"python": ctx.project_python, "source": ctx.python_source},
|
|
81
|
+
)
|
|
82
|
+
]
|
|
83
|
+
if ctx.declared_deps:
|
|
84
|
+
from pydoctor.utils.env_probe import normalise_name
|
|
85
|
+
|
|
86
|
+
missing = [d for d in ctx.declared_deps if normalise_name(d) not in ctx.installed_packages]
|
|
87
|
+
if len(missing) == len(ctx.declared_deps):
|
|
88
|
+
return [
|
|
89
|
+
Issue(
|
|
90
|
+
category="environment",
|
|
91
|
+
code="ENV_DEPS_NOT_INSTALLED",
|
|
92
|
+
severity=Severity.WARNING,
|
|
93
|
+
title="None of the project's dependencies are installed in the scanned Python",
|
|
94
|
+
description=(
|
|
95
|
+
f"Scanned {ctx.python_executable} ({ctx.python_source}), which has none "
|
|
96
|
+
f"of the {len(ctx.declared_deps)} declared dependencies installed. "
|
|
97
|
+
"Results describe that interpreter, not your project."
|
|
98
|
+
),
|
|
99
|
+
recommendation=(
|
|
100
|
+
"Install the dependencies, activate the project's virtualenv, or use "
|
|
101
|
+
"--python path/to/python."
|
|
102
|
+
),
|
|
103
|
+
extra={"python": ctx.project_python, "missing": sorted(missing)},
|
|
104
|
+
)
|
|
105
|
+
]
|
|
106
|
+
return []
|
|
107
|
+
|
|
108
|
+
|
|
51
109
|
# ──────────────────────────────────────────────────────────────
|
|
52
110
|
# Analyzer
|
|
53
111
|
# ──────────────────────────────────────────────────────────────
|
|
@@ -70,10 +128,16 @@ class Analyzer:
|
|
|
70
128
|
project_path: str = ".",
|
|
71
129
|
scanners: list[str] | None = None,
|
|
72
130
|
verbose: bool = False,
|
|
131
|
+
python: str | None = None,
|
|
132
|
+
ctx: ProjectContext | None = None,
|
|
133
|
+
no_cache: bool = False,
|
|
73
134
|
) -> None:
|
|
74
135
|
self._project_path = project_path
|
|
75
136
|
self._scanner_keys = scanners or list(SCANNER_REGISTRY.keys())
|
|
76
137
|
self._verbose = verbose
|
|
138
|
+
self._python = python
|
|
139
|
+
self._ctx = ctx
|
|
140
|
+
self._no_cache = no_cache
|
|
77
141
|
|
|
78
142
|
# ── Public API ─────────────────────────────────────────────
|
|
79
143
|
|
|
@@ -92,9 +156,11 @@ class Analyzer:
|
|
|
92
156
|
wall_start = time.perf_counter()
|
|
93
157
|
|
|
94
158
|
# Build project context once — shared across all scanners
|
|
95
|
-
ctx = ProjectContext.from_path(self._project_path)
|
|
159
|
+
ctx = self._ctx or ProjectContext.from_path(self._project_path, python=self._python)
|
|
160
|
+
ctx.no_cache = self._no_cache
|
|
96
161
|
report = DiagnosisReport(scan_path=str(ctx.root), ctx=ctx)
|
|
97
162
|
scanner_timings: dict[str, float] = {}
|
|
163
|
+
report.extend(context_issues(ctx, self._scanner_keys))
|
|
98
164
|
|
|
99
165
|
# Parallel execution for independence (each scanner gets its own thread)
|
|
100
166
|
with ThreadPoolExecutor(max_workers=min(MAX_WORKERS, len(self._scanner_keys))) as pool:
|