orphans 1.0.0__py3-none-any.whl
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.
- core/aliases.py +51 -0
- core/analyzer.py +11 -0
- core/cleanup.py +134 -0
- core/cli_output.py +84 -0
- core/config.py +78 -0
- core/disk_analyzer.py +72 -0
- core/duplicate_scanner.py +43 -0
- core/format.py +11 -0
- core/graph.py +41 -0
- core/health_scorer.py +83 -0
- core/project_scanner.py +229 -0
- core/repair.py +153 -0
- core/scan.py +58 -0
- core/scanners/brew_scanner.py +79 -0
- core/scanners/cargo_scanner.py +63 -0
- core/scanners/npm_scanner.py +95 -0
- core/scanners/pip_scanner.py +134 -0
- core/snapshot.py +200 -0
- core/uninstaller.py +92 -0
- core/vulnerability_scanner.py +28 -0
- main.py +146 -0
- models/package.py +49 -0
- models/vulnerability.py +22 -0
- orphans-1.0.0.dist-info/METADATA +126 -0
- orphans-1.0.0.dist-info/RECORD +34 -0
- orphans-1.0.0.dist-info/WHEEL +4 -0
- orphans-1.0.0.dist-info/entry_points.txt +2 -0
- orphans-1.0.0.dist-info/licenses/LICENSE +21 -0
- registry/brew_client.py +44 -0
- registry/cache.py +62 -0
- registry/crates_client.py +46 -0
- registry/npm_registry_client.py +45 -0
- registry/osv_client.py +157 -0
- registry/pypi_client.py +68 -0
core/aliases.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
KNOWN_ALIASES: list[tuple[str, str]] = [
|
|
4
|
+
("Pillow", "pil"),
|
|
5
|
+
("PyYAML", "yaml"),
|
|
6
|
+
("python-dateutil", "dateutil"),
|
|
7
|
+
("python-dotenv", "dotenv"),
|
|
8
|
+
("markdown-it-py", "markdown_it"),
|
|
9
|
+
("paho-mqtt", "paho"),
|
|
10
|
+
("influxdb-client", "influxdb_client"),
|
|
11
|
+
("Pygments", "pygments"),
|
|
12
|
+
("pyserial", "serial"),
|
|
13
|
+
("cffi", "cffi"),
|
|
14
|
+
("cryptography", "cryptography"),
|
|
15
|
+
("certifi", "certifi"),
|
|
16
|
+
("six", "six"),
|
|
17
|
+
("rich", "rich"),
|
|
18
|
+
("click", "click"),
|
|
19
|
+
("fastapi", "fastapi"),
|
|
20
|
+
("uvicorn", "uvicorn"),
|
|
21
|
+
("numpy", "numpy"),
|
|
22
|
+
("pydantic", "pydantic"),
|
|
23
|
+
("starlette", "starlette"),
|
|
24
|
+
("httptools", "httptools"),
|
|
25
|
+
("uvloop", "uvloop"),
|
|
26
|
+
("websockets", "websockets"),
|
|
27
|
+
("watchfiles", "watchfiles"),
|
|
28
|
+
("python-multipart", "python_multipart"),
|
|
29
|
+
("aenum", "aenum"),
|
|
30
|
+
("reactivex", "reactivex"),
|
|
31
|
+
("reedsolo", "reedsolo"),
|
|
32
|
+
("typing-extensions", "typing_extensions"),
|
|
33
|
+
("typing-inspection", "typing_inspection"),
|
|
34
|
+
("pydantic-core", "pydantic_core"),
|
|
35
|
+
("annotated-types", "annotated_types"),
|
|
36
|
+
("rich-click", "rich_click"),
|
|
37
|
+
("esp-idf-nvs-partition-gen", "esp_idf_nvs_partition_gen"),
|
|
38
|
+
("intelhex", "intelhex"),
|
|
39
|
+
("bitstring", "bitstring"),
|
|
40
|
+
("bitarray", "bitarray"),
|
|
41
|
+
("esptool", "esptool"),
|
|
42
|
+
("tibs", "tibs"),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
PACKAGE_TO_IMPORT: dict[str, str] = {
|
|
46
|
+
p.lower().replace("-", "_"): i for p, i in KNOWN_ALIASES
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
IMPORT_TO_PACKAGE: dict[str, str] = {
|
|
50
|
+
i: p for p, i in KNOWN_ALIASES
|
|
51
|
+
}
|
core/analyzer.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from models.package import InstallType, Package
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def find_orphans(packages: list[Package]) -> list[Package]:
|
|
7
|
+
return [
|
|
8
|
+
pkg for pkg in packages
|
|
9
|
+
if pkg.install_type == InstallType.DEPENDENCY
|
|
10
|
+
and not pkg.dependents
|
|
11
|
+
]
|
core/cleanup.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from core.analyzer import find_orphans
|
|
6
|
+
from core.format import fmt_size
|
|
7
|
+
from core.graph import build_dependency_graph
|
|
8
|
+
from core.health_scorer import score as score_health
|
|
9
|
+
from core.uninstaller import UninstallError, uninstall
|
|
10
|
+
from models.package import Package
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class CleanupResult:
|
|
15
|
+
removed: list[Package] = field(default_factory=list)
|
|
16
|
+
failed: list[tuple[Package, str]] = field(default_factory=list)
|
|
17
|
+
skipped: list[Package] = field(default_factory=list)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run(
|
|
21
|
+
packages: list[Package],
|
|
22
|
+
*,
|
|
23
|
+
yes: bool = False,
|
|
24
|
+
on_confirm: bool = False,
|
|
25
|
+
) -> CleanupResult:
|
|
26
|
+
build_dependency_graph(packages)
|
|
27
|
+
orphans = find_orphans(packages)
|
|
28
|
+
score_health(packages, orphans)
|
|
29
|
+
|
|
30
|
+
if not orphans:
|
|
31
|
+
return CleanupResult()
|
|
32
|
+
|
|
33
|
+
if not yes and not on_confirm:
|
|
34
|
+
return CleanupResult(skipped=orphans)
|
|
35
|
+
|
|
36
|
+
result = CleanupResult()
|
|
37
|
+
for pkg in orphans:
|
|
38
|
+
try:
|
|
39
|
+
uninstall(pkg)
|
|
40
|
+
result.removed.append(pkg)
|
|
41
|
+
except UninstallError as e:
|
|
42
|
+
result.failed.append((pkg, str(e)))
|
|
43
|
+
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cli_run(packages: list[Package], *, yes: bool = False) -> None:
|
|
48
|
+
from rich.console import Console
|
|
49
|
+
from rich.prompt import Confirm
|
|
50
|
+
from rich.table import Table
|
|
51
|
+
|
|
52
|
+
console = Console()
|
|
53
|
+
|
|
54
|
+
build_dependency_graph(packages)
|
|
55
|
+
orphans = find_orphans(packages)
|
|
56
|
+
score_health(packages, orphans)
|
|
57
|
+
|
|
58
|
+
if not orphans:
|
|
59
|
+
console.print("\n[bold green]No orphan packages found. Nothing to clean up.[/bold green]")
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
orphans.sort(key=lambda p: p.name.lower())
|
|
63
|
+
total_size = sum(p.size_bytes for p in orphans)
|
|
64
|
+
|
|
65
|
+
table = Table(title=f"Orphan Packages ({len(orphans)} found)")
|
|
66
|
+
table.add_column("Package", style="cyan")
|
|
67
|
+
table.add_column("Version", style="white")
|
|
68
|
+
table.add_column("Manager", style="yellow")
|
|
69
|
+
table.add_column("Size", style="green")
|
|
70
|
+
table.add_column("Dependents", style="magenta")
|
|
71
|
+
|
|
72
|
+
for pkg in orphans:
|
|
73
|
+
table.add_row(
|
|
74
|
+
pkg.name,
|
|
75
|
+
pkg.version or "—",
|
|
76
|
+
pkg.manager.value,
|
|
77
|
+
fmt_size(pkg.size_bytes) if pkg.size_bytes else "—",
|
|
78
|
+
str(len(pkg.dependents)),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
console.print(table)
|
|
82
|
+
console.print(f"\nTotal reclaimable space: [bold]{fmt_size(total_size)}[/bold]")
|
|
83
|
+
console.print(f"Total packages to remove: [bold]{len(orphans)}[/bold]")
|
|
84
|
+
|
|
85
|
+
if not yes:
|
|
86
|
+
confirmed = Confirm.ask("\nRemove these orphan packages?")
|
|
87
|
+
if not confirmed:
|
|
88
|
+
console.print("[yellow]Cleanup cancelled.[/yellow]")
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
from core.snapshot import create as create_snapshot
|
|
92
|
+
snap_id = create_snapshot(packages)
|
|
93
|
+
console.print(f"[dim]Snapshot: {snap_id}[/dim]")
|
|
94
|
+
|
|
95
|
+
result = CleanupResult()
|
|
96
|
+
with console.status("[bold green]Removing orphan packages...") as status:
|
|
97
|
+
for pkg in orphans:
|
|
98
|
+
status.update(f"[bold green]Removing {pkg.name}...[/bold green]")
|
|
99
|
+
try:
|
|
100
|
+
uninstall(pkg)
|
|
101
|
+
result.removed.append(pkg)
|
|
102
|
+
console.print(f" [green]\u2713[/green] Removed [cyan]{pkg.name}[/cyan]")
|
|
103
|
+
except UninstallError as e:
|
|
104
|
+
result.failed.append((pkg, str(e)))
|
|
105
|
+
console.print(f" [red]\u2717[/red] Failed to remove [cyan]{pkg.name}[/cyan]: {e}")
|
|
106
|
+
|
|
107
|
+
if result.removed:
|
|
108
|
+
console.print(
|
|
109
|
+
f"\n[bold green]Successfully removed {len(result.removed)} package(s).[/bold green]"
|
|
110
|
+
)
|
|
111
|
+
if result.failed:
|
|
112
|
+
console.print(f"\n[bold red]Failed to remove {len(result.failed)} package(s).[/bold red]")
|
|
113
|
+
for pkg, err in result.failed:
|
|
114
|
+
console.print(f" [red]{pkg.name}[/red]: {err}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def dry_run(packages: list[Package]) -> None:
|
|
118
|
+
from rich.console import Console
|
|
119
|
+
from rich.table import Table
|
|
120
|
+
|
|
121
|
+
console = Console()
|
|
122
|
+
build_dependency_graph(packages)
|
|
123
|
+
orphans = find_orphans(packages)
|
|
124
|
+
score_health(packages, orphans)
|
|
125
|
+
if not orphans:
|
|
126
|
+
console.print("[green]No orphan packages found.[/green]")
|
|
127
|
+
return
|
|
128
|
+
table = Table(title="Would remove (dry run)")
|
|
129
|
+
table.add_column("Package", style="cyan")
|
|
130
|
+
table.add_column("Size")
|
|
131
|
+
for p in sorted(orphans, key=lambda x: x.name.lower()):
|
|
132
|
+
table.add_row(p.name, fmt_size(p.size_bytes) if p.size_bytes else "")
|
|
133
|
+
console.print(table)
|
|
134
|
+
console.print(f"[bold]{len(orphans)} package(s) would be removed.[/bold]")
|
core/cli_output.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from core.format import fmt_size
|
|
4
|
+
from core.project_scanner import Project
|
|
5
|
+
from models.package import Package
|
|
6
|
+
|
|
7
|
+
_HEALTH_COLOR = {
|
|
8
|
+
"healthy": "green",
|
|
9
|
+
"outdated": "yellow",
|
|
10
|
+
"orphan": "magenta",
|
|
11
|
+
"deprecated": "red",
|
|
12
|
+
"vulnerable": "red",
|
|
13
|
+
"unknown": "dim",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _color_for(status: str) -> str:
|
|
18
|
+
return _HEALTH_COLOR.get(status, "dim")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render_packages(packages: list[Package], *, no_color: bool = False) -> None:
|
|
22
|
+
from rich.console import Console
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
|
|
25
|
+
console = Console(no_color=no_color)
|
|
26
|
+
orphans = [p for p in packages if p.health_status.value == "orphan"]
|
|
27
|
+
total = sum(p.size_bytes for p in packages)
|
|
28
|
+
|
|
29
|
+
table = Table(title=f"Packages ({len(packages)})")
|
|
30
|
+
table.add_column("Package", style="cyan", no_wrap=True)
|
|
31
|
+
table.add_column("Version", style="white")
|
|
32
|
+
table.add_column("Manager", style="yellow")
|
|
33
|
+
table.add_column("Type", style="blue")
|
|
34
|
+
table.add_column("Size", justify="right")
|
|
35
|
+
table.add_column("Status")
|
|
36
|
+
table.add_column("Deps", justify="right")
|
|
37
|
+
table.add_column("Used", justify="right")
|
|
38
|
+
|
|
39
|
+
for pkg in sorted(packages, key=lambda p: p.name.lower()):
|
|
40
|
+
status = pkg.health_status.value
|
|
41
|
+
style = "" if no_color else _color_for(status)
|
|
42
|
+
table.add_row(
|
|
43
|
+
pkg.name,
|
|
44
|
+
pkg.version or "",
|
|
45
|
+
pkg.manager.value,
|
|
46
|
+
pkg.install_type.value,
|
|
47
|
+
fmt_size(pkg.size_bytes) if pkg.size_bytes else "",
|
|
48
|
+
f"[{style}]{status}[/{style}]" if style else status,
|
|
49
|
+
str(len(pkg.dependents)),
|
|
50
|
+
str(len(pkg.used_in_projects)),
|
|
51
|
+
)
|
|
52
|
+
console.print(table)
|
|
53
|
+
console.print(f"Total size: [bold]{fmt_size(total)}[/bold]")
|
|
54
|
+
if orphans:
|
|
55
|
+
console.print(f"[magenta]{len(orphans)} orphan package(s)[/magenta]")
|
|
56
|
+
console.print(
|
|
57
|
+
"[dim]legend: healthy green / outdated yellow / orphan magenta / deprecated red[/dim]"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render_projects(projects: list[Project]) -> None:
|
|
62
|
+
from rich.console import Console
|
|
63
|
+
from rich.table import Table
|
|
64
|
+
|
|
65
|
+
console = Console()
|
|
66
|
+
table = Table(title=f"Projects ({len(projects)})")
|
|
67
|
+
table.add_column("Name", style="cyan")
|
|
68
|
+
table.add_column("Manager", style="yellow")
|
|
69
|
+
table.add_column("Imports", justify="right")
|
|
70
|
+
table.add_column("Deps", justify="right")
|
|
71
|
+
for p in sorted(projects, key=lambda p: p.name.lower()):
|
|
72
|
+
table.add_row(p.name, p.manager.value, str(len(p.imports)), str(len(p.dependencies)))
|
|
73
|
+
console.print(table)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render_summary(packages: list[Package]) -> None:
|
|
77
|
+
from rich.console import Console
|
|
78
|
+
|
|
79
|
+
console = Console()
|
|
80
|
+
counts: dict[str, int] = {}
|
|
81
|
+
for pkg in packages:
|
|
82
|
+
counts[pkg.health_status.value] = counts.get(pkg.health_status.value, 0) + 1
|
|
83
|
+
parts = [f"{v} {k}" for k, v in sorted(counts.items())]
|
|
84
|
+
console.print("Health: " + ", ".join(parts))
|
core/config.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_CONFIG_DIR = Path.home() / ".orphans"
|
|
7
|
+
_CONFIG_PATH = _CONFIG_DIR / "config.json"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _ensure_dir() -> None:
|
|
11
|
+
_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_scan_paths() -> list[Path]:
|
|
15
|
+
_ensure_dir()
|
|
16
|
+
if _CONFIG_PATH.is_file():
|
|
17
|
+
try:
|
|
18
|
+
data = json.loads(_CONFIG_PATH.read_text())
|
|
19
|
+
return [Path(p) for p in data.get("scan_paths", [])]
|
|
20
|
+
except (json.JSONDecodeError, OSError):
|
|
21
|
+
pass
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save_scan_paths(paths: list[Path]) -> None:
|
|
26
|
+
_ensure_dir()
|
|
27
|
+
data = _load_all()
|
|
28
|
+
data["scan_paths"] = [str(p) for p in paths]
|
|
29
|
+
_save_all(data)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_enabled_managers() -> list[str]:
|
|
33
|
+
_ensure_dir()
|
|
34
|
+
if _CONFIG_PATH.is_file():
|
|
35
|
+
try:
|
|
36
|
+
data = json.loads(_CONFIG_PATH.read_text())
|
|
37
|
+
return data.get("enabled_managers", ["pip", "npm", "cargo", "brew"])
|
|
38
|
+
except (json.JSONDecodeError, OSError):
|
|
39
|
+
pass
|
|
40
|
+
return ["pip", "npm", "cargo", "brew"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def save_enabled_managers(managers: list[str]) -> None:
|
|
44
|
+
_ensure_dir()
|
|
45
|
+
data = _load_all()
|
|
46
|
+
data["enabled_managers"] = managers
|
|
47
|
+
_save_all(data)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_exclude_patterns() -> list[str]:
|
|
51
|
+
_ensure_dir()
|
|
52
|
+
if _CONFIG_PATH.is_file():
|
|
53
|
+
try:
|
|
54
|
+
data = json.loads(_CONFIG_PATH.read_text())
|
|
55
|
+
return data.get("exclude_patterns", [])
|
|
56
|
+
except (json.JSONDecodeError, OSError):
|
|
57
|
+
pass
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def save_exclude_patterns(patterns: list[str]) -> None:
|
|
62
|
+
_ensure_dir()
|
|
63
|
+
data = _load_all()
|
|
64
|
+
data["exclude_patterns"] = patterns
|
|
65
|
+
_save_all(data)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _load_all() -> dict:
|
|
69
|
+
if _CONFIG_PATH.is_file():
|
|
70
|
+
try:
|
|
71
|
+
return json.loads(_CONFIG_PATH.read_text())
|
|
72
|
+
except (json.JSONDecodeError, OSError):
|
|
73
|
+
pass
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _save_all(data: dict) -> None:
|
|
78
|
+
_CONFIG_PATH.write_text(json.dumps(data, indent=2))
|
core/disk_analyzer.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from core.project_scanner import Project
|
|
6
|
+
from models.package import Package
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ProjectDiskUsage:
|
|
11
|
+
name: str
|
|
12
|
+
path: str
|
|
13
|
+
manager: str
|
|
14
|
+
total_bytes: int = 0
|
|
15
|
+
exclusive_bytes: int = 0
|
|
16
|
+
shared_bytes: int = 0
|
|
17
|
+
package_count: int = 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def analyze(projects: list[Project], packages: list[Package]) -> list[ProjectDiskUsage]:
|
|
21
|
+
if not projects:
|
|
22
|
+
return []
|
|
23
|
+
|
|
24
|
+
pkg_size_map: dict[str, int] = {p.name: p.size_bytes for p in packages}
|
|
25
|
+
project_packages: dict[Project, list[str]] = {}
|
|
26
|
+
for proj in projects:
|
|
27
|
+
project_packages[proj] = list(proj.dependencies)
|
|
28
|
+
|
|
29
|
+
path_to_packages: dict[str, set[str]] = {}
|
|
30
|
+
for pkg in packages:
|
|
31
|
+
for proj_path in pkg.used_in_projects:
|
|
32
|
+
path_to_packages.setdefault(proj_path, set()).add(pkg.name)
|
|
33
|
+
|
|
34
|
+
pkg_project_count: dict[str, int] = {}
|
|
35
|
+
for proj in projects:
|
|
36
|
+
for dep in proj.dependencies:
|
|
37
|
+
pkg_project_count[dep] = pkg_project_count.get(dep, 0) + 1
|
|
38
|
+
for pkg_names in path_to_packages.values():
|
|
39
|
+
for name in pkg_names:
|
|
40
|
+
pkg_project_count[name] = pkg_project_count.get(name, 0) + 1
|
|
41
|
+
|
|
42
|
+
results: list[ProjectDiskUsage] = []
|
|
43
|
+
for proj in projects:
|
|
44
|
+
proj_pkgs = project_packages.get(proj, [])
|
|
45
|
+
proj_path_str = str(proj.path)
|
|
46
|
+
pkgs_from_imports = path_to_packages.get(proj_path_str, set())
|
|
47
|
+
|
|
48
|
+
all_pkg_names = set(proj_pkgs) | pkgs_from_imports
|
|
49
|
+
total = sum(pkg_size_map.get(n, 0) for n in all_pkg_names)
|
|
50
|
+
|
|
51
|
+
exclusive = 0
|
|
52
|
+
shared = 0
|
|
53
|
+
for name in all_pkg_names:
|
|
54
|
+
count = pkg_project_count.get(name, 0)
|
|
55
|
+
sz = pkg_size_map.get(name, 0)
|
|
56
|
+
if count <= 1:
|
|
57
|
+
exclusive += sz
|
|
58
|
+
else:
|
|
59
|
+
shared += sz
|
|
60
|
+
|
|
61
|
+
results.append(ProjectDiskUsage(
|
|
62
|
+
name=proj.name,
|
|
63
|
+
path=proj_path_str,
|
|
64
|
+
manager=proj.manager.value,
|
|
65
|
+
total_bytes=total,
|
|
66
|
+
exclusive_bytes=exclusive,
|
|
67
|
+
shared_bytes=shared,
|
|
68
|
+
package_count=len(all_pkg_names),
|
|
69
|
+
))
|
|
70
|
+
|
|
71
|
+
results.sort(key=lambda r: -r.total_bytes)
|
|
72
|
+
return results
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from models.package import Package
|
|
4
|
+
|
|
5
|
+
_KNOWN_CROSS_MANAGER: dict[str, list[str]] = {}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def scan(packages: list[Package]) -> None:
|
|
9
|
+
for pkg in packages:
|
|
10
|
+
pkg.duplicate_of = []
|
|
11
|
+
|
|
12
|
+
norm_map: dict[str, list[Package]] = {}
|
|
13
|
+
for pkg in packages:
|
|
14
|
+
norm = _canonical(pkg.name)
|
|
15
|
+
norm_map.setdefault(norm, []).append(pkg)
|
|
16
|
+
|
|
17
|
+
for pkg in packages:
|
|
18
|
+
norm = _canonical(pkg.name)
|
|
19
|
+
same_norm = norm_map.get(norm, [])
|
|
20
|
+
same_aliases = _KNOWN_CROSS_MANAGER.get(norm, [])
|
|
21
|
+
|
|
22
|
+
seen: set[str] = set()
|
|
23
|
+
for other in same_norm:
|
|
24
|
+
if other is pkg or other.manager == pkg.manager:
|
|
25
|
+
continue
|
|
26
|
+
key = f"{other.manager.value}:{norm}"
|
|
27
|
+
if key not in seen:
|
|
28
|
+
seen.add(key)
|
|
29
|
+
pkg.duplicate_of.append(f"{other.name} ({other.manager.value})")
|
|
30
|
+
|
|
31
|
+
for alias in same_aliases:
|
|
32
|
+
alias_lower = alias.lower()
|
|
33
|
+
for other in norm_map.get(alias_lower, []):
|
|
34
|
+
if other is pkg or other.manager == pkg.manager:
|
|
35
|
+
continue
|
|
36
|
+
key = f"{other.manager.value}:{alias_lower}"
|
|
37
|
+
if key not in seen:
|
|
38
|
+
seen.add(key)
|
|
39
|
+
pkg.duplicate_of.append(f"{other.name} ({other.manager.value})")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _canonical(name: str) -> str:
|
|
43
|
+
return name.lower().replace("-", "_").replace(".", "_").replace(" ", "_")
|
core/format.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def fmt_size(bytes_: int) -> str:
|
|
5
|
+
if bytes_ < 1024:
|
|
6
|
+
return f"{bytes_} B"
|
|
7
|
+
if bytes_ < 1024 ** 2:
|
|
8
|
+
return f"{bytes_ / 1024:.1f} KB"
|
|
9
|
+
if bytes_ < 1024 ** 3:
|
|
10
|
+
return f"{bytes_ / 1024 ** 2:.1f} MB"
|
|
11
|
+
return f"{bytes_ / 1024 ** 3:.1f} GB"
|
core/graph.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from models.package import Manager, Package
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _normalize(name: str) -> str:
|
|
7
|
+
return name.lower().replace("-", "_")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _build_index(packages: list[Package]) -> dict[str, Package]:
|
|
11
|
+
index: dict[str, Package] = {}
|
|
12
|
+
for p in packages:
|
|
13
|
+
index[_normalize(p.name)] = p
|
|
14
|
+
index[p.name] = p
|
|
15
|
+
return index
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_INDEXES = {
|
|
19
|
+
Manager.PIP: "pip",
|
|
20
|
+
Manager.NPM: "npm",
|
|
21
|
+
Manager.CARGO: "cargo",
|
|
22
|
+
Manager.BREW: "brew",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_dependency_graph(packages: list[Package]) -> None:
|
|
27
|
+
indexes: dict[str, dict[str, Package]] = {}
|
|
28
|
+
for mgr, key in _INDEXES.items():
|
|
29
|
+
mgr_pkgs = [p for p in packages if p.manager == mgr]
|
|
30
|
+
if mgr_pkgs:
|
|
31
|
+
indexes[key] = _build_index(mgr_pkgs)
|
|
32
|
+
|
|
33
|
+
for pkg in packages:
|
|
34
|
+
key = _INDEXES.get(pkg.manager, "")
|
|
35
|
+
index = indexes.get(key)
|
|
36
|
+
if index is None:
|
|
37
|
+
continue
|
|
38
|
+
for dep_name in pkg.dependencies:
|
|
39
|
+
dep = index.get(dep_name) or index.get(_normalize(dep_name))
|
|
40
|
+
if dep is not None:
|
|
41
|
+
dep.dependents.append(pkg.name)
|
core/health_scorer.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from packaging.version import Version
|
|
6
|
+
|
|
7
|
+
from core.vulnerability_scanner import scan as scan_vulnerabilities
|
|
8
|
+
from models.package import HealthStatus, Manager, Package
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def score(packages: list[Package], orphans: list[Package]) -> None:
|
|
12
|
+
orphan_names = {p.name for p in packages if p in orphans}
|
|
13
|
+
|
|
14
|
+
for pkg in packages:
|
|
15
|
+
if pkg.name in orphan_names:
|
|
16
|
+
pkg.health_status = HealthStatus.ORPHAN
|
|
17
|
+
else:
|
|
18
|
+
pkg.health_status = HealthStatus.HEALTHY
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def score_from_registry(packages: list[Package]) -> None:
|
|
22
|
+
asyncio.run(_score_all(packages))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _score_all(packages: list[Package]) -> None:
|
|
26
|
+
tasks = []
|
|
27
|
+
for pkg in packages:
|
|
28
|
+
if pkg.health_status != HealthStatus.HEALTHY:
|
|
29
|
+
continue
|
|
30
|
+
tasks.append(_check_package(pkg))
|
|
31
|
+
|
|
32
|
+
if tasks:
|
|
33
|
+
await asyncio.gather(*tasks)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def _check_package(pkg: Package) -> None:
|
|
37
|
+
if pkg.manager == Manager.PIP:
|
|
38
|
+
from registry.pypi_client import fetch as pypi_fetch
|
|
39
|
+
info = await pypi_fetch(pkg.name)
|
|
40
|
+
elif pkg.manager == Manager.NPM:
|
|
41
|
+
from registry.npm_registry_client import fetch as npm_fetch
|
|
42
|
+
info = await npm_fetch(pkg.name)
|
|
43
|
+
elif pkg.manager == Manager.CARGO:
|
|
44
|
+
from registry.crates_client import fetch as cargo_fetch
|
|
45
|
+
info = await cargo_fetch(pkg.name)
|
|
46
|
+
elif pkg.manager == Manager.BREW:
|
|
47
|
+
from registry.brew_client import fetch as brew_fetch
|
|
48
|
+
info = await brew_fetch(pkg.name)
|
|
49
|
+
else:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
if info is None:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
if info.get("is_deprecated"):
|
|
56
|
+
pkg.health_status = HealthStatus.DEPRECATED
|
|
57
|
+
pkg.deprecation_reason = info.get("deprecation_reason", "")
|
|
58
|
+
if info.get("latest_version"):
|
|
59
|
+
pkg.latest_version = info["latest_version"]
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
pkg.license = info.get("license", "") or ""
|
|
63
|
+
|
|
64
|
+
latest = info.get("latest_version", "")
|
|
65
|
+
if latest and pkg.version:
|
|
66
|
+
try:
|
|
67
|
+
installed = Version(pkg.version)
|
|
68
|
+
latest_v = Version(latest)
|
|
69
|
+
if latest_v > installed:
|
|
70
|
+
pkg.health_status = HealthStatus.OUTDATED
|
|
71
|
+
pkg.latest_version = latest
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def score_vulnerabilities(packages: list[Package], *, deep: bool = False) -> None:
|
|
77
|
+
scan_vulnerabilities(packages, deep=deep)
|
|
78
|
+
for pkg in packages:
|
|
79
|
+
if not pkg.vulnerabilities:
|
|
80
|
+
continue
|
|
81
|
+
if pkg.health_status in (HealthStatus.DEPRECATED, HealthStatus.ORPHAN):
|
|
82
|
+
continue
|
|
83
|
+
pkg.health_status = HealthStatus.VULNERABLE
|