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.
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/PKG-INFO +7 -1
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/README.md +6 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/__init__.py +1 -1
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/cli/main.py +155 -39
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/scanners/unused_package_scanner.py +111 -8
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/utils/parser_utils.py +31 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/PKG-INFO +7 -1
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/SOURCES.txt +1 -0
- {pydoctor_cli-2.0.1 → 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.1 → pydoctor_cli-2.1.0}/tests/test_unused_scanner.py +67 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/LICENSE +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/analysis/__init__.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/analysis/health_score.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/cache/__init__.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/cache/cache_manager.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/cli/__init__.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/config/__init__.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/config/settings.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/core/__init__.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/core/analyzer.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/core/project.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/core/report.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/performance/__init__.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/performance/executor.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/performance/parallel.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/reports/__init__.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/reports/json_formatter.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/reports/table_formatter.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/reports/terminal_colors.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/scanners/__init__.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/scanners/ci_scanner.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/scanners/dependency_scanner.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/scanners/env_scanner.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/scanners/outdated_package_scanner.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/scanners/vulnerability_scanner.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/security/__init__.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/security/osv_client.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/utils/__init__.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/utils/file_utils.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/utils/pip_utils.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor/utils/subprocess_utils.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/dependency_links.txt +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/entry_points.txt +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/requires.txt +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/pydoctor_cli.egg-info/top_level.txt +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/setup.cfg +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/tests/conftest.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/tests/test_ci_scanner.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/tests/test_cli_ux.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/tests/test_dependency_scanner.py +0 -0
- {pydoctor_cli-2.0.1 → pydoctor_cli-2.1.0}/tests/test_env_scanner.py +0 -0
- {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
|
|
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.
|
|
@@ -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
|
|
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
|
|
|
@@ -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
|
|
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.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pydoctor-cli"
|
|
7
|
-
version = "2.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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|