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 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