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.
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/PKG-INFO +11 -1
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/README.md +10 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/__init__.py +1 -1
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/cli/main.py +155 -39
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/scanners/unused_package_scanner.py +111 -8
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/scanners/vulnerability_scanner.py +71 -3
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/security/osv_client.py +12 -2
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/utils/parser_utils.py +31 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/PKG-INFO +11 -1
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/SOURCES.txt +1 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pyproject.toml +1 -1
- pydoctor_cli-2.1.0/tests/test_fix_command.py +141 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/tests/test_unused_scanner.py +67 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/tests/test_vulnerability_scanner.py +75 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/LICENSE +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/analysis/__init__.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/analysis/health_score.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/cache/__init__.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/cache/cache_manager.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/cli/__init__.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/config/__init__.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/config/settings.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/core/__init__.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/core/analyzer.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/core/project.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/core/report.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/performance/__init__.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/performance/executor.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/performance/parallel.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/reports/__init__.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/reports/json_formatter.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/reports/table_formatter.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/reports/terminal_colors.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/scanners/__init__.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/scanners/ci_scanner.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/scanners/dependency_scanner.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/scanners/env_scanner.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/scanners/outdated_package_scanner.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/security/__init__.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/utils/__init__.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/utils/file_utils.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/utils/pip_utils.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor/utils/subprocess_utils.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/dependency_links.txt +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/entry_points.txt +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/requires.txt +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/top_level.txt +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/setup.cfg +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/tests/conftest.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/tests/test_ci_scanner.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/tests/test_cli_ux.py +0 -0
- {pydoctor_cli-2.0.0 → pydoctor_cli-2.1.0}/tests/test_dependency_scanner.py +0 -0
- {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.
|
|
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.
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
return
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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=
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
454
|
-
if not
|
|
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
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
471
|
-
|
|
472
|
-
|
|
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
|
|
19
|
-
|
|
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"
|
|
76
|
-
description=
|
|
77
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pydoctor-cli"
|
|
7
|
-
version = "2.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|