pydoctor-cli 2.0.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/PKG-INFO +11 -1
  2. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/README.md +10 -0
  3. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/__init__.py +1 -1
  4. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/cli/main.py +155 -39
  5. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/scanners/unused_package_scanner.py +111 -8
  6. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/scanners/vulnerability_scanner.py +71 -3
  7. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/security/osv_client.py +12 -2
  8. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/utils/parser_utils.py +31 -0
  9. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/PKG-INFO +11 -1
  10. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/SOURCES.txt +1 -0
  11. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pyproject.toml +1 -1
  12. pydoctor_cli-2.1.0/tests/test_fix_command.py +141 -0
  13. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/tests/test_unused_scanner.py +67 -0
  14. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/tests/test_vulnerability_scanner.py +75 -0
  15. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/LICENSE +0 -0
  16. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/analysis/__init__.py +0 -0
  17. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/analysis/health_score.py +0 -0
  18. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/cache/__init__.py +0 -0
  19. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/cache/cache_manager.py +0 -0
  20. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/cli/__init__.py +0 -0
  21. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/config/__init__.py +0 -0
  22. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/config/settings.py +0 -0
  23. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/core/__init__.py +0 -0
  24. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/core/analyzer.py +0 -0
  25. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/core/project.py +0 -0
  26. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/core/report.py +0 -0
  27. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/performance/__init__.py +0 -0
  28. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/performance/executor.py +0 -0
  29. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/performance/parallel.py +0 -0
  30. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/reports/__init__.py +0 -0
  31. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/reports/json_formatter.py +0 -0
  32. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/reports/table_formatter.py +0 -0
  33. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/reports/terminal_colors.py +0 -0
  34. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/scanners/__init__.py +0 -0
  35. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/scanners/ci_scanner.py +0 -0
  36. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/scanners/dependency_scanner.py +0 -0
  37. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/scanners/env_scanner.py +0 -0
  38. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/scanners/outdated_package_scanner.py +0 -0
  39. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/security/__init__.py +0 -0
  40. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/utils/__init__.py +0 -0
  41. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/utils/file_utils.py +0 -0
  42. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/utils/pip_utils.py +0 -0
  43. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/utils/subprocess_utils.py +0 -0
  44. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/dependency_links.txt +0 -0
  45. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/entry_points.txt +0 -0
  46. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/requires.txt +0 -0
  47. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/top_level.txt +0 -0
  48. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/setup.cfg +0 -0
  49. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/tests/conftest.py +0 -0
  50. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/tests/test_ci_scanner.py +0 -0
  51. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/tests/test_cli_ux.py +0 -0
  52. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/tests/test_dependency_scanner.py +0 -0
  53. {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/tests/test_env_scanner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydoctor-cli
3
- Version: 2.0.0
3
+ Version: 2.1.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>
@@ -323,6 +323,16 @@ pydoctor fix
323
323
 
324
324
  ## Release History
325
325
 
326
+ ### v2.1.0
327
+ - **`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
+ - **One prompt per package:** vulnerable packages are offered once, with the advisory count, and upgraded to the lowest version that fixes every advisory.
329
+ - **No accidental removals:** `pydoctor fix <package>` no longer offers to uninstall it. Removal only runs with `--remove`.
330
+ - **Fewer false "unused" reports:** packages imported under a different name (`decouple`), loaded from strings (Django `INSTALLED_APPS`, `MIDDLEWARE`, backends), or run from a Procfile/Dockerfile (`gunicorn`) are recognised as used. Remaining findings are labelled "Possibly unused".
331
+
332
+ ### v2.0.1
333
+ - **Security scans fail closed:** if the OSV database can't be reached, pydoctor now reports `SEC_SCAN_INCOMPLETE` (error, exit 1) instead of a clean result.
334
+ - **No more double-counted advisories:** advisories listed under several IDs (e.g. GHSA and PYSEC) are reported and scored once.
335
+
326
336
  ### v2.0.0
327
337
 
328
338
  - **🚀 Major UI/UX Revamp**: Introduced a premium "kernel/hacker" aesthetic with boxed reports, dynamic progress animations, and improved readability.
@@ -74,6 +74,16 @@ pydoctor fix
74
74
 
75
75
  ## Release History
76
76
 
77
+ ### v2.1.0
78
+ - **`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
+ - **One prompt per package:** vulnerable packages are offered once, with the advisory count, and upgraded to the lowest version that fixes every advisory.
80
+ - **No accidental removals:** `pydoctor fix <package>` no longer offers to uninstall it. Removal only runs with `--remove`.
81
+ - **Fewer false "unused" reports:** packages imported under a different name (`decouple`), loaded from strings (Django `INSTALLED_APPS`, `MIDDLEWARE`, backends), or run from a Procfile/Dockerfile (`gunicorn`) are recognised as used. Remaining findings are labelled "Possibly unused".
82
+
83
+ ### v2.0.1
84
+ - **Security scans fail closed:** if the OSV database can't be reached, pydoctor now reports `SEC_SCAN_INCOMPLETE` (error, exit 1) instead of a clean result.
85
+ - **No more double-counted advisories:** advisories listed under several IDs (e.g. GHSA and PYSEC) are reported and scored once.
86
+
77
87
  ### v2.0.0
78
88
 
79
89
  - **🚀 Major UI/UX Revamp**: Introduced a premium "kernel/hacker" aesthetic with boxed reports, dynamic progress animations, and improved readability.
@@ -9,6 +9,6 @@ Author: Favour Bamgboye
9
9
  License: MIT
10
10
  """
11
11
 
12
- __version__ = "2.0.0"
12
+ __version__ = "2.1.0"
13
13
  __author__ = "Favour Bamgboye"
14
14
  __description__ = "Python environment diagnostic CLI — your project's doctor."
@@ -24,6 +24,7 @@ Global flags
24
24
 
25
25
  from __future__ import annotations
26
26
 
27
+ import re
27
28
  import subprocess
28
29
  import sys
29
30
  from pathlib import Path
@@ -37,6 +38,7 @@ if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
37
38
  pass
38
39
 
39
40
  import typer
41
+ from packaging.version import InvalidVersion, Version
40
42
  from rich.console import Console
41
43
  from rich.panel import Panel
42
44
  from rich.progress import (
@@ -60,6 +62,7 @@ from pydoctor.reports.table_formatter import (
60
62
  )
61
63
  from pydoctor.reports.terminal_colors import PYDOCTOR_THEME
62
64
  from pydoctor.utils.pip_utils import (
65
+ get_installed_packages,
63
66
  remove_from_requirements_file,
64
67
  update_requirements_file,
65
68
  )
@@ -82,7 +85,7 @@ _HELP_EPILOG = """
82
85
  [section]🏥 COMMUNITY & SUPPORT[/]
83
86
 
84
87
  [label]Docs:[/][pkg] https://pydoctor.vercel.app/docs[/]
85
- [label]Version:[/][info] v2.0.0 (Premium Kernel Edition)[/]
88
+ [label]Version:[/][info] v2.1.0 (Premium Kernel Edition)[/]
86
89
 
87
90
  [kernel]Healthy code leads to healthy deployments. Keep your environment sterile.[/]
88
91
  """
@@ -385,12 +388,18 @@ def fix(
385
388
  if packages:
386
389
  _filter_issues_by_targets(report_data, packages)
387
390
  actions = 0
388
- actions += _fix_vulnerabilities(report_data, ctx, safe)
391
+ handled: set[str] = set()
392
+ actions += _fix_vulnerabilities(report_data, ctx, safe, handled)
389
393
  if upgrade:
390
- actions += _fix_outdated(report_data, ctx, safe)
394
+ actions += _fix_outdated(report_data, ctx, safe, handled)
391
395
  actions += _fix_dependencies(report_data, ctx, safe)
392
- if remove or packages:
396
+ if remove:
393
397
  actions += _fix_unused(report_data, ctx, safe)
398
+ elif any(i.code == "UNUSED_PACKAGE" for i in report_data.issues):
399
+ console.print(
400
+ "\n[dim_text]Possibly unused packages were found but not removed. "
401
+ "Review them, then run `pydoctor fix --remove` to remove them.[/]"
402
+ )
394
403
  actions += _fix_venv(report_data, path, safe)
395
404
  _render_fix_summary(actions)
396
405
 
@@ -405,34 +414,117 @@ def _filter_issues_by_targets(report_data, packages):
405
414
  raise typer.Exit()
406
415
 
407
416
 
408
- def _run_package_manager_command(ctx, pkg, action, upgrade=False):
409
- path = str(ctx.root)
410
- if ctx.is_poetry:
411
- cmd = ["poetry", "add", pkg] if action == "add" else ["poetry", "remove", pkg]
412
- if action == "update":
413
- cmd = ["poetry", "update", pkg]
414
- console.print(f" [dim_text]Running: {' '.join(cmd)}[/]")
415
- return subprocess.run(cmd, cwd=path)
417
+ def _manager_command(ctx, pkg, action, upgrade=False, min_version=None):
418
+ """
419
+ Build the uv / Poetry / pdm command for one package, or None for plain pip.
420
+
421
+ When ``min_version`` is given for an add/update, the requirement is passed as
422
+ ``pkg>=min_version`` so the constraint in pyproject.toml is rewritten. A bare
423
+ ``uv add --upgrade pkg`` keeps an existing pin such as ``pkg==1.0`` and
424
+ silently changes nothing.
425
+ """
426
+ requirement = f"{pkg}>={min_version}" if min_version else pkg
416
427
  if ctx.is_uv:
417
- cmd = ["uv", "add", pkg] if action == "add" else ["uv", "remove", pkg]
418
- if upgrade:
419
- cmd = ["uv", "add", "--upgrade", pkg]
420
- console.print(f" [dim_text]Running: {' '.join(cmd)}[/]")
421
- return subprocess.run(cmd, cwd=path)
422
- if ctx.is_pdm:
423
- cmd = ["pdm", "add", pkg] if action == "add" else ["pdm", "remove", pkg]
424
- if action == "update":
425
- cmd = ["pdm", "update", pkg]
428
+ if action == "remove":
429
+ return ["uv", "remove", pkg]
430
+ if upgrade and not min_version:
431
+ return ["uv", "add", "--upgrade-package", pkg, pkg]
432
+ return ["uv", "add", requirement]
433
+ for tool, enabled in (("poetry", ctx.is_poetry), ("pdm", ctx.is_pdm)):
434
+ if enabled:
435
+ if action == "remove":
436
+ return [tool, "remove", pkg]
437
+ if action == "update" and not min_version:
438
+ return [tool, "update", pkg]
439
+ return [tool, "add", requirement]
440
+ return None
441
+
442
+
443
+ def _run_package_manager_command(ctx, pkg, action, upgrade=False, min_version=None):
444
+ """Run the project's package manager (or pip) for one package."""
445
+ cmd = _manager_command(ctx, pkg, action, upgrade, min_version)
446
+ if cmd:
426
447
  console.print(f" [dim_text]Running: {' '.join(cmd)}[/]")
427
- return subprocess.run(cmd, cwd=path)
448
+ return subprocess.run(cmd, cwd=str(ctx.root))
428
449
  if action == "remove":
429
450
  return run_pip_command(["uninstall", "-y", pkg], python_executable=ctx.project_python)
430
- else:
431
- args = ["install"]
432
- if upgrade:
433
- args.append("--upgrade")
434
- args.append(pkg)
435
- return run_pip_command(args, python_executable=ctx.project_python)
451
+ args = ["install"]
452
+ if upgrade:
453
+ args.append("--upgrade")
454
+ args.append(f"{pkg}>={min_version}" if min_version else pkg)
455
+ console.print(f" [dim_text]Running: pip {' '.join(args)}[/]")
456
+ return run_pip_command(args, python_executable=ctx.project_python)
457
+
458
+
459
+ def _norm(name: str) -> str:
460
+ """Normalise a distribution name (PEP 503)."""
461
+ return re.sub(r"[-_.]+", "-", name).lower()
462
+
463
+
464
+ def _installed_version(ctx, pkg: str) -> str | None:
465
+ """Return the version of ``pkg`` installed in the project's environment."""
466
+ target = _norm(pkg)
467
+ for name, version in get_installed_packages(python_executable=ctx.project_python).items():
468
+ if _norm(name) == target:
469
+ return version
470
+ return None
471
+
472
+
473
+ def _declared_constraint(ctx, pkg: str) -> str | None:
474
+ """Find how ``pkg`` is declared in pyproject.toml or requirements.txt, if at all."""
475
+ name = re.escape(pkg).replace(r"\-", "[-_.]").replace(r"\_", "[-_.]")
476
+ pattern = re.compile(
477
+ rf"""^\s*["']?({name}\s*(\[[^\]]*\])?\s*[<>=!~^][^"'#,\n]*(,[^"'#\n]*)?)""", re.I | re.M
478
+ )
479
+ for fname in ("pyproject.toml", "requirements.txt"):
480
+ fpath = ctx.root / fname
481
+ if fpath.is_file():
482
+ match = pattern.search(fpath.read_text(encoding="utf-8", errors="ignore"))
483
+ if match:
484
+ return match.group(1).strip().rstrip(",").strip()
485
+ return None
486
+
487
+
488
+ def _max_version(versions):
489
+ best = None
490
+ for v in versions:
491
+ try:
492
+ parsed = Version(v)
493
+ except (InvalidVersion, TypeError):
494
+ continue
495
+ if best is None or parsed > best:
496
+ best = parsed
497
+ return str(best) if best else None
498
+
499
+
500
+ def _apply_and_verify(ctx, pkg, action, min_version, verb):
501
+ """
502
+ Run the upgrade, then confirm the installed version actually changed.
503
+
504
+ A package manager can exit 0 without changing anything (for example when a
505
+ pin in pyproject.toml blocks the upgrade), so the exit code alone is not
506
+ proof of a fix.
507
+ """
508
+ before = _installed_version(ctx, pkg)
509
+ result = _run_package_manager_command(ctx, pkg, action, True, min_version)
510
+ if result.returncode != 0:
511
+ console.print(f" [error]✖ {pkg} could not be {verb} (exit code {result.returncode}).[/]")
512
+ return False
513
+ after = _installed_version(ctx, pkg)
514
+ if before and after == before:
515
+ console.print(
516
+ f" [warning]⚠ {pkg} is still {after}. The command succeeded but nothing changed.[/]"
517
+ )
518
+ constraint = _declared_constraint(ctx, pkg)
519
+ if constraint:
520
+ console.print(
521
+ f' [dim_text]Your project declares "{constraint}", which blocks the upgrade. '
522
+ f"Loosen it, then run `pydoctor fix {pkg}` again.[/]"
523
+ )
524
+ return False
525
+ change = f"{before} → {after}" if before and after else (after or "")
526
+ console.print(f" [ok]✔ {pkg} {verb}{f' ({change})' if change else ''}.[/]")
527
+ return True
436
528
 
437
529
 
438
530
  def _update_pip_requirements(ctx, pkg, safe, action, version_spec=""):
@@ -449,27 +541,51 @@ def _update_pip_requirements(ctx, pkg, safe, action, version_spec=""):
449
541
  update_requirements_file(req_file, pkg, version_spec)
450
542
 
451
543
 
452
- def _fix_vulnerabilities(report, ctx, safe):
453
- vulnerable = [i for i in report.issues if i.code == "SEC_VULNERABILITY"]
454
- if not vulnerable:
544
+ def _fix_vulnerabilities(report, ctx, safe, handled=None):
545
+ """Offer one upgrade per vulnerable package, to the lowest version that fixes every advisory."""
546
+ handled = handled if handled is not None else set()
547
+ groups: dict[str, list] = {}
548
+ for i in report.issues:
549
+ if i.code == "SEC_VULNERABILITY" and i.package:
550
+ groups.setdefault(i.package, []).append(i)
551
+ if not groups:
455
552
  return 0
553
+ latest = {
554
+ _norm(i.package): i.extra.get("latest_version")
555
+ for i in report.issues
556
+ if i.code == "PKG_OUTDATED" and i.package
557
+ }
456
558
  actions = 0
457
- for i in vulnerable:
458
- if i.package and (not safe or Confirm.ask(f" Fix {i.package}?")):
459
- if _run_package_manager_command(ctx, i.package, "add", True).returncode == 0:
460
- console.print(f" [ok]✔ {i.package} fixed.[/]")
559
+ for pkg, issues in groups.items():
560
+ fixed = [i.extra.get("fixed_in") for i in issues if i.extra.get("fixed_in")]
561
+ target = _max_version(fixed) or latest.get(_norm(pkg))
562
+ critical = sum(1 for i in issues if i.severity == "critical")
563
+ summary = f"{len(issues)} advisor{'y' if len(issues) == 1 else 'ies'}"
564
+ if critical:
565
+ summary += f", {critical} critical"
566
+ if len(fixed) < len(issues):
567
+ summary += f", {len(issues) - len(fixed)} with no fix released yet"
568
+ to = f" to {target}" if target else ""
569
+ handled.add(_norm(pkg))
570
+ if not safe or Confirm.ask(f" Upgrade {pkg}{to}? ({summary})"):
571
+ if _apply_and_verify(ctx, pkg, "add", target, "upgraded"):
461
572
  actions += 1
462
573
  return actions
463
574
 
464
575
 
465
- def _fix_outdated(report, ctx, safe):
576
+ def _fix_outdated(report, ctx, safe, handled=None):
577
+ handled = handled if handled is not None else set()
466
578
  outdated = [i for i in report.issues if i.code == "PKG_OUTDATED"]
467
579
  actions = 0
468
580
  for i in outdated:
469
581
  pkg = i.package
470
- if pkg and (not safe or Confirm.ask(f" Upgrade {pkg}?")):
471
- if _run_package_manager_command(ctx, pkg, "update", True).returncode == 0:
472
- console.print(f" [ok]✔ {pkg} upgraded.[/]")
582
+ if not pkg or _norm(pkg) in handled:
583
+ continue
584
+ handled.add(_norm(pkg))
585
+ target = i.extra.get("latest_version")
586
+ to = f" to {target}" if target else ""
587
+ if not safe or Confirm.ask(f" Upgrade {pkg}{to}?"):
588
+ if _apply_and_verify(ctx, pkg, "update", target, "upgraded"):
473
589
  actions += 1
474
590
  return actions
475
591
 
@@ -13,16 +13,30 @@ Algorithm
13
13
  (requirements.txt / pyproject.toml).
14
14
  5. Packages that are declared but never imported are flagged as unused.
15
15
 
16
+ Import names are mapped to distributions with the project's own
17
+ ``importlib.metadata.packages_distributions()``, so packages whose import
18
+ name differs from their PyPI name (``decouple`` → ``python-decouple``,
19
+ ``jwt`` → ``pyjwt``) are matched correctly.
20
+
21
+ A dependency also counts as used when:
22
+ - a string constant in the code names one of its modules
23
+ (Django ``INSTALLED_APPS``, ``MIDDLEWARE``, ``CACHES`` backends, …), or
24
+ - it is named in a deploy or run file (Procfile, Dockerfile, compose files,
25
+ Makefile, shell scripts, CI workflows), e.g. ``gunicorn`` or ``uvicorn``.
26
+
16
27
  Limitations
17
28
  ───────────
18
- - Dynamic imports (``importlib.import_module``, ``__import__``) are not
19
- tracked — false positives may occur for packages loaded dynamically.
29
+ - Dynamic imports built at runtime (``importlib.import_module(f"...")``)
30
+ are not tracked, so findings are reported as *possibly* unused.
20
31
  - Standard library modules are excluded automatically.
21
32
  - Only *declared* dependencies are checked; transitive installs are not.
22
33
  """
23
34
 
24
35
  from __future__ import annotations
25
36
 
37
+ import json
38
+ import re
39
+ import subprocess
26
40
  import sys
27
41
 
28
42
  from pydoctor.config.settings import Severity
@@ -30,6 +44,7 @@ from pydoctor.core.project import ProjectContext
30
44
  from pydoctor.core.report import Issue
31
45
  from pydoctor.utils.parser_utils import (
32
46
  extract_imports_from_project,
47
+ extract_string_module_refs,
33
48
  import_name_to_package,
34
49
  )
35
50
 
@@ -72,9 +87,17 @@ def scan(ctx: ProjectContext) -> list[Issue]:
72
87
  category=CATEGORY,
73
88
  code="UNUSED_PACKAGE",
74
89
  severity=Severity.WARNING,
75
- title=f"Unused dependency: {pkg}",
76
- description=f"No imports corresponding to '{pkg}' were found, and it is not required by any used packages.",
77
- recommendation=f"pip uninstall {pkg}",
90
+ title=f"Possibly unused dependency: {pkg}",
91
+ description=(
92
+ f"No import, module-path string, or run command referencing '{pkg}' "
93
+ "was found, and no used package depends on it. Packages loaded "
94
+ "dynamically or by plugins can still be missed."
95
+ ),
96
+ recommendation=(
97
+ f"Confirm '{pkg}' is unused, then remove it with your package manager "
98
+ "(or `pydoctor fix --remove`). If it is used, add it to "
99
+ "ignored_packages in [tool.pydoctor]."
100
+ ),
78
101
  package=pkg,
79
102
  )
80
103
  )
@@ -89,7 +112,7 @@ def scan(ctx: ProjectContext) -> list[Issue]:
89
112
  category=CATEGORY,
90
113
  code="UNUSED_TRANSITIVE",
91
114
  severity=Severity.INFO,
92
- title=f"Transitive dependency: {pkg} [dim](required by {parent_str})[/]",
115
+ title=f"Transitive dependency: {pkg} (required by {parent_str})",
93
116
  description=f"'{pkg}' is declared explicitly but only used indirectly via {parent_str}.",
94
117
  recommendation="Consider removing from requirements to keep dependencies lean.",
95
118
  package=pkg,
@@ -125,15 +148,95 @@ def _check_preconditions(ctx: ProjectContext) -> list[Issue]:
125
148
  return []
126
149
 
127
150
 
151
+ def _norm(name: str) -> str:
152
+ return re.sub(r"[-_.]+", "-", name).lower()
153
+
154
+
128
155
  def _get_imported_packages(ctx: ProjectContext) -> set[str]:
129
156
  names = extract_imports_from_project(ctx.python_files)
130
- pkgs = set()
157
+ dist_map = _distribution_map(ctx)
158
+ pkgs: set[str] = set()
131
159
  for n in names:
132
160
  pkgs.add(import_name_to_package(n))
133
161
  pkgs.add(n.lower().replace("_", "-"))
162
+ pkgs.update(_norm(d) for d in dist_map.get(n, []))
163
+ # Module paths held in strings only count when they match an installed
164
+ # distribution, so arbitrary strings can't mark random packages as used.
165
+ for n in extract_string_module_refs(ctx.python_files):
166
+ pkgs.update(_norm(d) for d in dist_map.get(n, []))
167
+ pkgs.update(_packages_named_in_run_files(ctx))
134
168
  return pkgs
135
169
 
136
170
 
171
+ _DIST_MAP_SNIPPET = (
172
+ "import json, importlib.metadata as m; print(json.dumps(m.packages_distributions()))"
173
+ )
174
+
175
+
176
+ def _distribution_map(ctx: ProjectContext) -> dict[str, list[str]]:
177
+ """
178
+ Map top-level import names to distribution names in the *project's* environment.
179
+
180
+ Runs in the project interpreter, not pydoctor's own, so it is correct even
181
+ when pydoctor is installed with pipx. Returns {} if that fails.
182
+ """
183
+ python = getattr(ctx, "project_python", None) or sys.executable
184
+ try:
185
+ result = subprocess.run(
186
+ [python, "-c", _DIST_MAP_SNIPPET],
187
+ capture_output=True,
188
+ text=True,
189
+ timeout=30,
190
+ )
191
+ if result.returncode == 0:
192
+ data = json.loads(result.stdout)
193
+ if isinstance(data, dict):
194
+ return {k: list(v) for k, v in data.items()}
195
+ except (OSError, subprocess.SubprocessError, ValueError):
196
+ pass
197
+ return {}
198
+
199
+
200
+ _RUN_FILE_GLOBS = (
201
+ "Procfile",
202
+ "Dockerfile*",
203
+ "*.dockerfile",
204
+ "docker-compose*.yml",
205
+ "docker-compose*.yaml",
206
+ "compose*.yml",
207
+ "compose*.yaml",
208
+ "Makefile",
209
+ "*.sh",
210
+ "fly.toml",
211
+ "render.yaml",
212
+ "app.yaml",
213
+ ".github/workflows/*.yml",
214
+ ".github/workflows/*.yaml",
215
+ )
216
+
217
+
218
+ def _packages_named_in_run_files(ctx: ProjectContext) -> set[str]:
219
+ """Declared dependencies named in deploy/run files (e.g. `gunicorn app.wsgi`)."""
220
+ text_parts: list[str] = []
221
+ for pattern in _RUN_FILE_GLOBS:
222
+ for fpath in ctx.root.glob(pattern):
223
+ if fpath.is_file():
224
+ try:
225
+ text_parts.append(fpath.read_text(encoding="utf-8", errors="ignore"))
226
+ except OSError:
227
+ continue
228
+ if not text_parts:
229
+ return set()
230
+ text = "\n".join(text_parts).lower()
231
+ found: set[str] = set()
232
+ for dep in ctx.declared_deps:
233
+ norm = _norm(dep)
234
+ variants = {norm, norm.replace("-", "_")}
235
+ if any(re.search(rf"(?<![\w-]){re.escape(v)}(?![\w-])", text) for v in variants):
236
+ found.add(norm)
237
+ return found
238
+
239
+
137
240
  def _get_implicitly_used_packages(ctx: ProjectContext, imported: set[str]) -> dict[str, list[str]]:
138
241
  used: dict[str, list[str]] = {}
139
242
 
@@ -203,7 +306,7 @@ def _identify_unused(
203
306
  transitive = []
204
307
 
205
308
  for dep in ctx.declared_deps:
206
- norm = dep.lower().replace("_", "-")
309
+ norm = _norm(dep)
207
310
  if norm in imported or norm in ignored:
208
311
  continue
209
312
 
@@ -50,9 +50,19 @@ def scan(ctx: ProjectContext) -> list[Issue]:
50
50
  client = OSVClient(cache=cache)
51
51
 
52
52
  # Query OSV for all installed packages
53
- records = client.query_packages(ctx.installed_packages)
53
+ records = _dedupe_records(client.query_packages(ctx.installed_packages))
54
+ failed = list(getattr(client, "failed_packages", []) or [])
55
+
56
+ issues: list[Issue] = []
57
+
58
+ if failed:
59
+ # Fail closed: a security scan that could not reach the vulnerability
60
+ # database must never report the project as healthy.
61
+ issues.append(_incomplete_scan_issue(failed, len(ctx.installed_packages)))
54
62
 
55
63
  if not records:
64
+ if failed:
65
+ return issues
56
66
  return [
57
67
  Issue(
58
68
  category=CATEGORY,
@@ -67,8 +77,6 @@ def scan(ctx: ProjectContext) -> list[Issue]:
67
77
  )
68
78
  ]
69
79
 
70
- issues: list[Issue] = []
71
-
72
80
  # Add environment context info
73
81
  issues.append(
74
82
  Issue(
@@ -90,6 +98,66 @@ def scan(ctx: ProjectContext) -> list[Issue]:
90
98
  return issues
91
99
 
92
100
 
101
+ _SEVERITY_RANK = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1, "UNKNOWN": 0}
102
+
103
+
104
+ def _dedupe_records(records: list[VulnerabilityRecord]) -> list[VulnerabilityRecord]:
105
+ """
106
+ Collapse records that describe the same advisory.
107
+
108
+ OSV often returns one advisory under several databases (for example a
109
+ GHSA entry and a PYSEC entry that list each other as aliases). Without
110
+ this step each copy is reported and penalised separately, so a single
111
+ vulnerability can cost the health score twice. Records for the same
112
+ package and version are merged when their IDs or aliases overlap; the
113
+ highest severity among the duplicates is kept.
114
+ """
115
+ kept: list[VulnerabilityRecord] = []
116
+ kept_ids: list[set[str]] = []
117
+ for rec in records:
118
+ ids = {rec.vuln_id, *rec.aliases}
119
+ for idx, existing in enumerate(kept):
120
+ if (
121
+ existing.package == rec.package
122
+ and existing.version == rec.version
123
+ and kept_ids[idx] & ids
124
+ ):
125
+ kept_ids[idx] |= ids
126
+ if _SEVERITY_RANK.get(rec.severity.upper(), 0) > _SEVERITY_RANK.get(
127
+ existing.severity.upper(), 0
128
+ ):
129
+ existing.severity = rec.severity
130
+ existing.aliases = sorted(kept_ids[idx] - {existing.vuln_id})
131
+ break
132
+ else:
133
+ kept.append(rec)
134
+ kept_ids.append(ids)
135
+ return kept
136
+
137
+
138
+ def _incomplete_scan_issue(failed: list[str], total: int) -> Issue:
139
+ """Build the ERROR issue reported when OSV lookups did not complete."""
140
+ preview = ", ".join(sorted(failed)[:5])
141
+ if len(failed) > 5:
142
+ preview += f", and {len(failed) - 5} more"
143
+ return Issue(
144
+ category=CATEGORY,
145
+ code="SEC_SCAN_INCOMPLETE",
146
+ severity=Severity.ERROR,
147
+ title=f"Security scan incomplete: {len(failed)} of {total} packages not checked",
148
+ description=(
149
+ "pydoctor could not reach the OSV vulnerability database "
150
+ f"(https://api.osv.dev) for: {preview}. These packages were not "
151
+ "checked, so the absence of findings is not evidence that they are safe."
152
+ ),
153
+ recommendation=(
154
+ "Check network or proxy access to api.osv.dev, or raise "
155
+ "PYDOCTOR_REQUEST_TIMEOUT, then run `pydoctor scan-security` again."
156
+ ),
157
+ extra={"unchecked_packages": sorted(failed)},
158
+ )
159
+
160
+
93
161
  def _record_to_issue(rec: VulnerabilityRecord) -> Issue:
94
162
  """
95
163
  Convert a VulnerabilityRecord into a structured Issue.
@@ -99,6 +99,11 @@ class OSVClient:
99
99
  self._cache = cache or CacheManager()
100
100
  self._timeout = timeout
101
101
  self._session = requests.Session()
102
+ # Packages whose OSV lookup could not be completed (network error,
103
+ # timeout, or a non-2xx response after all retries). Callers must
104
+ # check this: an empty result list does NOT mean "no vulnerabilities"
105
+ # when this list is non-empty.
106
+ self.failed_packages: list[str] = []
102
107
  self._session.headers.update(
103
108
  {
104
109
  "Content-Type": "application/json",
@@ -123,8 +128,10 @@ class OSVClient:
123
128
  Returns
124
129
  -------
125
130
  list[VulnerabilityRecord]
126
- All vulnerabilities found. Empty list if none / network error.
131
+ All vulnerabilities found. Packages that could not be checked
132
+ are recorded in ``self.failed_packages``.
127
133
  """
134
+ self.failed_packages = []
128
135
  if not packages:
129
136
  return []
130
137
 
@@ -196,7 +203,10 @@ class OSVClient:
196
203
  break
197
204
  except (requests.RequestException, ValueError):
198
205
  if attempt == retries - 1:
199
- # Final attempt failed — return whatever we got from cache
206
+ # Final attempt failed. Record the packages we could not
207
+ # check so the scanner can report an incomplete scan
208
+ # instead of a clean bill of health.
209
+ self.failed_packages.extend(name for name, _ in to_fetch)
200
210
  return records
201
211
  # Simple exponential backoff: 1s, 2s, 4s...
202
212
  time.sleep(2**attempt)
@@ -10,6 +10,7 @@ actually imported somewhere in the project source.
10
10
  from __future__ import annotations
11
11
 
12
12
  import ast
13
+ import re
13
14
  from pathlib import Path
14
15
 
15
16
  from pydoctor.utils.file_utils import read_file_safe
@@ -72,6 +73,36 @@ def extract_imports_from_project(python_files: list[Path]) -> set[str]:
72
73
  return all_imports
73
74
 
74
75
 
76
+ _MODULE_STRING = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$")
77
+
78
+
79
+ def extract_string_module_refs(python_files: list[Path]) -> set[str]:
80
+ """
81
+ Collect top-level names from string constants that look like module paths.
82
+
83
+ Frameworks load many dependencies by name rather than by ``import``:
84
+ Django's ``INSTALLED_APPS = ["corsheaders"]``, ``MIDDLEWARE = ["whitenoise.middleware..."]``
85
+ and ``"BACKEND": "django_redis.cache.RedisCache"`` are all plain strings.
86
+ Callers should only treat a returned name as a dependency reference when it
87
+ matches the import name of an installed distribution.
88
+ """
89
+ names: set[str] = set()
90
+ for fpath in python_files:
91
+ source = read_file_safe(fpath)
92
+ if source is None:
93
+ continue
94
+ try:
95
+ tree = ast.parse(source)
96
+ except (SyntaxError, ValueError):
97
+ continue
98
+ for node in ast.walk(tree):
99
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
100
+ value = node.value.strip()
101
+ if 0 < len(value) <= 200 and _MODULE_STRING.match(value):
102
+ names.add(value.split(".", 1)[0])
103
+ return names
104
+
105
+
75
106
  # ──────────────────────────────────────────────────────────────
76
107
  # AST walking
77
108
  # ──────────────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydoctor-cli
3
- Version: 2.0.0
3
+ Version: 2.1.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>
@@ -323,6 +323,16 @@ pydoctor fix
323
323
 
324
324
  ## Release History
325
325
 
326
+ ### v2.1.0
327
+ - **`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
+ - **One prompt per package:** vulnerable packages are offered once, with the advisory count, and upgraded to the lowest version that fixes every advisory.
329
+ - **No accidental removals:** `pydoctor fix <package>` no longer offers to uninstall it. Removal only runs with `--remove`.
330
+ - **Fewer false "unused" reports:** packages imported under a different name (`decouple`), loaded from strings (Django `INSTALLED_APPS`, `MIDDLEWARE`, backends), or run from a Procfile/Dockerfile (`gunicorn`) are recognised as used. Remaining findings are labelled "Possibly unused".
331
+
332
+ ### v2.0.1
333
+ - **Security scans fail closed:** if the OSV database can't be reached, pydoctor now reports `SEC_SCAN_INCOMPLETE` (error, exit 1) instead of a clean result.
334
+ - **No more double-counted advisories:** advisories listed under several IDs (e.g. GHSA and PYSEC) are reported and scored once.
335
+
326
336
  ### v2.0.0
327
337
 
328
338
  - **🚀 Major UI/UX Revamp**: Introduced a premium "kernel/hacker" aesthetic with boxed reports, dynamic progress animations, and improved readability.
@@ -46,5 +46,6 @@ tests/test_ci_scanner.py
46
46
  tests/test_cli_ux.py
47
47
  tests/test_dependency_scanner.py
48
48
  tests/test_env_scanner.py
49
+ tests/test_fix_command.py
49
50
  tests/test_unused_scanner.py
50
51
  tests/test_vulnerability_scanner.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pydoctor-cli"
7
- version = "2.0.0"
7
+ version = "2.1.0"
8
8
  description = "Premium Python environment diagnostic CLI — your project's personal physician."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -0,0 +1,141 @@
1
+ """
2
+ tests/test_fix_command.py
3
+ ─────────────────────────
4
+ Unit tests for `pydoctor fix`: per-package prompts, verified upgrades,
5
+ and no implicit removals.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import subprocess
11
+ from types import SimpleNamespace
12
+
13
+ from typer.testing import CliRunner
14
+
15
+ from pydoctor.cli import main
16
+ from pydoctor.core.report import DiagnosisReport, Issue
17
+
18
+
19
+ def _vuln(pkg, fixed_in, severity="critical"):
20
+ return Issue(
21
+ category="security",
22
+ code="SEC_VULNERABILITY",
23
+ severity=severity,
24
+ title=f"{pkg} advisory",
25
+ description="",
26
+ recommendation="",
27
+ package=pkg,
28
+ extra={"fixed_in": fixed_in},
29
+ )
30
+
31
+
32
+ def test_one_prompt_per_vulnerable_package(monkeypatch):
33
+ report = DiagnosisReport(
34
+ issues=[
35
+ _vuln("pillow", "12.3.0"),
36
+ _vuln("pillow", "13.0.0"),
37
+ _vuln("pillow", "12.2.1", severity="warning"),
38
+ _vuln("cryptography", "46.0.5"),
39
+ ]
40
+ )
41
+ prompts, applied = [], []
42
+ monkeypatch.setattr(main.Confirm, "ask", lambda msg, *a, **k: prompts.append(msg) or True)
43
+ monkeypatch.setattr(
44
+ main,
45
+ "_apply_and_verify",
46
+ lambda ctx, pkg, action, target, verb: applied.append((pkg, target)) or True,
47
+ )
48
+
49
+ handled: set[str] = set()
50
+ actions = main._fix_vulnerabilities(report, SimpleNamespace(), True, handled)
51
+
52
+ assert len(prompts) == 2
53
+ assert "3 advisories, 2 critical" in prompts[0]
54
+ assert applied == [("pillow", "13.0.0"), ("cryptography", "46.0.5")]
55
+ assert actions == 2
56
+ assert handled == {"pillow", "cryptography"}
57
+
58
+
59
+ def test_outdated_skips_packages_already_handled(monkeypatch):
60
+ report = DiagnosisReport(
61
+ issues=[
62
+ Issue(
63
+ category="outdated",
64
+ code="PKG_OUTDATED",
65
+ severity="warning",
66
+ title="",
67
+ description="",
68
+ recommendation="",
69
+ package="pillow",
70
+ extra={"latest_version": "13.0.0"},
71
+ )
72
+ ]
73
+ )
74
+ monkeypatch.setattr(main.Confirm, "ask", lambda *a, **k: (_ for _ in ()).throw(AssertionError))
75
+ assert main._fix_outdated(report, SimpleNamespace(), True, {"pillow"}) == 0
76
+
77
+
78
+ def test_unchanged_version_is_not_reported_as_fixed(monkeypatch, tmp_path, capsys):
79
+ (tmp_path / "pyproject.toml").write_text(
80
+ '[project]\ndependencies = [\n "pillow==12.2.0",\n]\n', encoding="utf-8"
81
+ )
82
+ ctx = SimpleNamespace(root=tmp_path)
83
+ monkeypatch.setattr(main, "_installed_version", lambda ctx, pkg: "12.2.0")
84
+ monkeypatch.setattr(
85
+ main,
86
+ "_run_package_manager_command",
87
+ lambda *a, **k: subprocess.CompletedProcess(args=[], returncode=0),
88
+ )
89
+
90
+ assert main._apply_and_verify(ctx, "pillow", "add", None, "upgraded") is False
91
+ out = capsys.readouterr().out
92
+ assert "still 12.2.0" in out
93
+ assert "pillow==12.2.0" in out
94
+ assert "✔" not in out
95
+
96
+
97
+ def test_changed_version_is_reported(monkeypatch, capsys):
98
+ versions = iter(["12.2.0", "13.0.0"])
99
+ monkeypatch.setattr(main, "_installed_version", lambda ctx, pkg: next(versions))
100
+ monkeypatch.setattr(
101
+ main,
102
+ "_run_package_manager_command",
103
+ lambda *a, **k: subprocess.CompletedProcess(args=[], returncode=0),
104
+ )
105
+ assert main._apply_and_verify(SimpleNamespace(), "pillow", "add", "13.0.0", "upgraded")
106
+ assert "12.2.0 → 13.0.0" in capsys.readouterr().out
107
+
108
+
109
+ def test_uv_uses_minimum_version_requirement(monkeypatch):
110
+ calls = []
111
+ monkeypatch.setattr(
112
+ main.subprocess,
113
+ "run",
114
+ lambda cmd, cwd=None: calls.append(cmd) or subprocess.CompletedProcess(cmd, 0),
115
+ )
116
+ ctx = SimpleNamespace(root=".", is_poetry=False, is_uv=True, is_pdm=False)
117
+ main._run_package_manager_command(ctx, "pillow", "add", True, "13.0.0")
118
+ assert calls == [["uv", "add", "pillow>=13.0.0"]]
119
+
120
+
121
+ def test_naming_a_package_does_not_remove_it(monkeypatch, tmp_path):
122
+ report = DiagnosisReport(
123
+ issues=[
124
+ Issue(
125
+ category="unused",
126
+ code="UNUSED_PACKAGE",
127
+ severity="warning",
128
+ title="",
129
+ description="",
130
+ recommendation="",
131
+ package="redis",
132
+ )
133
+ ]
134
+ )
135
+ monkeypatch.setattr(main, "_run_scan", lambda **k: report)
136
+ monkeypatch.setattr(main, "_fix_unused", lambda *a: (_ for _ in ()).throw(AssertionError))
137
+ monkeypatch.setattr(main, "_fix_venv", lambda *a: 0)
138
+
139
+ result = CliRunner().invoke(main.app, ["fix", "redis", "--path", str(tmp_path)])
140
+ assert result.exit_code == 0, result.stdout
141
+ assert "pydoctor fix --remove" in result.stdout
@@ -104,3 +104,70 @@ class TestUnusedPackageScanner:
104
104
  # flask is imported via from-import, requests is not
105
105
  assert "requests" in pkg_names
106
106
  assert "flask" not in pkg_names
107
+
108
+
109
+ class TestFrameworkAwareDetection:
110
+ """Dependencies used without a plain ``import`` must not be flagged."""
111
+
112
+ def test_import_name_differs_from_distribution(self, tmp_path, monkeypatch):
113
+ monkeypatch.setattr(
114
+ unused_package_scanner,
115
+ "_distribution_map",
116
+ lambda ctx: {"decouple": ["python-decouple"]},
117
+ )
118
+ ctx = _make_ctx(
119
+ tmp_path,
120
+ py_files=[("settings.py", "from decouple import config\n")],
121
+ declared={"python-decouple": ""},
122
+ )
123
+ unused = [i.package for i in unused_package_scanner.scan(ctx) if i.code == "UNUSED_PACKAGE"]
124
+ assert "python-decouple" not in unused
125
+
126
+ def test_module_path_strings_count_as_usage(self, tmp_path, monkeypatch):
127
+ monkeypatch.setattr(
128
+ unused_package_scanner,
129
+ "_distribution_map",
130
+ lambda ctx: {
131
+ "whitenoise": ["whitenoise"],
132
+ "corsheaders": ["django-cors-headers"],
133
+ "django_redis": ["django-redis"],
134
+ },
135
+ )
136
+ settings = (
137
+ 'INSTALLED_APPS = ["corsheaders"]\n'
138
+ 'MIDDLEWARE = ["whitenoise.middleware.WhiteNoiseMiddleware"]\n'
139
+ 'CACHES = {"default": {"BACKEND": "django_redis.cache.RedisCache"}}\n'
140
+ )
141
+ ctx = _make_ctx(
142
+ tmp_path,
143
+ py_files=[("settings.py", settings)],
144
+ declared={"whitenoise": "", "django-cors-headers": "", "django-redis": "", "six": ""},
145
+ )
146
+ unused = [i.package for i in unused_package_scanner.scan(ctx) if i.code == "UNUSED_PACKAGE"]
147
+ assert unused == ["six"]
148
+
149
+ def test_arbitrary_strings_do_not_mark_packages_used(self, tmp_path, monkeypatch):
150
+ monkeypatch.setattr(unused_package_scanner, "_distribution_map", lambda ctx: {})
151
+ ctx = _make_ctx(
152
+ tmp_path,
153
+ py_files=[("main.py", 'GREETING = "six"\n')],
154
+ declared={"six": ""},
155
+ )
156
+ unused = [i.package for i in unused_package_scanner.scan(ctx) if i.code == "UNUSED_PACKAGE"]
157
+ assert unused == ["six"]
158
+
159
+ def test_server_named_in_procfile_is_used(self, tmp_path, monkeypatch):
160
+ monkeypatch.setattr(unused_package_scanner, "_distribution_map", lambda ctx: {})
161
+ (tmp_path / "Procfile").write_text("web: gunicorn app.wsgi\n", encoding="utf-8")
162
+ ctx = _make_ctx(
163
+ tmp_path,
164
+ py_files=[("main.py", "x = 1\n")],
165
+ declared={"gunicorn": "", "uvicorn": ""},
166
+ )
167
+ unused = [i.package for i in unused_package_scanner.scan(ctx) if i.code == "UNUSED_PACKAGE"]
168
+ assert unused == ["uvicorn"]
169
+
170
+ def test_title_has_no_rich_markup(self, tmp_path):
171
+ ctx = _make_ctx(tmp_path, py_files=[("main.py", "import os\n")], declared={"pandas": ""})
172
+ for issue in unused_package_scanner.scan(ctx):
173
+ assert "[dim]" not in issue.title and "[/]" not in issue.title
@@ -80,3 +80,78 @@ def test_vulns_detected_and_mapped(basic_ctx, monkeypatch):
80
80
  assert critical_issue.package == "requests"
81
81
  assert critical_issue.code == "SEC_VULNERABILITY"
82
82
  assert warning_issue.package == "flask"
83
+
84
+
85
+ def test_unreachable_osv_fails_closed(basic_ctx, monkeypatch):
86
+ """When OSV cannot be reached, report an ERROR instead of 'no vulnerabilities'."""
87
+
88
+ class FakeClient:
89
+ def __init__(self, **kwargs):
90
+ self.failed_packages = []
91
+
92
+ def query_packages(self, pkgs):
93
+ self.failed_packages = list(pkgs)
94
+ return []
95
+
96
+ monkeypatch.setattr("pydoctor.scanners.vulnerability_scanner.OSVClient", FakeClient)
97
+
98
+ issues = vulnerability_scanner.scan(basic_ctx)
99
+ codes = [i.code for i in issues]
100
+ assert "SEC_SCAN_INCOMPLETE" in codes
101
+ assert "SEC_NO_VULNS" not in codes
102
+ incomplete = next(i for i in issues if i.code == "SEC_SCAN_INCOMPLETE")
103
+ assert incomplete.severity == Severity.ERROR
104
+
105
+
106
+ def test_osv_client_records_failed_packages(monkeypatch, tmp_path):
107
+ """OSVClient records unchecked packages when every retry fails."""
108
+ import requests
109
+
110
+ from pydoctor.cache.cache_manager import CacheManager
111
+ from pydoctor.security import osv_client as osv_module
112
+
113
+ monkeypatch.setattr(osv_module.time, "sleep", lambda _s: None)
114
+ client = osv_module.OSVClient(cache=CacheManager(cache_file=tmp_path / "c.json"))
115
+
116
+ def boom(*args, **kwargs):
117
+ raise requests.ConnectionError("offline")
118
+
119
+ monkeypatch.setattr(client._session, "post", boom)
120
+ records = client.query_packages({"requests": "2.31.0"})
121
+ assert records == []
122
+ assert client.failed_packages == ["requests"]
123
+
124
+
125
+ def test_aliased_advisories_reported_once(basic_ctx, monkeypatch):
126
+ """A GHSA and a PYSEC record that alias each other count as one vulnerability."""
127
+
128
+ class FakeClient:
129
+ def __init__(self, **kwargs):
130
+ self.failed_packages = []
131
+
132
+ def query_packages(self, pkgs):
133
+ return [
134
+ VulnerabilityRecord(
135
+ vuln_id="GHSA-6q48-6c7w-9gw2",
136
+ package="certifi",
137
+ version="2026.7.22",
138
+ summary="certifi bundles emSign roots with compromised keys",
139
+ severity="HIGH",
140
+ aliases=["PYSEC-2026-4320"],
141
+ ),
142
+ VulnerabilityRecord(
143
+ vuln_id="PYSEC-2026-4320",
144
+ package="certifi",
145
+ version="2026.7.22",
146
+ summary="certifi bundles emSign roots with compromised keys",
147
+ severity="CRITICAL",
148
+ aliases=["GHSA-6q48-6c7w-9gw2"],
149
+ ),
150
+ ]
151
+
152
+ monkeypatch.setattr("pydoctor.scanners.vulnerability_scanner.OSVClient", FakeClient)
153
+
154
+ issues = vulnerability_scanner.scan(basic_ctx)
155
+ vulns = [i for i in issues if i.code == "SEC_VULNERABILITY"]
156
+ assert len(vulns) == 1
157
+ assert vulns[0].severity == Severity.CRITICAL
File without changes
File without changes