pydoctor-cli 2.0.1__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.1 → pydoctor_cli-2.1.0}/PKG-INFO +7 -1
  2. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/README.md +6 -0
  3. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/__init__.py +1 -1
  4. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/cli/main.py +155 -39
  5. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/scanners/unused_package_scanner.py +111 -8
  6. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/utils/parser_utils.py +31 -0
  7. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/PKG-INFO +7 -1
  8. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/SOURCES.txt +1 -0
  9. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pyproject.toml +1 -1
  10. pydoctor_cli-2.1.0/tests/test_fix_command.py +141 -0
  11. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/tests/test_unused_scanner.py +67 -0
  12. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/LICENSE +0 -0
  13. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/analysis/__init__.py +0 -0
  14. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/analysis/health_score.py +0 -0
  15. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/cache/__init__.py +0 -0
  16. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/cache/cache_manager.py +0 -0
  17. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/cli/__init__.py +0 -0
  18. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/config/__init__.py +0 -0
  19. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/config/settings.py +0 -0
  20. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/core/__init__.py +0 -0
  21. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/core/analyzer.py +0 -0
  22. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/core/project.py +0 -0
  23. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/core/report.py +0 -0
  24. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/performance/__init__.py +0 -0
  25. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/performance/executor.py +0 -0
  26. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/performance/parallel.py +0 -0
  27. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/reports/__init__.py +0 -0
  28. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/reports/json_formatter.py +0 -0
  29. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/reports/table_formatter.py +0 -0
  30. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/reports/terminal_colors.py +0 -0
  31. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/scanners/__init__.py +0 -0
  32. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/scanners/ci_scanner.py +0 -0
  33. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/scanners/dependency_scanner.py +0 -0
  34. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/scanners/env_scanner.py +0 -0
  35. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/scanners/outdated_package_scanner.py +0 -0
  36. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/scanners/vulnerability_scanner.py +0 -0
  37. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/security/__init__.py +0 -0
  38. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/security/osv_client.py +0 -0
  39. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/utils/__init__.py +0 -0
  40. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/utils/file_utils.py +0 -0
  41. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/utils/pip_utils.py +0 -0
  42. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/utils/subprocess_utils.py +0 -0
  43. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/dependency_links.txt +0 -0
  44. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/entry_points.txt +0 -0
  45. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/requires.txt +0 -0
  46. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/top_level.txt +0 -0
  47. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/setup.cfg +0 -0
  48. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/tests/conftest.py +0 -0
  49. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/tests/test_ci_scanner.py +0 -0
  50. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/tests/test_cli_ux.py +0 -0
  51. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/tests/test_dependency_scanner.py +0 -0
  52. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/tests/test_env_scanner.py +0 -0
  53. {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/tests/test_vulnerability_scanner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydoctor-cli
3
- Version: 2.0.1
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,12 @@ 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
+
326
332
  ### v2.0.1
327
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.
328
334
  - **No more double-counted advisories:** advisories listed under several IDs (e.g. GHSA and PYSEC) are reported and scored once.
@@ -74,6 +74,12 @@ 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
+
77
83
  ### v2.0.1
78
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.
79
85
  - **No more double-counted advisories:** advisories listed under several IDs (e.g. GHSA and PYSEC) are reported and scored once.
@@ -9,6 +9,6 @@ Author: Favour Bamgboye
9
9
  License: MIT
10
10
  """
11
11
 
12
- __version__ = "2.0.1"
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.1 (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
 
@@ -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.1
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,12 @@ 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
+
326
332
  ### v2.0.1
327
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.
328
334
  - **No more double-counted advisories:** advisories listed under several IDs (e.g. GHSA and PYSEC) are reported and scored once.
@@ -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.1"
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
File without changes
File without changes