pydoctor-cli 2.2.0__tar.gz → 2.3.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.2.0 → pydoctor_cli-2.3.0}/PKG-INFO +48 -1
  2. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/README.md +47 -0
  3. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/__init__.py +1 -1
  4. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/cli/main.py +122 -15
  5. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/core/analyzer.py +1 -0
  6. pydoctor_cli-2.3.0/pydoctor/core/baseline.py +153 -0
  7. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/core/report.py +8 -0
  8. pydoctor_cli-2.3.0/pydoctor/reports/docs_links.py +52 -0
  9. pydoctor_cli-2.3.0/pydoctor/reports/fingerprint.py +57 -0
  10. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/reports/json_formatter.py +1 -0
  11. pydoctor_cli-2.3.0/pydoctor/reports/sarif_formatter.py +187 -0
  12. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/reports/table_formatter.py +21 -0
  13. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor_cli.egg-info/PKG-INFO +48 -1
  14. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor_cli.egg-info/SOURCES.txt +7 -0
  15. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor_cli.egg-info/top_level.txt +1 -0
  16. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pyproject.toml +1 -1
  17. pydoctor_cli-2.3.0/tests/test_baseline.py +165 -0
  18. pydoctor_cli-2.3.0/tests/test_github_action.py +86 -0
  19. pydoctor_cli-2.3.0/tests/test_sarif.py +117 -0
  20. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/LICENSE +0 -0
  21. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/analysis/__init__.py +0 -0
  22. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/analysis/health_score.py +0 -0
  23. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/cache/__init__.py +0 -0
  24. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/cache/cache_manager.py +0 -0
  25. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/cli/__init__.py +0 -0
  26. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/config/__init__.py +0 -0
  27. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/config/settings.py +0 -0
  28. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/core/__init__.py +0 -0
  29. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/core/project.py +0 -0
  30. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/reports/__init__.py +0 -0
  31. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/reports/terminal_colors.py +0 -0
  32. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/scanners/__init__.py +0 -0
  33. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/scanners/ci_scanner.py +0 -0
  34. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/scanners/dependency_scanner.py +0 -0
  35. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/scanners/env_scanner.py +0 -0
  36. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/scanners/outdated_package_scanner.py +0 -0
  37. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/scanners/unused_package_scanner.py +0 -0
  38. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/scanners/vulnerability_scanner.py +0 -0
  39. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/security/__init__.py +0 -0
  40. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/security/cvss.py +0 -0
  41. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/security/osv_client.py +0 -0
  42. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/security/secrets.py +0 -0
  43. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/utils/__init__.py +0 -0
  44. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/utils/env_probe.py +0 -0
  45. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/utils/file_utils.py +0 -0
  46. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/utils/parser_utils.py +0 -0
  47. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/utils/pip_utils.py +0 -0
  48. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor/utils/subprocess_utils.py +0 -0
  49. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor_cli.egg-info/dependency_links.txt +0 -0
  50. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor_cli.egg-info/entry_points.txt +0 -0
  51. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/pydoctor_cli.egg-info/requires.txt +0 -0
  52. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/setup.cfg +0 -0
  53. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/tests/conftest.py +0 -0
  54. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/tests/test_ci_scanner.py +0 -0
  55. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/tests/test_cli_ux.py +0 -0
  56. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/tests/test_dependency_scanner.py +0 -0
  57. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/tests/test_env_scanner.py +0 -0
  58. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/tests/test_environment.py +0 -0
  59. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/tests/test_fix_command.py +0 -0
  60. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/tests/test_osv_parsing.py +0 -0
  61. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/tests/test_report_output.py +0 -0
  62. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.0}/tests/test_unused_scanner.py +0 -0
  63. {pydoctor_cli-2.2.0 → pydoctor_cli-2.3.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.2.0
3
+ Version: 2.3.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>
@@ -322,6 +322,44 @@ Run a comprehensive check-up on your project.
322
322
  pydoctor diagnose
323
323
  ```
324
324
 
325
+ ### In CI: GitHub Action
326
+
327
+ Findings appear as code-scanning alerts on pull requests, and the job fails on new problems:
328
+
329
+ ```yaml
330
+ permissions:
331
+ contents: read
332
+ security-events: write
333
+
334
+ steps:
335
+ - uses: actions/checkout@v4
336
+ - uses: actions/setup-python@v5
337
+ with: { python-version: "3.12" }
338
+ - run: pip install -r requirements.txt
339
+ - uses: iamAgbaCoder/pydoctor-cli@v2
340
+ ```
341
+
342
+ Set `ci-only: "true"` to run only the secrets and workflow checks. Any CI system can use `pydoctor check --sarif pydoctor.sarif` directly.
343
+
344
+ ### Adopting it in an existing project: baselines
345
+
346
+ ```bash
347
+ pydoctor baseline # accept today's issues; commit .pydoctor-baseline.json
348
+ pydoctor check # now fails only on new issues
349
+ ```
350
+
351
+ Problems with the scan itself (unreadable environment, unreachable vulnerability database) are never baselined.
352
+
353
+ ### pre-commit
354
+
355
+ ```yaml
356
+ repos:
357
+ - repo: https://github.com/iamAgbaCoder/pydoctor-cli
358
+ rev: v2.3.0
359
+ hooks:
360
+ - id: pydoctor-ci # blocks commits that add secrets or risky workflows
361
+ ```
362
+
325
363
  ### Automated Fixes
326
364
 
327
365
  Let PyDoctor perform the heavy lifting. It will offer to install missing packages, upgrade outdated ones, and clean up your environment.
@@ -334,6 +372,15 @@ pydoctor fix
334
372
 
335
373
  ## Release History
336
374
 
375
+ ### v2.3.0
376
+ Built for CI: adopt pydoctor in an existing project without a wall of failures, and see findings where you review code.
377
+
378
+ - **Baselines:** `pydoctor baseline` records today's issues in `.pydoctor-baseline.json`; scans then fail only on new ones and tell you when baselined issues are fixed. Fingerprints survive unrelated edits (a secret moved down by new lines stays known) and package upgrades. `--ci` updates only the CI-guard entries. `--baseline FILE` / `--no-baseline` on every scan command. Broken scans are never baselined.
379
+ - **SARIF output:** `--sarif FILE` on every scan command (SARIF 2.1.0, schema-validated). Vulnerabilities carry their CVSS score as security severity and point at the line that declares the package; secrets and workflow findings point at their file and line.
380
+ - **GitHub Action:** `uses: iamAgbaCoder/pydoctor-cli@v2` installs pydoctor in isolation, uploads findings to code scanning, and fails the job on new problems.
381
+ - **pre-commit hooks:** `pydoctor-ci` blocks commits that add secrets, `.env` files or risky workflows; `pydoctor-check` runs the full scan at pre-push.
382
+ - **Docs links:** every issue links to its page on pydoctor-docs.thally.app (`docs_url` in JSON; shown in the terminal for errors).
383
+
337
384
  ### v2.2.0
338
385
  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
386
 
@@ -73,6 +73,44 @@ Run a comprehensive check-up on your project.
73
73
  pydoctor diagnose
74
74
  ```
75
75
 
76
+ ### In CI: GitHub Action
77
+
78
+ Findings appear as code-scanning alerts on pull requests, and the job fails on new problems:
79
+
80
+ ```yaml
81
+ permissions:
82
+ contents: read
83
+ security-events: write
84
+
85
+ steps:
86
+ - uses: actions/checkout@v4
87
+ - uses: actions/setup-python@v5
88
+ with: { python-version: "3.12" }
89
+ - run: pip install -r requirements.txt
90
+ - uses: iamAgbaCoder/pydoctor-cli@v2
91
+ ```
92
+
93
+ Set `ci-only: "true"` to run only the secrets and workflow checks. Any CI system can use `pydoctor check --sarif pydoctor.sarif` directly.
94
+
95
+ ### Adopting it in an existing project: baselines
96
+
97
+ ```bash
98
+ pydoctor baseline # accept today's issues; commit .pydoctor-baseline.json
99
+ pydoctor check # now fails only on new issues
100
+ ```
101
+
102
+ Problems with the scan itself (unreadable environment, unreachable vulnerability database) are never baselined.
103
+
104
+ ### pre-commit
105
+
106
+ ```yaml
107
+ repos:
108
+ - repo: https://github.com/iamAgbaCoder/pydoctor-cli
109
+ rev: v2.3.0
110
+ hooks:
111
+ - id: pydoctor-ci # blocks commits that add secrets or risky workflows
112
+ ```
113
+
76
114
  ### Automated Fixes
77
115
 
78
116
  Let PyDoctor perform the heavy lifting. It will offer to install missing packages, upgrade outdated ones, and clean up your environment.
@@ -85,6 +123,15 @@ pydoctor fix
85
123
 
86
124
  ## Release History
87
125
 
126
+ ### v2.3.0
127
+ Built for CI: adopt pydoctor in an existing project without a wall of failures, and see findings where you review code.
128
+
129
+ - **Baselines:** `pydoctor baseline` records today's issues in `.pydoctor-baseline.json`; scans then fail only on new ones and tell you when baselined issues are fixed. Fingerprints survive unrelated edits (a secret moved down by new lines stays known) and package upgrades. `--ci` updates only the CI-guard entries. `--baseline FILE` / `--no-baseline` on every scan command. Broken scans are never baselined.
130
+ - **SARIF output:** `--sarif FILE` on every scan command (SARIF 2.1.0, schema-validated). Vulnerabilities carry their CVSS score as security severity and point at the line that declares the package; secrets and workflow findings point at their file and line.
131
+ - **GitHub Action:** `uses: iamAgbaCoder/pydoctor-cli@v2` installs pydoctor in isolation, uploads findings to code scanning, and fails the job on new problems.
132
+ - **pre-commit hooks:** `pydoctor-ci` blocks commits that add secrets, `.env` files or risky workflows; `pydoctor-check` runs the full scan at pre-push.
133
+ - **Docs links:** every issue links to its page on pydoctor-docs.thally.app (`docs_url` in JSON; shown in the terminal for errors).
134
+
88
135
  ### v2.2.0
89
136
  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
137
 
@@ -9,6 +9,6 @@ Author: Favour Bamgboye
9
9
  License: MIT
10
10
  """
11
11
 
12
- __version__ = "2.2.0"
12
+ __version__ = "2.3.0"
13
13
  __author__ = "Favour Bamgboye"
14
14
  __description__ = "Python environment diagnostic CLI — your project's doctor."
@@ -85,7 +85,7 @@ _HELP_EPILOG = """
85
85
  [section]🏥 COMMUNITY & SUPPORT[/]
86
86
 
87
87
  [label]Docs:[/][pkg] https://pydoctor-docs.thally.app[/]
88
- [label]Version:[/][info] v2.2.0 (Premium Kernel Edition)[/]
88
+ [label]Version:[/][info] v2.3.0 (Premium Kernel Edition)[/]
89
89
 
90
90
  [kernel]Healthy code leads to healthy deployments. Keep your environment sterile.[/]
91
91
  """
@@ -122,6 +122,19 @@ _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
+ _SARIF_ARG = typer.Option(
126
+ ...,
127
+ "--sarif",
128
+ help="Also write findings to this file in SARIF format (for GitHub code scanning).",
129
+ )
130
+ _BASELINE_ARG = typer.Option(
131
+ ...,
132
+ "--baseline",
133
+ help="Baseline file of accepted issues (default: .pydoctor-baseline.json if present).",
134
+ )
135
+ _NO_BASELINE_ARG = typer.Option(
136
+ ..., "--no-baseline", help="Report every issue, ignoring the baseline."
137
+ )
125
138
  _PYTHON_ARG = typer.Option(
126
139
  ...,
127
140
  "--python",
@@ -212,6 +225,42 @@ def _output(report: DiagnosisReport, as_json: bool, verbose: bool = False) -> No
212
225
  render_report(report, verbose=verbose)
213
226
 
214
227
 
228
+ def _finish(
229
+ report: DiagnosisReport,
230
+ path: str,
231
+ as_json: bool,
232
+ verbose: bool,
233
+ sarif: Path | None,
234
+ baseline: Path | None,
235
+ no_baseline: bool,
236
+ ) -> None:
237
+ """Apply the baseline, print the report, write SARIF, and exit with the right code."""
238
+ from pydoctor.core.baseline import BaselineError, apply_baseline, default_path
239
+
240
+ if not no_baseline:
241
+ path_to_use = baseline or default_path(report)
242
+ if baseline is not None and not path_to_use.is_file():
243
+ err_console.print(f"[error]✖ Baseline file not found: {path_to_use}[/]")
244
+ raise typer.Exit(code=2)
245
+ if path_to_use.is_file():
246
+ try:
247
+ apply_baseline(report, path_to_use, set(report.scanners))
248
+ except BaselineError as exc:
249
+ err_console.print(f"[error]✖ {exc}[/]")
250
+ raise typer.Exit(code=2) from exc
251
+
252
+ _output(report, as_json=as_json, verbose=verbose)
253
+
254
+ if sarif is not None:
255
+ from pydoctor.reports.sarif_formatter import write_sarif
256
+
257
+ write_sarif(report, sarif)
258
+ if not as_json:
259
+ console.print(f"[dim_text]SARIF report written to {sarif}[/]")
260
+
261
+ raise typer.Exit(code=_exit_code(report, path))
262
+
263
+
215
264
  def _exit_code(report: DiagnosisReport, path: str = ".") -> int:
216
265
  """
217
266
  Return a non-zero exit code if:
@@ -237,6 +286,9 @@ def _exit_code(report: DiagnosisReport, path: str = ".") -> int:
237
286
  def diagnose(
238
287
  path: Annotated[str, _PATH_ARG] = ".",
239
288
  python: Annotated[str | None, _PYTHON_ARG] = None,
289
+ sarif: Annotated[Path | None, _SARIF_ARG] = None,
290
+ baseline: Annotated[Path | None, _BASELINE_ARG] = None,
291
+ no_baseline: Annotated[bool, _NO_BASELINE_ARG] = False,
240
292
  json: Annotated[bool, _JSON_ARG] = False,
241
293
  verbose: Annotated[bool, _VERBOSE_ARG] = False,
242
294
  no_cache: Annotated[bool, _NO_CACHE_ARG] = False,
@@ -268,14 +320,16 @@ def diagnose(
268
320
  python=python,
269
321
  ctx=ctx,
270
322
  )
271
- _output(report, as_json=json, verbose=verbose)
272
- raise typer.Exit(code=_exit_code(report, path))
323
+ _finish(report, path, json, verbose, sarif, baseline, no_baseline)
273
324
 
274
325
 
275
326
  @app.command(rich_help_panel="CORE COMMANDS")
276
327
  def check(
277
328
  path: Annotated[str, _PATH_ARG] = ".",
278
329
  python: Annotated[str | None, _PYTHON_ARG] = None,
330
+ sarif: Annotated[Path | None, _SARIF_ARG] = None,
331
+ baseline: Annotated[Path | None, _BASELINE_ARG] = None,
332
+ no_baseline: Annotated[bool, _NO_BASELINE_ARG] = False,
279
333
  ci: bool = typer.Option(False, "--ci", help="Run in CI/CD guard mode."),
280
334
  json: Annotated[bool, _JSON_ARG] = False,
281
335
  verbose: Annotated[bool, _VERBOSE_ARG] = False,
@@ -284,8 +338,7 @@ def check(
284
338
  report = _run_scan(
285
339
  path=path, ci_mode=ci, verbose=verbose, as_json=json, command="check", python=python
286
340
  )
287
- _output(report, as_json=json, verbose=verbose)
288
- raise typer.Exit(code=_exit_code(report, path))
341
+ _finish(report, path, json, verbose, sarif, baseline, no_baseline)
289
342
 
290
343
 
291
344
  @app.command(rich_help_panel="SECURITY & CI/CD")
@@ -319,6 +372,9 @@ def github(
319
372
  repo: Annotated[str | None, typer.Argument(help="GitHub repository URL (optional).")] = None,
320
373
  path: Annotated[str, _PATH_ARG] = ".",
321
374
  python: Annotated[str | None, _PYTHON_ARG] = None,
375
+ sarif: Annotated[Path | None, _SARIF_ARG] = None,
376
+ baseline: Annotated[Path | None, _BASELINE_ARG] = None,
377
+ no_baseline: Annotated[bool, _NO_BASELINE_ARG] = False,
322
378
  json: Annotated[bool, _JSON_ARG] = False,
323
379
  verbose: Annotated[bool, _VERBOSE_ARG] = False,
324
380
  ) -> None:
@@ -347,8 +403,7 @@ def github(
347
403
  report = _run_scan(
348
404
  path=path, ci_mode=True, verbose=verbose, as_json=json, command="github", python=python
349
405
  )
350
- _output(report, as_json=json, verbose=verbose)
351
- raise typer.Exit(code=_exit_code(report, path))
406
+ _finish(report, path, json, verbose, sarif, baseline, no_baseline)
352
407
 
353
408
 
354
409
  # --- Individual Targeted Scans ---
@@ -358,6 +413,9 @@ def github(
358
413
  def scan_security(
359
414
  path: Annotated[str, _PATH_ARG] = ".",
360
415
  python: Annotated[str | None, _PYTHON_ARG] = None,
416
+ sarif: Annotated[Path | None, _SARIF_ARG] = None,
417
+ baseline: Annotated[Path | None, _BASELINE_ARG] = None,
418
+ no_baseline: Annotated[bool, _NO_BASELINE_ARG] = False,
361
419
  json: Annotated[bool, _JSON_ARG] = False,
362
420
  verbose: Annotated[bool, _VERBOSE_ARG] = False,
363
421
  ) -> None:
@@ -370,8 +428,7 @@ def scan_security(
370
428
  command="scan-security",
371
429
  python=python,
372
430
  )
373
- _output(report, as_json=json, verbose=verbose)
374
- raise typer.Exit(code=_exit_code(report, path))
431
+ _finish(report, path, json, verbose, sarif, baseline, no_baseline)
375
432
 
376
433
 
377
434
  # ──────────────────────────────────────────────────────────────
@@ -383,6 +440,9 @@ def scan_security(
383
440
  def scan_unused(
384
441
  path: Annotated[str, _PATH_ARG] = ".",
385
442
  python: Annotated[str | None, _PYTHON_ARG] = None,
443
+ sarif: Annotated[Path | None, _SARIF_ARG] = None,
444
+ baseline: Annotated[Path | None, _BASELINE_ARG] = None,
445
+ no_baseline: Annotated[bool, _NO_BASELINE_ARG] = False,
386
446
  json: Annotated[bool, _JSON_ARG] = False,
387
447
  verbose: Annotated[bool, _VERBOSE_ARG] = False,
388
448
  ) -> None:
@@ -395,14 +455,16 @@ def scan_unused(
395
455
  command="scan-unused",
396
456
  python=python,
397
457
  )
398
- _output(report, as_json=json, verbose=verbose)
399
- raise typer.Exit(code=_exit_code(report, path))
458
+ _finish(report, path, json, verbose, sarif, baseline, no_baseline)
400
459
 
401
460
 
402
461
  @app.command(name="scan-deps", rich_help_panel="RESOURCE HYGIENE")
403
462
  def scan_deps(
404
463
  path: Annotated[str, _PATH_ARG] = ".",
405
464
  python: Annotated[str | None, _PYTHON_ARG] = None,
465
+ sarif: Annotated[Path | None, _SARIF_ARG] = None,
466
+ baseline: Annotated[Path | None, _BASELINE_ARG] = None,
467
+ no_baseline: Annotated[bool, _NO_BASELINE_ARG] = False,
406
468
  json: Annotated[bool, _JSON_ARG] = False,
407
469
  verbose: Annotated[bool, _VERBOSE_ARG] = False,
408
470
  ) -> None:
@@ -415,14 +477,16 @@ def scan_deps(
415
477
  command="scan-deps",
416
478
  python=python,
417
479
  )
418
- _output(report, as_json=json, verbose=verbose)
419
- raise typer.Exit(code=_exit_code(report, path))
480
+ _finish(report, path, json, verbose, sarif, baseline, no_baseline)
420
481
 
421
482
 
422
483
  @app.command(name="check-env", rich_help_panel="RESOURCE HYGIENE")
423
484
  def check_env(
424
485
  path: Annotated[str, _PATH_ARG] = ".",
425
486
  python: Annotated[str | None, _PYTHON_ARG] = None,
487
+ sarif: Annotated[Path | None, _SARIF_ARG] = None,
488
+ baseline: Annotated[Path | None, _BASELINE_ARG] = None,
489
+ no_baseline: Annotated[bool, _NO_BASELINE_ARG] = False,
426
490
  json: Annotated[bool, _JSON_ARG] = False,
427
491
  verbose: Annotated[bool, _VERBOSE_ARG] = False,
428
492
  ) -> None:
@@ -435,13 +499,56 @@ def check_env(
435
499
  command="check-env",
436
500
  python=python,
437
501
  )
438
- _output(report, as_json=json, verbose=verbose)
439
- raise typer.Exit(code=_exit_code(report, path))
502
+ _finish(report, path, json, verbose, sarif, baseline, no_baseline)
440
503
 
441
504
 
442
505
  # --- Re-adding fix and other commands ---
443
506
 
444
507
 
508
+ @app.command(rich_help_panel="CORE COMMANDS")
509
+ def baseline(
510
+ path: Annotated[str, _PATH_ARG] = ".",
511
+ python: Annotated[str | None, _PYTHON_ARG] = None,
512
+ ci: bool = typer.Option(
513
+ False, "--ci", help="Only record CI/CD guard findings (keeps the rest of the file)."
514
+ ),
515
+ output: Annotated[
516
+ Path | None,
517
+ typer.Option(
518
+ "--output", "-o", help="Where to write it (default: .pydoctor-baseline.json)."
519
+ ),
520
+ ] = None,
521
+ ) -> None:
522
+ """📌 Accept today's issues so CI only fails on new ones."""
523
+ from pydoctor.core.baseline import NEVER_BASELINE, default_path, write_baseline
524
+
525
+ report = _run_scan(
526
+ scanners=["ci"] if ci else None, path=path, python=python, command="baseline"
527
+ )
528
+ target = output or default_path(report)
529
+ count = write_baseline(report, target, __version__, set(report.scanners) if ci else None)
530
+ console.print(
531
+ Panel(
532
+ f"[ok]✔ {count} issue{'s' if count != 1 else ''} recorded in {target}[/]\n\n"
533
+ "Commit this file. Scans now hide these issues and fail only on new ones.\n"
534
+ "Run `pydoctor baseline` again after fixing issues to shrink it, or scan with "
535
+ "--no-baseline to see everything.",
536
+ border_style="ok",
537
+ )
538
+ )
539
+
540
+ incomplete = [i for i in report.issues if i.code in NEVER_BASELINE]
541
+ if incomplete:
542
+ for issue in incomplete:
543
+ err_console.print(f"[error]✖ {issue.title}[/]\n {issue.description}")
544
+ err_console.print(
545
+ "\n[warning]⚠ Part of the scan didn't run, so its issues aren't in the baseline. "
546
+ "They will be reported as new once the scan can run. Fix the problems above and "
547
+ "run `pydoctor baseline` again.[/]"
548
+ )
549
+ raise typer.Exit(code=1)
550
+
551
+
445
552
  @app.command(rich_help_panel="CORE COMMANDS")
446
553
  def fix(
447
554
  packages: Annotated[
@@ -159,6 +159,7 @@ class Analyzer:
159
159
  ctx = self._ctx or ProjectContext.from_path(self._project_path, python=self._python)
160
160
  ctx.no_cache = self._no_cache
161
161
  report = DiagnosisReport(scan_path=str(ctx.root), ctx=ctx)
162
+ report.scanners = [k for k in self._scanner_keys if k in SCANNER_REGISTRY]
162
163
  scanner_timings: dict[str, float] = {}
163
164
  report.extend(context_issues(ctx, self._scanner_keys))
164
165
 
@@ -0,0 +1,153 @@
1
+ """
2
+ pydoctor/core/baseline.py
3
+ ──────────────────────────
4
+ Baselines: accept today's issues so CI only fails on new ones.
5
+
6
+ A project with dozens of existing findings can't turn on a pydoctor CI gate
7
+ without it failing on every run. ``pydoctor baseline`` records the current
8
+ issues in ``.pydoctor-baseline.json``; later scans hide issues whose
9
+ fingerprint is in that file and report how many were hidden. Fixed issues
10
+ drop out naturally, and the file can be refreshed at any time.
11
+
12
+ Problems with the scan itself (an unreadable environment, an unreachable
13
+ vulnerability database, a crashed scanner) are never baselined: accepting
14
+ them would turn a broken scan into a passing one.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import datetime
20
+ import json
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ from pydoctor.config.settings import Severity
25
+ from pydoctor.core.report import DiagnosisReport, Issue
26
+ from pydoctor.reports.fingerprint import fingerprint
27
+
28
+ BASELINE_FILE = ".pydoctor-baseline.json"
29
+ BASELINE_FORMAT = 1
30
+
31
+ # Never accepted into a baseline.
32
+ NEVER_BASELINE = {"ENV_PACKAGES_UNAVAILABLE", "SEC_SCAN_INCOMPLETE", "SCANNER_ERROR"}
33
+ _IGNORED_SEVERITIES = {Severity.OK, Severity.INFO}
34
+
35
+
36
+ class BaselineError(Exception):
37
+ """The baseline file exists but can't be used."""
38
+
39
+
40
+ @dataclass
41
+ class BaselineResult:
42
+ path: Path
43
+ suppressed: int = 0
44
+ stale: int = 0
45
+ suppressed_codes: dict[str, int] = field(default_factory=dict)
46
+
47
+
48
+ def _root(report: DiagnosisReport) -> Path:
49
+ return Path(report.ctx.root) if report.ctx is not None else Path(report.scan_path)
50
+
51
+
52
+ def baselinable(issue: Issue) -> bool:
53
+ return issue.severity not in _IGNORED_SEVERITIES and issue.code not in NEVER_BASELINE
54
+
55
+
56
+ def default_path(report: DiagnosisReport) -> Path:
57
+ return _root(report) / BASELINE_FILE
58
+
59
+
60
+ def write_baseline(
61
+ report: DiagnosisReport, path: Path, version: str, scanned_categories: set[str] | None = None
62
+ ) -> int:
63
+ """
64
+ Record every baselinable issue in ``report``. Returns how many are in the file.
65
+
66
+ When ``scanned_categories`` is given (a partial scan such as
67
+ `pydoctor baseline --ci`), entries for categories that weren't scanned are
68
+ kept from the existing file instead of being dropped.
69
+ """
70
+ root = _root(report)
71
+ entries: dict[str, dict] = {}
72
+ if scanned_categories is not None and path.is_file():
73
+ try:
74
+ previous = json.loads(path.read_text(encoding="utf-8")).get("issues", [])
75
+ except (OSError, ValueError):
76
+ previous = []
77
+ for e in previous:
78
+ if e.get("category") not in scanned_categories and "fingerprint" in e:
79
+ entries[e["fingerprint"]] = e
80
+ for issue in report.issues:
81
+ if not baselinable(issue):
82
+ continue
83
+ fp = fingerprint(issue, root)
84
+ entries[fp] = {
85
+ "fingerprint": fp,
86
+ "code": issue.code,
87
+ "category": issue.category,
88
+ "severity": issue.severity,
89
+ "title": issue.title,
90
+ }
91
+ data = {
92
+ "format": BASELINE_FORMAT,
93
+ "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
94
+ "pydoctor_version": version,
95
+ "issues": sorted(entries.values(), key=lambda e: (e["code"], e["title"])),
96
+ }
97
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
98
+ return len(entries)
99
+
100
+
101
+ def load_fingerprints(path: Path) -> set[str]:
102
+ try:
103
+ data = json.loads(path.read_text(encoding="utf-8"))
104
+ except (OSError, ValueError) as exc:
105
+ raise BaselineError(f"{path} couldn't be read: {exc}") from exc
106
+ if not isinstance(data, dict) or data.get("format") != BASELINE_FORMAT:
107
+ raise BaselineError(
108
+ f"{path} isn't a pydoctor baseline (format {BASELINE_FORMAT}). "
109
+ "Recreate it with `pydoctor baseline`."
110
+ )
111
+ return {e["fingerprint"] for e in data.get("issues", []) if "fingerprint" in e}
112
+
113
+
114
+ def apply_baseline(
115
+ report: DiagnosisReport, path: Path, scanned_categories: set[str]
116
+ ) -> BaselineResult:
117
+ """
118
+ Remove issues recorded in the baseline from ``report``.
119
+
120
+ ``scanned_categories`` limits stale-entry counting to what this scan
121
+ actually checked: `scan-security` can't tell whether a baselined secret
122
+ still exists.
123
+ """
124
+ known = load_fingerprints(path)
125
+ root = _root(report)
126
+ result = BaselineResult(path=path)
127
+ seen: set[str] = set()
128
+ kept: list[Issue] = []
129
+ for issue in report.issues:
130
+ fp = fingerprint(issue, root) if baselinable(issue) else None
131
+ if fp is not None and fp in known:
132
+ seen.add(fp)
133
+ result.suppressed += 1
134
+ result.suppressed_codes[issue.code] = result.suppressed_codes.get(issue.code, 0) + 1
135
+ continue
136
+ kept.append(issue)
137
+ report.issues = kept
138
+
139
+ try:
140
+ entries = json.loads(path.read_text(encoding="utf-8")).get("issues", [])
141
+ except (OSError, ValueError):
142
+ entries = []
143
+ result.stale = sum(
144
+ 1
145
+ for e in entries
146
+ if e.get("fingerprint") not in seen and e.get("category") in scanned_categories
147
+ )
148
+ report.scanner_meta["baseline"] = {
149
+ "file": str(path),
150
+ "suppressed": result.suppressed,
151
+ "stale": result.stale,
152
+ }
153
+ return result
@@ -60,8 +60,15 @@ class Issue:
60
60
  "recommendation": self.recommendation,
61
61
  "package": self.package,
62
62
  "extra": self.extra,
63
+ "docs_url": self.docs_url,
63
64
  }
64
65
 
66
+ @property
67
+ def docs_url(self) -> str:
68
+ from pydoctor.reports.docs_links import docs_url
69
+
70
+ return docs_url(self)
71
+
65
72
 
66
73
  # ──────────────────────────────────────────────────────────────
67
74
  # DiagnosisReport — aggregated result of all scans
@@ -89,6 +96,7 @@ class DiagnosisReport:
89
96
  scanner_meta: dict = field(default_factory=dict)
90
97
  ctx: Any = None
91
98
  command: str | None = None
99
+ scanners: list[str] = field(default_factory=list) # scanner keys that ran
92
100
 
93
101
  # ── Convenience helpers ────────────────────────────────────
94
102
 
@@ -0,0 +1,52 @@
1
+ """
2
+ pydoctor/reports/docs_links.py
3
+ ───────────────────────────────
4
+ Link every issue to the page of the documentation that explains it.
5
+
6
+ Anchors here must exist on https://pydoctor-docs.thally.app. Specific
7
+ troubleshooting entries win; otherwise the issue links to the section of
8
+ the scanner reference that produced it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pydoctor.core.report import Issue
14
+
15
+ DOCS_URL = "https://pydoctor-docs.thally.app"
16
+
17
+ _BY_CODE: dict[str, str] = {
18
+ "ENV_PACKAGES_UNAVAILABLE": "/guides/troubleshooting#packages-unavailable",
19
+ "ENV_DEPS_NOT_INSTALLED": "/guides/troubleshooting#wrong-python",
20
+ "SEC_SCAN_INCOMPLETE": "/guides/troubleshooting#scan-incomplete",
21
+ "CI_ENV_FILE_COMMITTED": "/guides/troubleshooting#env-file-committed",
22
+ "CI_ENV_FILE_NOT_IGNORED": "/guides/troubleshooting#env-file-committed",
23
+ "CI_PULL_REQUEST_TARGET_CHECKOUT": "/scanners#workflow-checks",
24
+ "CI_SCRIPT_INJECTION": "/scanners#workflow-checks",
25
+ "CI_BROAD_PERMISSIONS": "/scanners#workflow-checks",
26
+ "CI_UNPINNED_ACTION": "/scanners#workflow-checks",
27
+ "CI_CLEARTEXT_ENV": "/scanners#workflow-checks",
28
+ "SEC_VULNERABILITY": "/scanners#severity",
29
+ }
30
+
31
+ _BY_PREFIX: dict[str, str] = {
32
+ "SECRET_EXPOSED_": "/scanners#secret-rules",
33
+ }
34
+
35
+ _BY_CATEGORY: dict[str, str] = {
36
+ "environment": "/scanners#environment",
37
+ "dependencies": "/scanners#dependencies",
38
+ "outdated": "/scanners#outdated-packages",
39
+ "security": "/scanners#security",
40
+ "unused": "/scanners#unused-packages",
41
+ "ci": "/scanners#ci-cd",
42
+ }
43
+
44
+
45
+ def docs_url(issue: Issue) -> str:
46
+ """The documentation page for ``issue`` (falls back to the docs home)."""
47
+ path = _BY_CODE.get(issue.code)
48
+ if path is None:
49
+ path = next((p for prefix, p in _BY_PREFIX.items() if issue.code.startswith(prefix)), None)
50
+ if path is None:
51
+ path = _BY_CATEGORY.get(issue.category, "")
52
+ return DOCS_URL + path