libyear-multi 0.1.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.
@@ -0,0 +1,16 @@
1
+ from .core import Dependency, EcosystemAdapter
2
+ from .scanner import LibyearScanner
3
+ from .scoring import DependencyAgeScore, score_dependency_age, summary_line
4
+ from .cache import Cache
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = [
9
+ "Dependency",
10
+ "EcosystemAdapter",
11
+ "LibyearScanner",
12
+ "DependencyAgeScore",
13
+ "score_dependency_age",
14
+ "summary_line",
15
+ "Cache",
16
+ ]
@@ -0,0 +1,14 @@
1
+ from .pypi import PyPIAdapter
2
+ from .npm import NpmAdapter
3
+ from .cargo import CargoAdapter
4
+ from .rubygems import RubyGemsAdapter
5
+
6
+ ALL_ADAPTERS = [PyPIAdapter, NpmAdapter, CargoAdapter, RubyGemsAdapter]
7
+
8
+ __all__ = [
9
+ "PyPIAdapter",
10
+ "NpmAdapter",
11
+ "CargoAdapter",
12
+ "RubyGemsAdapter",
13
+ "ALL_ADAPTERS",
14
+ ]
@@ -0,0 +1,51 @@
1
+ """crates.io adapter for Rust Cargo.lock files."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import urllib.request
7
+ from datetime import datetime
8
+
9
+ from ..core import EcosystemAdapter
10
+
11
+
12
+ class CargoAdapter(EcosystemAdapter):
13
+ name = "cargo"
14
+
15
+ def detect(self, project_path: str) -> bool:
16
+ return os.path.exists(os.path.join(project_path, "Cargo.lock"))
17
+
18
+ def list_dependencies(self, project_path: str) -> list[tuple[str, str]]:
19
+ path = os.path.join(project_path, "Cargo.lock")
20
+ deps = []
21
+ with open(path) as f:
22
+ content = f.read()
23
+ # Parse package blocks directly so the core package has no TOML runtime
24
+ # dependency. This is sufficient for the fields needed by the scanner.
25
+ for block in content.split("[[package]]")[1:]:
26
+ name_match = re.search(r'name\s*=\s*"([^"]+)"', block)
27
+ version_match = re.search(r'version\s*=\s*"([^"]+)"', block)
28
+ if name_match and version_match:
29
+ deps.append((name_match.group(1), version_match.group(1)))
30
+ return deps
31
+
32
+ def get_latest_version(self, name: str) -> str | None:
33
+ try:
34
+ url = f"https://crates.io/api/v1/crates/{name}"
35
+ req = urllib.request.Request(url, headers={"User-Agent": "libyear-multi"})
36
+ with urllib.request.urlopen(req, timeout=10) as r:
37
+ data = json.load(r)
38
+ return data["crate"]["max_stable_version"]
39
+ except Exception:
40
+ return None
41
+
42
+ def get_release_date(self, name: str, version: str) -> datetime | None:
43
+ try:
44
+ url = f"https://crates.io/api/v1/crates/{name}/{version}"
45
+ req = urllib.request.Request(url, headers={"User-Agent": "libyear-multi"})
46
+ with urllib.request.urlopen(req, timeout=10) as r:
47
+ data = json.load(r)
48
+ ts = data["version"]["created_at"]
49
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
50
+ except Exception:
51
+ return None
@@ -0,0 +1,50 @@
1
+ """npm adapter for Node.js package.json files."""
2
+
3
+ import json
4
+ import os
5
+ import urllib.request
6
+ from datetime import datetime
7
+
8
+ from ..core import EcosystemAdapter
9
+
10
+
11
+ class NpmAdapter(EcosystemAdapter):
12
+ name = "npm"
13
+
14
+ def detect(self, project_path: str) -> bool:
15
+ return os.path.exists(os.path.join(project_path, "package.json"))
16
+
17
+ def list_dependencies(self, project_path: str) -> list[tuple[str, str]]:
18
+ path = os.path.join(project_path, "package.json")
19
+ with open(path) as f:
20
+ pkg = json.load(f)
21
+ deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
22
+ cleaned = []
23
+ for name, version in deps.items():
24
+ v = version.lstrip("^~=v ").strip()
25
+ # Registry metadata cannot resolve Git, file, or workspace specs.
26
+ if any(c in v for c in ["/", ":", "*", "workspace"]):
27
+ continue
28
+ cleaned.append((name, v))
29
+ return cleaned
30
+
31
+ def get_latest_version(self, name: str) -> str | None:
32
+ try:
33
+ url = f"https://registry.npmjs.org/{name}"
34
+ with urllib.request.urlopen(url, timeout=10) as r:
35
+ data = json.load(r)
36
+ return data.get("dist-tags", {}).get("latest")
37
+ except Exception:
38
+ return None
39
+
40
+ def get_release_date(self, name: str, version: str) -> datetime | None:
41
+ try:
42
+ url = f"https://registry.npmjs.org/{name}"
43
+ with urllib.request.urlopen(url, timeout=10) as r:
44
+ data = json.load(r)
45
+ ts = data.get("time", {}).get(version)
46
+ if not ts:
47
+ return None
48
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
49
+ except Exception:
50
+ return None
@@ -0,0 +1,50 @@
1
+ """PyPI adapter for Python requirements.txt files."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import urllib.request
7
+ from datetime import datetime
8
+
9
+ from ..core import EcosystemAdapter
10
+
11
+
12
+ class PyPIAdapter(EcosystemAdapter):
13
+ name = "pypi"
14
+
15
+ def detect(self, project_path: str) -> bool:
16
+ return os.path.exists(os.path.join(project_path, "requirements.txt"))
17
+
18
+ def list_dependencies(self, project_path: str) -> list[tuple[str, str]]:
19
+ deps = []
20
+ path = os.path.join(project_path, "requirements.txt")
21
+ with open(path) as f:
22
+ for line in f:
23
+ line = line.strip()
24
+ if not line or line.startswith("#"):
25
+ continue
26
+ match = re.match(r"^([A-Za-z0-9_.\-]+)\s*==\s*([A-Za-z0-9_.\-]+)", line)
27
+ if match:
28
+ deps.append((match.group(1), match.group(2)))
29
+ return deps
30
+
31
+ def get_latest_version(self, name: str) -> str | None:
32
+ try:
33
+ with urllib.request.urlopen(f"https://pypi.org/pypi/{name}/json", timeout=10) as r:
34
+ data = json.load(r)
35
+ return data["info"]["version"]
36
+ except Exception:
37
+ return None
38
+
39
+ def get_release_date(self, name: str, version: str) -> datetime | None:
40
+ try:
41
+ url = f"https://pypi.org/pypi/{name}/{version}/json"
42
+ with urllib.request.urlopen(url, timeout=10) as r:
43
+ data = json.load(r)
44
+ urls = data.get("urls") or []
45
+ if not urls:
46
+ return None
47
+ ts = urls[0]["upload_time_iso_8601"].replace("Z", "+00:00")
48
+ return datetime.fromisoformat(ts)
49
+ except Exception:
50
+ return None
@@ -0,0 +1,64 @@
1
+ """RubyGems adapter for Ruby Gemfile.lock files."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import urllib.request
7
+ from datetime import datetime
8
+
9
+ from ..core import EcosystemAdapter
10
+
11
+
12
+ class RubyGemsAdapter(EcosystemAdapter):
13
+ name = "rubygems"
14
+
15
+ def detect(self, project_path: str) -> bool:
16
+ return os.path.exists(os.path.join(project_path, "Gemfile.lock"))
17
+
18
+ def list_dependencies(self, project_path: str) -> list[tuple[str, str]]:
19
+ path = os.path.join(project_path, "Gemfile.lock")
20
+ deps = []
21
+ in_specs = False
22
+ with open(path) as f:
23
+ for line in f:
24
+ stripped = line.strip()
25
+ if stripped == "specs:":
26
+ in_specs = True
27
+ continue
28
+ if in_specs:
29
+ if not line.startswith(" ") or line.startswith(" "):
30
+ # Nested entries are transitive dependencies. The
31
+ # scanner currently reports only top-level gems.
32
+ if not re.match(r"^\s{4}\S", line):
33
+ continue
34
+ match = re.match(r"^\s{4}([A-Za-z0-9_.\-]+)\s+\(([^)]+)\)", line)
35
+ if match:
36
+ deps.append((match.group(1), match.group(2)))
37
+ elif line.strip() == "" or not line.startswith(" "):
38
+ in_specs = False
39
+ return deps
40
+
41
+ def get_latest_version(self, name: str) -> str | None:
42
+ try:
43
+ url = f"https://rubygems.org/api/v1/versions/{name}.json"
44
+ with urllib.request.urlopen(url, timeout=10) as r:
45
+ data = json.load(r)
46
+ for entry in data:
47
+ if not entry.get("prerelease"):
48
+ return entry["number"]
49
+ return data[0]["number"] if data else None
50
+ except Exception:
51
+ return None
52
+
53
+ def get_release_date(self, name: str, version: str) -> datetime | None:
54
+ try:
55
+ url = f"https://rubygems.org/api/v1/versions/{name}.json"
56
+ with urllib.request.urlopen(url, timeout=10) as r:
57
+ data = json.load(r)
58
+ for entry in data:
59
+ if entry["number"] == version:
60
+ ts = entry["created_at"]
61
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
62
+ return None
63
+ except Exception:
64
+ return None
libyear_multi/cache.py ADDED
@@ -0,0 +1,112 @@
1
+ """
2
+ Lightweight SQLite cache for registry lookups.
3
+
4
+ Release dates for a given (ecosystem, package, version) never change, so we
5
+ cache them forever. "Latest version" lookups are cached with a TTL since
6
+ they go stale.
7
+ """
8
+
9
+ import sqlite3
10
+ import threading
11
+ import time
12
+ import json
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+
16
+
17
+ DEFAULT_CACHE_PATH = Path.home() / ".cache" / "libyear-multi" / "cache.sqlite3"
18
+ LATEST_VERSION_TTL_SECONDS = 24 * 60 * 60 # 1 day
19
+
20
+
21
+ class Cache:
22
+ """
23
+ Thread-safe wrapper around a SQLite cache.
24
+
25
+ The scanner resolves dependencies concurrently via a thread pool, and
26
+ sqlite3 connections can't be shared across threads by default, so we
27
+ open a connection per thread (cached in thread-local storage) and guard
28
+ writes with a lock to avoid "database is locked" errors.
29
+ """
30
+
31
+ def __init__(self, path: Path | str = DEFAULT_CACHE_PATH):
32
+ self.path = Path(path)
33
+ self.path.parent.mkdir(parents=True, exist_ok=True)
34
+ self._local = threading.local()
35
+ self._write_lock = threading.Lock()
36
+ self._init_schema()
37
+
38
+ @property
39
+ def conn(self) -> sqlite3.Connection:
40
+ if not hasattr(self._local, "conn"):
41
+ self._local.conn = sqlite3.connect(self.path, timeout=30)
42
+ return self._local.conn
43
+
44
+ def _init_schema(self):
45
+ self.conn.execute(
46
+ """
47
+ CREATE TABLE IF NOT EXISTS release_dates (
48
+ ecosystem TEXT,
49
+ package TEXT,
50
+ version TEXT,
51
+ iso_date TEXT,
52
+ PRIMARY KEY (ecosystem, package, version)
53
+ )
54
+ """
55
+ )
56
+ self.conn.execute(
57
+ """
58
+ CREATE TABLE IF NOT EXISTS latest_versions (
59
+ ecosystem TEXT,
60
+ package TEXT,
61
+ version TEXT,
62
+ fetched_at REAL,
63
+ PRIMARY KEY (ecosystem, package)
64
+ )
65
+ """
66
+ )
67
+ self.conn.commit()
68
+
69
+ def get_release_date(self, ecosystem: str, package: str, version: str) -> datetime | None:
70
+ row = self.conn.execute(
71
+ "SELECT iso_date FROM release_dates WHERE ecosystem=? AND package=? AND version=?",
72
+ (ecosystem, package, version),
73
+ ).fetchone()
74
+ if row is None:
75
+ return None
76
+ return datetime.fromisoformat(row[0])
77
+
78
+ def set_release_date(self, ecosystem: str, package: str, version: str, date: datetime | None):
79
+ if date is None:
80
+ return
81
+ with self._write_lock:
82
+ self.conn.execute(
83
+ "INSERT OR REPLACE INTO release_dates VALUES (?, ?, ?, ?)",
84
+ (ecosystem, package, version, date.isoformat()),
85
+ )
86
+ self.conn.commit()
87
+
88
+ def get_latest_version(self, ecosystem: str, package: str) -> str | None:
89
+ row = self.conn.execute(
90
+ "SELECT version, fetched_at FROM latest_versions WHERE ecosystem=? AND package=?",
91
+ (ecosystem, package),
92
+ ).fetchone()
93
+ if row is None:
94
+ return None
95
+ version, fetched_at = row
96
+ if time.time() - fetched_at > LATEST_VERSION_TTL_SECONDS:
97
+ return None
98
+ return version
99
+
100
+ def set_latest_version(self, ecosystem: str, package: str, version: str | None):
101
+ if version is None:
102
+ return
103
+ with self._write_lock:
104
+ self.conn.execute(
105
+ "INSERT OR REPLACE INTO latest_versions VALUES (?, ?, ?, ?)",
106
+ (ecosystem, package, version, time.time()),
107
+ )
108
+ self.conn.commit()
109
+
110
+ def close(self):
111
+ if hasattr(self._local, "conn"):
112
+ self._local.conn.close()
libyear_multi/cli.py ADDED
@@ -0,0 +1,72 @@
1
+ """
2
+ Command-line entrypoint.
3
+
4
+ Usage:
5
+ python -m libyear_multi.cli /path/to/project
6
+ python -m libyear_multi.cli /path/to/project --json
7
+ """
8
+
9
+ import argparse
10
+ import json as json_lib
11
+ import sys
12
+
13
+ from .scanner import LibyearScanner
14
+ from .scoring import score_dependency_age, summary_line
15
+
16
+
17
+ def main():
18
+ parser = argparse.ArgumentParser(description="Compute libyear across any codebase, any language.")
19
+ parser.add_argument("path", help="Path to the project to scan")
20
+ parser.add_argument("--json", action="store_true", help="Output raw JSON instead of a text report")
21
+ parser.add_argument("--max-workers", type=int, default=8, help="Parallel registry lookups")
22
+ args = parser.parse_args()
23
+
24
+ scanner = LibyearScanner(max_workers=args.max_workers)
25
+ deps = scanner.scan(args.path)
26
+
27
+ if not deps:
28
+ print("No supported dependency manifests found (requirements.txt, package.json, Cargo.lock, Gemfile.lock).", file=sys.stderr)
29
+ sys.exit(1)
30
+
31
+ score = score_dependency_age(deps)
32
+
33
+ if args.json:
34
+ payload = {
35
+ "summary": {
36
+ "total_libyears": score.total_libyears,
37
+ "avg_libyears": score.avg_libyears,
38
+ "median_libyears": score.median_libyears,
39
+ "max_libyears": score.max_libyears,
40
+ "pct_severely_outdated": score.pct_severely_outdated,
41
+ "dependency_count": score.dependency_count,
42
+ "unresolved_count": score.unresolved_count,
43
+ "risk_band": score.to_risk_band(),
44
+ },
45
+ "dependencies": [
46
+ {
47
+ "name": d.name,
48
+ "ecosystem": d.ecosystem,
49
+ "installed_version": d.installed_version,
50
+ "latest_version": d.latest_version,
51
+ "libyears": d.libyears,
52
+ }
53
+ for d in sorted(deps, key=lambda d: (d.libyears or 0), reverse=True)
54
+ ],
55
+ }
56
+ print(json_lib.dumps(payload, indent=2))
57
+ return
58
+
59
+ print(summary_line(score))
60
+ print(f"Risk band: {score.to_risk_band()}\n")
61
+
62
+ worst = sorted([d for d in deps if d.libyears is not None], key=lambda d: d.libyears, reverse=True)[:15]
63
+ print("Most outdated dependencies:")
64
+ for d in worst:
65
+ print(f" [{d.ecosystem}] {d.name}: {d.installed_version} -> {d.latest_version} ({d.libyears} libyears)")
66
+
67
+ if score.unresolved_count:
68
+ print(f"\nNote: {score.unresolved_count} dependencies could not be resolved (registry errors, yanked versions, etc.)")
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
libyear_multi/core.py ADDED
@@ -0,0 +1,59 @@
1
+ """
2
+ Core data structures and libyear math.
3
+ This part is language-agnostic. Every adapter feeds into these structures.
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from abc import ABC, abstractmethod
9
+
10
+
11
+ @dataclass
12
+ class Dependency:
13
+ name: str
14
+ installed_version: str
15
+ latest_version: str
16
+ installed_date: datetime | None
17
+ latest_date: datetime | None
18
+ ecosystem: str
19
+
20
+ @property
21
+ def libyears(self) -> float | None:
22
+ """Years of staleness between installed and latest release."""
23
+ if not self.installed_date or not self.latest_date:
24
+ return None
25
+ delta_days = (self.latest_date - self.installed_date).days
26
+ return round(delta_days / 365.25, 2)
27
+
28
+ @property
29
+ def is_up_to_date(self) -> bool:
30
+ return self.installed_version == self.latest_version
31
+
32
+
33
+ class EcosystemAdapter(ABC):
34
+ """
35
+ One adapter per language / package manager.
36
+
37
+ To add support for a new ecosystem, subclass this and implement
38
+ the four methods below, then register it with the scanner.
39
+ """
40
+
41
+ name: str = "unknown"
42
+
43
+ @abstractmethod
44
+ def detect(self, project_path: str) -> bool:
45
+ """Return True if this project uses this ecosystem."""
46
+ raise NotImplementedError
47
+
48
+ @abstractmethod
49
+ def list_dependencies(self, project_path: str) -> list[tuple[str, str]]:
50
+ """Return [(name, installed_version), ...]."""
51
+ raise NotImplementedError
52
+
53
+ @abstractmethod
54
+ def get_latest_version(self, name: str) -> str | None:
55
+ raise NotImplementedError
56
+
57
+ @abstractmethod
58
+ def get_release_date(self, name: str, version: str) -> datetime | None:
59
+ raise NotImplementedError
@@ -0,0 +1,77 @@
1
+ """Orchestrates adapters across a project directory."""
2
+
3
+ from concurrent.futures import ThreadPoolExecutor, as_completed
4
+
5
+ from .core import Dependency, EcosystemAdapter
6
+ from .adapters import ALL_ADAPTERS
7
+ from .cache import Cache
8
+
9
+
10
+ class LibyearScanner:
11
+ def __init__(self, adapters: list[EcosystemAdapter] | None = None, cache: Cache | None = None, max_workers: int = 8):
12
+ self.adapters = adapters or [cls() for cls in ALL_ADAPTERS]
13
+ self.cache = cache or Cache()
14
+ self.max_workers = max_workers
15
+
16
+ def scan(self, project_path: str) -> list[Dependency]:
17
+ detected = [a for a in self.adapters if a.detect(project_path)]
18
+ if not detected:
19
+ return []
20
+
21
+ jobs = []
22
+ for adapter in detected:
23
+ for name, installed_version in adapter.list_dependencies(project_path):
24
+ jobs.append((adapter, name, installed_version))
25
+
26
+ results: list[Dependency] = []
27
+ with ThreadPoolExecutor(max_workers=self.max_workers) as pool:
28
+ futures = {
29
+ pool.submit(self._resolve, adapter, name, installed_version): (adapter, name)
30
+ for adapter, name, installed_version in jobs
31
+ }
32
+ for future in as_completed(futures):
33
+ dep = future.result()
34
+ if dep is not None:
35
+ results.append(dep)
36
+
37
+ return results
38
+
39
+ def _resolve(self, adapter: EcosystemAdapter, name: str, installed_version: str) -> Dependency | None:
40
+ eco = adapter.name
41
+
42
+ latest_version = self.cache.get_latest_version(eco, name)
43
+ if latest_version is None:
44
+ latest_version = adapter.get_latest_version(name)
45
+ self.cache.set_latest_version(eco, name, latest_version)
46
+
47
+ if latest_version is None:
48
+ return Dependency(
49
+ name=name,
50
+ installed_version=installed_version,
51
+ latest_version="unknown",
52
+ installed_date=None,
53
+ latest_date=None,
54
+ ecosystem=eco,
55
+ )
56
+
57
+ installed_date = self.cache.get_release_date(eco, name, installed_version)
58
+ if installed_date is None:
59
+ installed_date = adapter.get_release_date(name, installed_version)
60
+ self.cache.set_release_date(eco, name, installed_version, installed_date)
61
+
62
+ if latest_version == installed_version:
63
+ latest_date = installed_date
64
+ else:
65
+ latest_date = self.cache.get_release_date(eco, name, latest_version)
66
+ if latest_date is None:
67
+ latest_date = adapter.get_release_date(name, latest_version)
68
+ self.cache.set_release_date(eco, name, latest_version, latest_date)
69
+
70
+ return Dependency(
71
+ name=name,
72
+ installed_version=installed_version,
73
+ latest_version=latest_version,
74
+ installed_date=installed_date,
75
+ latest_date=latest_date,
76
+ ecosystem=eco,
77
+ )
@@ -0,0 +1,59 @@
1
+ """Aggregate scoring on top of a list of Dependency results."""
2
+
3
+ from dataclasses import dataclass
4
+ from statistics import mean, median
5
+
6
+ from .core import Dependency
7
+
8
+
9
+ @dataclass
10
+ class DependencyAgeScore:
11
+ total_libyears: float
12
+ avg_libyears: float
13
+ median_libyears: float
14
+ max_libyears: float
15
+ pct_severely_outdated: float # Fraction of dependencies beyond the threshold.
16
+ dependency_count: int
17
+ unresolved_count: int # Dependencies without release dates.
18
+
19
+ def to_risk_band(self) -> str:
20
+ if self.dependency_count == 0:
21
+ return "unknown"
22
+ if self.avg_libyears < 0.5 and self.pct_severely_outdated < 0.05:
23
+ return "low"
24
+ elif self.avg_libyears < 1.5 and self.pct_severely_outdated < 0.20:
25
+ return "moderate"
26
+ elif self.avg_libyears < 3.0 and self.pct_severely_outdated < 0.40:
27
+ return "high"
28
+ else:
29
+ return "severe"
30
+
31
+
32
+ def score_dependency_age(dependencies: list[Dependency], severe_threshold_years: float = 2.0) -> DependencyAgeScore:
33
+ valid = [d for d in dependencies if d.libyears is not None]
34
+ unresolved = len(dependencies) - len(valid)
35
+
36
+ if not valid:
37
+ return DependencyAgeScore(0, 0, 0, 0, 0, len(dependencies), unresolved)
38
+
39
+ years = [d.libyears for d in valid]
40
+ severe = [y for y in years if y > severe_threshold_years]
41
+
42
+ return DependencyAgeScore(
43
+ total_libyears=round(sum(years), 2),
44
+ avg_libyears=round(mean(years), 2),
45
+ median_libyears=round(median(years), 2),
46
+ max_libyears=round(max(years), 2),
47
+ pct_severely_outdated=round(len(severe) / len(years), 2),
48
+ dependency_count=len(valid),
49
+ unresolved_count=unresolved,
50
+ )
51
+
52
+
53
+ def summary_line(score: DependencyAgeScore) -> str:
54
+ return (
55
+ f"{score.dependency_count} dependencies scanned: "
56
+ f"{score.total_libyears} total libyears of staleness "
57
+ f"(avg {score.avg_libyears} yrs/package, "
58
+ f"{int(score.pct_severely_outdated * 100)}% severely outdated)."
59
+ )
@@ -0,0 +1,183 @@
1
+ Metadata-Version: 2.4
2
+ Name: libyear-multi
3
+ Version: 0.1.0
4
+ Summary: Language-agnostic libyear: dependency staleness scoring across PyPI, npm, crates.io, RubyGems and more.
5
+ Author: Shreyas Dhakal
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=8.0; extra == "dev"
15
+ Dynamic: license-file
16
+
17
+ # libyear-multi
18
+
19
+ ![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)
20
+
21
+ `libyear-multi` measures dependency staleness across the package managers used
22
+ by a project. It detects supported manifests, looks up release dates, and
23
+ reports a single score that can be used to prioritise maintenance work.
24
+
25
+ The [libyear](https://libyear.com/) metric measures dependency age in calendar
26
+ time rather than semantic-version distance. A package released three years ago
27
+ whose latest release shipped yesterday represents approximately three libyears
28
+ of staleness.
29
+
30
+ ## Highlights
31
+
32
+ - Scans multiple ecosystems in one repository, including monorepos.
33
+ - Reports total, average, median, and maximum libyears.
34
+ - Classifies results into low, moderate, high, and severe risk bands.
35
+ - Provides both human-readable and JSON output.
36
+ - Caches registry responses locally to reduce repeated network requests.
37
+
38
+ ## Supported Ecosystems
39
+
40
+ | Ecosystem | Manifest file | Registry |
41
+ | --- | --- | --- |
42
+ | Python | `requirements.txt` with pinned `==` versions | PyPI |
43
+ | Node.js | `package.json` | npm registry |
44
+ | Rust | `Cargo.lock` | crates.io |
45
+ | Ruby | `Gemfile.lock` | RubyGems |
46
+
47
+ Additional adapters are welcome. See [Adding an ecosystem](#adding-an-ecosystem).
48
+
49
+ ## Installation
50
+
51
+ ```bash
52
+ python -m pip install -e .
53
+ ```
54
+
55
+ The package is not published to PyPI yet. Install it from a clone or use the
56
+ `libyear_multi/` package directly.
57
+
58
+ ## Usage
59
+
60
+ ### Command line
61
+
62
+ ```bash
63
+ python -m libyear_multi.cli /path/to/project
64
+ ```
65
+
66
+ Example output:
67
+
68
+ ```text
69
+ 14 dependencies scanned: 22.4 total libyears of staleness (avg 1.6 yrs/package, 21% severely outdated).
70
+ Risk band: high
71
+
72
+ Most outdated dependencies:
73
+ [npm] left-pad: 1.1.0 -> 1.3.0 (4.2 libyears)
74
+ [pypi] requests: 2.20.0 -> 2.32.3 (3.8 libyears)
75
+ ```
76
+
77
+ Use JSON output for scripts and CI integrations:
78
+
79
+ ```bash
80
+ python -m libyear_multi.cli /path/to/project --json
81
+ ```
82
+
83
+ The number of concurrent registry lookups can be configured with
84
+ `--max-workers`.
85
+
86
+ ### Python API
87
+
88
+ ```python
89
+ from libyear_multi import LibyearScanner, score_dependency_age, summary_line
90
+
91
+ scanner = LibyearScanner()
92
+ dependencies = scanner.scan("/path/to/project")
93
+
94
+ score = score_dependency_age(dependencies)
95
+ print(summary_line(score))
96
+ print(score.to_risk_band())
97
+
98
+ for dependency in dependencies:
99
+ print(dependency.name, dependency.ecosystem, dependency.libyears)
100
+ ```
101
+
102
+ ## How It Works
103
+
104
+ 1. Each adapter checks whether its manifest exists in the target directory.
105
+ 2. Detected adapters parse the manifest into package and installed-version pairs.
106
+ 3. Registry APIs provide the latest version and release dates.
107
+ 4. Staleness is calculated as `(latest_date - installed_date).days / 365.25`.
108
+ 5. Individual results are aggregated into a project score.
109
+
110
+ Release dates are cached permanently in
111
+ `~/.cache/libyear-multi/cache.sqlite3`. Latest-version lookups expire after 24
112
+ hours.
113
+
114
+ ## Development
115
+
116
+ Clone the repository and install the development dependencies:
117
+
118
+ ```bash
119
+ git clone https://github.com/shreyasdhakal/libyear-multi.git
120
+ cd libyear-multi
121
+ python -m pip install -e ".[dev]"
122
+ ```
123
+
124
+ Run the test suite:
125
+
126
+ ```bash
127
+ python -m pytest
128
+ ```
129
+
130
+ Before opening a pull request, make sure tests pass and that changes are
131
+ covered by tests where practical. Network-dependent registry calls are not part
132
+ of the unit test suite.
133
+
134
+ ## Adding an Ecosystem
135
+
136
+ Subclass `EcosystemAdapter` in `libyear_multi/core.py` and implement its four
137
+ abstract methods:
138
+
139
+ ```python
140
+ from datetime import datetime
141
+
142
+ from libyear_multi.core import EcosystemAdapter
143
+
144
+
145
+ class MyAdapter(EcosystemAdapter):
146
+ name = "my_ecosystem"
147
+
148
+ def detect(self, project_path: str) -> bool: ...
149
+ def list_dependencies(self, project_path: str) -> list[tuple[str, str]]: ...
150
+ def get_latest_version(self, name: str) -> str | None: ...
151
+ def get_release_date(self, name: str, version: str) -> datetime | None: ...
152
+ ```
153
+
154
+ Register the adapter in `libyear_multi/adapters/__init__.py` and add parsing
155
+ tests in `tests/`. Good future candidates include Go, Maven, Composer, and
156
+ NuGet.
157
+
158
+ ## Known Limitations
159
+
160
+ - Python adapters currently expect pinned `==` versions.
161
+ - Rust and Ruby adapters read lockfiles, while npm currently reads `package.json`.
162
+ - Non-registry npm specifications such as Git URLs and workspace references are skipped.
163
+ - Transitive dependencies are only included when they appear in a parsed lockfile.
164
+ - Large repositories may encounter registry rate limits. The default worker limit is eight and responses are cached.
165
+ - The metric uses registry release dates and does not measure repository activity or abandonment.
166
+
167
+ ## Contributing
168
+
169
+ Contributions are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) before
170
+ submitting a change. Bug reports, documentation improvements, new adapters, and
171
+ focused fixes are all useful.
172
+
173
+ ## Security
174
+
175
+ Please report suspected vulnerabilities privately by following the instructions
176
+ in [SECURITY.md](SECURITY.md). Do not disclose exploitable details in a public
177
+ issue.
178
+
179
+ ## License
180
+
181
+ This project is licensed under the MIT License. See [LICENSE](LICENSE).
182
+
183
+ Copyright (c) 2026 Shreyas Dhakal.
@@ -0,0 +1,17 @@
1
+ libyear_multi/__init__.py,sha256=kqfj35KfMj9ZOz3qKRgtxrWuMIJJh3EYJCjUp65lmQs,373
2
+ libyear_multi/cache.py,sha256=28GTlybZCfz4Q7s8DhdV3NvPR3CBWylduIfqthiX4Yo,3698
3
+ libyear_multi/cli.py,sha256=TjHOleW8GQC9iXpmWgyXVmxAbwQ0yxDBTsQgFhkdge4,2603
4
+ libyear_multi/core.py,sha256=0ZIIcSgh6NUgHrk3Y92HaDUg7DwDTqkWpBBU2czrda8,1695
5
+ libyear_multi/scanner.py,sha256=tSQwSef0fYlrbgJzDzG66-Ulz21IFWVTn53fKTGuF_k,2989
6
+ libyear_multi/scoring.py,sha256=IYY52bfJPAne_LkiGuU2nezmYDSqavt2JKfXRlLsmJM,2043
7
+ libyear_multi/adapters/__init__.py,sha256=4HIt64uduV4AnVdZTuG2-yKBE2JICQOXnMbOeSCZyPw,316
8
+ libyear_multi/adapters/cargo.py,sha256=nilB1tSpALYwbQDzte10ScFG5-ulGx9-CMlQ2XLrBG8,1997
9
+ libyear_multi/adapters/npm.py,sha256=eu4Y-s6MBEDD5buaZ9GhtXnaqvfvxcWTobwWCBfMprQ,1762
10
+ libyear_multi/adapters/pypi.py,sha256=2zIDfn3bpYqtxCebX02D78EbmeLlyWE0v4rMYqWFAZ8,1710
11
+ libyear_multi/adapters/rubygems.py,sha256=PohmB1YuQE_VUHukJXKtEVk-KEkertZVVnTaQ0gQapc,2420
12
+ libyear_multi-0.1.0.dist-info/licenses/LICENSE,sha256=U7l75bzTn7dkL8LRURoaPfhgjC2wilSMhiN2BpRJNDs,1071
13
+ libyear_multi-0.1.0.dist-info/METADATA,sha256=5kq6Cv9AHBxu8fqptrtrkK6s09qjC15M7l3m72brx_Y,5602
14
+ libyear_multi-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
15
+ libyear_multi-0.1.0.dist-info/entry_points.txt,sha256=XWNR4h73BsyQmd9HXUSL9I2DKtxjTzCy3hKEzHfA6-Y,57
16
+ libyear_multi-0.1.0.dist-info/top_level.txt,sha256=JkJnOisb2AwwySvV2gzf4zxg97c8AV8hHYN3DXna2Qs,14
17
+ libyear_multi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ libyear-multi = libyear_multi.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shreyas Dhakal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ libyear_multi