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.
Files changed (63) hide show
  1. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/PKG-INFO +27 -3
  2. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/README.md +24 -0
  3. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/__init__.py +1 -1
  4. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/analysis/health_score.py +48 -5
  5. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/cache/cache_manager.py +8 -1
  6. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/cli/main.py +107 -23
  7. pydoctor_cli-2.2.0/pydoctor/core/__init__.py +17 -0
  8. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/core/analyzer.py +67 -1
  9. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/core/project.py +105 -78
  10. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/reports/json_formatter.py +44 -15
  11. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/reports/table_formatter.py +7 -2
  12. pydoctor_cli-2.2.0/pydoctor/scanners/ci_scanner.py +319 -0
  13. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/scanners/dependency_scanner.py +54 -9
  14. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/scanners/env_scanner.py +30 -12
  15. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/scanners/outdated_package_scanner.py +40 -14
  16. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/scanners/unused_package_scanner.py +57 -8
  17. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/scanners/vulnerability_scanner.py +10 -2
  18. pydoctor_cli-2.2.0/pydoctor/security/cvss.py +77 -0
  19. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/security/osv_client.py +140 -61
  20. pydoctor_cli-2.2.0/pydoctor/security/secrets.py +296 -0
  21. pydoctor_cli-2.2.0/pydoctor/utils/env_probe.py +145 -0
  22. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/utils/file_utils.py +4 -2
  23. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/utils/parser_utils.py +24 -14
  24. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/utils/pip_utils.py +40 -29
  25. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/PKG-INFO +27 -3
  26. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/SOURCES.txt +6 -3
  27. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pyproject.toml +3 -3
  28. pydoctor_cli-2.2.0/tests/test_ci_scanner.py +196 -0
  29. pydoctor_cli-2.2.0/tests/test_cli_ux.py +72 -0
  30. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/tests/test_env_scanner.py +27 -14
  31. pydoctor_cli-2.2.0/tests/test_environment.py +197 -0
  32. pydoctor_cli-2.2.0/tests/test_osv_parsing.py +181 -0
  33. pydoctor_cli-2.2.0/tests/test_report_output.py +72 -0
  34. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/tests/test_unused_scanner.py +19 -0
  35. pydoctor_cli-2.1.0/pydoctor/core/__init__.py +0 -7
  36. pydoctor_cli-2.1.0/pydoctor/performance/__init__.py +0 -5
  37. pydoctor_cli-2.1.0/pydoctor/performance/executor.py +0 -132
  38. pydoctor_cli-2.1.0/pydoctor/performance/parallel.py +0 -40
  39. pydoctor_cli-2.1.0/pydoctor/scanners/ci_scanner.py +0 -141
  40. pydoctor_cli-2.1.0/tests/test_ci_scanner.py +0 -39
  41. pydoctor_cli-2.1.0/tests/test_cli_ux.py +0 -38
  42. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/LICENSE +0 -0
  43. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/analysis/__init__.py +0 -0
  44. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/cache/__init__.py +0 -0
  45. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/cli/__init__.py +0 -0
  46. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/config/__init__.py +0 -0
  47. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/config/settings.py +0 -0
  48. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/core/report.py +0 -0
  49. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/reports/__init__.py +0 -0
  50. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/reports/terminal_colors.py +0 -0
  51. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/scanners/__init__.py +0 -0
  52. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/security/__init__.py +0 -0
  53. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/utils/__init__.py +0 -0
  54. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor/utils/subprocess_utils.py +0 -0
  55. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/dependency_links.txt +0 -0
  56. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/entry_points.txt +0 -0
  57. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/requires.txt +0 -0
  58. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/pydoctor_cli.egg-info/top_level.txt +0 -0
  59. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/setup.cfg +0 -0
  60. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/tests/conftest.py +0 -0
  61. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/tests/test_dependency_scanner.py +0 -0
  62. {pydoctor_cli-2.1.0 → pydoctor_cli-2.2.0}/tests/test_fix_command.py +0 -0
  63. {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.1.0
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.vercel.app
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.vercel.app/docs
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
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/pydoctor-cli?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/pydoctor-cli)
256
257
  [![CI Status](https://github.com/iamAgbaCoder/pydoctor-cli/actions/workflows/publish.yml/badge.svg)](https://github.com/iamAgbaCoder/pydoctor-cli/actions)
257
258
  [![PyPI version](https://img.shields.io/pypi/v/pydoctor-cli.svg?color=blue&style=flat-square)](https://pypi.org/project/pydoctor-cli/)
258
259
  [![Python versions](https://img.shields.io/pypi/pyversions/pydoctor-cli.svg?style=flat-square)](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
+ [![PyPI Downloads](https://static.pepy.tech/personalized-badge/pydoctor-cli?period=total&units=INTERNATIONAL_SYSTEM&left_color=GREY&right_color=GREEN&left_text=downloads)](https://pepy.tech/projects/pydoctor-cli)
7
8
  [![CI Status](https://github.com/iamAgbaCoder/pydoctor-cli/actions/workflows/publish.yml/badge.svg)](https://github.com/iamAgbaCoder/pydoctor-cli/actions)
8
9
  [![PyPI version](https://img.shields.io/pypi/v/pydoctor-cli.svg?color=blue&style=flat-square)](https://pypi.org/project/pydoctor-cli/)
9
10
  [![Python versions](https://img.shields.io/pypi/pyversions/pydoctor-cli.svg?style=flat-square)](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.
@@ -9,6 +9,6 @@ Author: Favour Bamgboye
9
9
  License: MIT
10
10
  """
11
11
 
12
- __version__ = "2.1.0"
12
+ __version__ = "2.2.0"
13
13
  __author__ = "Favour Bamgboye"
14
14
  __description__ = "Python environment diagnostic CLI — your project's doctor."
@@ -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
- return _get_verdict(score, categories)
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
- score = 100
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
- score -= _calculate_issue_penalty(issue)
34
- return max(0, score)
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.vercel.app/docs[/]
88
- [label]Version:[/][info] v2.1.0 (Premium Kernel Edition)[/]
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(project_path=path, scanners=scanners, verbose=verbose)
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("[warning]⚠ No virtual environment detected in this project.[/]")
225
- if not Confirm.ask("Do you want to proceed using global system packages?"):
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, verbose=verbose, no_cache=no_cache, as_json=json, command="diagnose"
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(path=path, ci_mode=ci, verbose=verbose, as_json=json, command="check")
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
- """🐳 Diagnose Python issues inside **Docker** containers."""
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("\n[info]ℹ This feature is in beta. Full container introspection coming soon.[/]")
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
- """🐙 Integrate with **GitHub** for repository-wide health scans."""
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(path=path, ci_mode=True, verbose=verbose, as_json=json, command="github")
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"], path=path, verbose=verbose, as_json=json, command="scan-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"], path=path, verbose=verbose, as_json=json, command="scan-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"], path=path, verbose=verbose, as_json=json, command="scan-deps"
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"], path=path, verbose=verbose, as_json=json, command="check-env"
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 = ProjectContext.from_path(path)
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 get_installed_packages(python_executable=ctx.project_python).items():
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: