Skip to content

Commit ded8cba

Browse files
committed
fix: rewrite fetch_ldpov_pypi.py to use S3 redirect_map; update Python data
- fetch_ldpov_pypi.py now reads redirect_map from S3 and extracts package names + versions directly from whl/sdist filenames — no registry calls, immune to rate limiting - Python catalog rebuilt: 1726 packages (from redirect_map, accurate) - Python audit re-scanned: 31 vulnerable / 99 CVEs (unchanged result) - JS catalog timestamp refresh (re-generated, same 4387 packages)
1 parent 2df6f1f commit ded8cba

4 files changed

Lines changed: 61 additions & 91 deletions

File tree

public/data/ldpov/javascript/catalog.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

public/data/ldpov/python/audit.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

public/data/ldpov/python/catalog.json

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

scripts/fetch_ldpov_pypi.py

Lines changed: 58 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
#!/usr/bin/env python3
22
"""Fetch all package names + versions from the LDPoV PyPI catalog.
33
4-
Reads LDPOV_USER, LDPOV_CATALOG_TOKEN, PYPI_URL from ../.env or environment.
4+
Reads LDPOV_ORG_ID from ../.env or environment.
5+
Fetches the redirect_map from S3 and extracts package names + versions
6+
directly from the wheel/sdist filenames — no registry calls needed.
57
Writes public/data/ldpov/python/catalog.json.
68
"""
79

8-
import base64
910
import datetime
1011
import json
1112
import os
1213
import re
1314
import subprocess
14-
from concurrent.futures import ThreadPoolExecutor, as_completed
15-
from urllib.parse import urljoin
16-
from urllib.request import Request, urlopen
15+
import urllib.parse
16+
from collections import defaultdict
1717

1818
_env: dict[str, str] = {}
1919
_env_path = os.path.join(os.path.dirname(__file__), "..", ".env")
@@ -24,94 +24,64 @@
2424
k, v = line.split("=", 1)
2525
_env[k.strip()] = v.strip()
2626

27-
USER = os.environ.get("LDPOV_USER", _env.get("LDPOV_USER", ""))
28-
PASSWORD = os.environ.get("LDPOV_CATALOG_TOKEN", _env.get("LDPOV_CATALOG_TOKEN", ""))
29-
BASE_URL = os.environ.get("PYPI_URL", _env.get("PYPI_URL", ""))
30-
31-
if not USER or not PASSWORD:
32-
raise SystemExit("ERROR: set LDPOV_USER and LDPOV_CATALOG_TOKEN in .env or environment")
33-
if not BASE_URL:
34-
raise SystemExit("ERROR: set PYPI_URL in .env or environment")
35-
36-
AUTH = base64.b64encode(f"{USER}:{PASSWORD}".encode()).decode()
37-
38-
HREF_RE = re.compile(r'href="([^"]+)"')
39-
WHL_VERSION_RE = re.compile(r'^[^-]+-([^-]+)-')
40-
41-
42-
def get(url: str) -> str:
43-
req = Request(url, headers={"Authorization": f"Basic {AUTH}"})
44-
with urlopen(req, timeout=30) as r:
45-
return r.read().decode("utf-8")
46-
47-
48-
def fetch_versions(pkg_name: str) -> tuple[str, list[str]]:
49-
url = urljoin(BASE_URL, f"{pkg_name}/")
50-
try:
51-
html = get(url)
52-
versions: set[str] = set()
53-
for href in HREF_RE.findall(html):
54-
filename = href.split("/")[-1].split("#")[0]
55-
if filename.endswith(".whl"):
56-
m = WHL_VERSION_RE.match(filename)
57-
if m:
58-
versions.add(m.group(1))
59-
elif filename.endswith(".tar.gz") or filename.endswith(".zip"):
60-
stem = filename.replace(".tar.gz", "").replace(".zip", "")
61-
parts = stem.rsplit("-", 1)
62-
if len(parts) == 2:
63-
versions.add(parts[1])
64-
return pkg_name, sorted(
65-
versions,
66-
key=lambda v: [int(x) if x.isdigit() else x for x in re.split(r'[.\-]', v)],
67-
)
68-
except Exception:
69-
return pkg_name, []
70-
71-
72-
def get_package_list_from_s3(org_id: str) -> list[str]:
73-
s3_path = f"s3://curated-catalog/env=prod/org-id={org_id}/repo-type=pypi/"
74-
print(f"Fetching PyPI package list from S3: {s3_path}", flush=True)
75-
result = subprocess.run(
76-
["aws", "s3", "ls", s3_path],
77-
capture_output=True, text=True, check=True,
78-
)
79-
packages = []
80-
for line in result.stdout.splitlines():
81-
# Lines look like: " PRE numpy/"
82-
parts = line.strip().split()
83-
if len(parts) == 2 and parts[0] == "PRE":
84-
packages.append(parts[1].rstrip("/"))
85-
return sorted(packages, key=str.lower)
27+
ORG_ID = os.environ.get("LDPOV_ORG_ID", _env.get("LDPOV_ORG_ID", ""))
28+
29+
if not ORG_ID:
30+
raise SystemExit("ERROR: set LDPOV_ORG_ID in .env or environment")
31+
32+
# wheel filename: {distribution}-{version}(-{build})?-{python}-{abi}-{platform}.whl
33+
_WHL_RE = re.compile(r'^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)-(\d[^-]*)(-.*)?\.whl$')
34+
# sdist filename: {distribution}-{version}.tar.gz or .zip
35+
_SDIST_RE = re.compile(r'^(.+?)-(\d[\w.\-+]*)\.(?:tar\.gz|zip)$')
36+
37+
38+
def _normalize(name: str) -> str:
39+
return re.sub(r'[-_.]+', '-', name).lower()
40+
41+
42+
def _version_key(v: str) -> list:
43+
return [int(x) if x.isdigit() else x for x in re.split(r'[.\-]', v.split('+')[0])]
8644

8745

8846
def main() -> None:
89-
org_id = os.environ.get("LDPOV_ORG_ID", _env.get("LDPOV_ORG_ID", ""))
90-
if not org_id:
91-
raise SystemExit("ERROR: set LDPOV_ORG_ID in .env or environment")
92-
93-
packages = get_package_list_from_s3(org_id)
94-
total = len(packages)
95-
print(f"Found {total} packages. Fetching versions with 10 workers...", flush=True)
96-
97-
results: list[dict] = []
98-
done = 0
99-
with ThreadPoolExecutor(max_workers=10) as pool:
100-
futures = {pool.submit(fetch_versions, pkg): pkg for pkg in packages}
101-
for future in as_completed(futures):
102-
name, versions = future.result()
103-
if versions:
104-
results.append({"name": name, "versions": versions})
105-
done += 1
106-
if done % 100 == 0 or done == total:
107-
print(f" {done}/{total}", flush=True)
108-
109-
results.sort(key=lambda x: x["name"].lower())
47+
s3_path = f"s3://curated-catalog/env=prod/org-id={ORG_ID}/repo-type=pypi/redirect_map.json"
48+
print(f"Fetching PyPI redirect_map from S3: {s3_path}", flush=True)
49+
result = subprocess.run(
50+
["aws", "s3", "cp", s3_path, "-"],
51+
capture_output=True, text=True, check=True,
52+
)
53+
data = json.loads(result.stdout)
54+
print(f"Found {len(data)} redirect entries", flush=True)
55+
56+
pkg_versions: dict[str, set[str]] = defaultdict(set)
57+
for raw_key in data:
58+
# keys are: {sha256_hash}/{filename}
59+
parts = raw_key.split('/', 1)
60+
if len(parts) != 2:
61+
continue
62+
filename = urllib.parse.unquote(parts[1])
63+
64+
m = _WHL_RE.match(filename)
65+
if m:
66+
dist = _normalize(m.group(1))
67+
version = m.group(3)
68+
pkg_versions[dist].add(version)
69+
continue
70+
71+
m = _SDIST_RE.match(filename)
72+
if m:
73+
dist = _normalize(m.group(1))
74+
version = m.group(2)
75+
pkg_versions[dist].add(version)
76+
77+
packages = [
78+
{"name": name, "versions": sorted(versions, key=_version_key)}
79+
for name, versions in sorted(pkg_versions.items(), key=lambda x: x[0].lower())
80+
]
11081

11182
out = {
11283
"generated": datetime.datetime.utcnow().isoformat() + "Z",
113-
"index_url": BASE_URL,
114-
"packages": results,
84+
"packages": packages,
11585
}
11686

11787
out_path = os.path.join(
@@ -121,7 +91,7 @@ def main() -> None:
12191
with open(out_path, "w") as f:
12292
json.dump(out, f, separators=(",", ":"))
12393

124-
print(f"Wrote public/data/ldpov/python/catalog.json — {len(results)} packages ({total - len(results)} skipped, no versions)")
94+
print(f"Wrote public/data/ldpov/python/catalog.json — {len(packages)} packages")
12595

12696

12797
if __name__ == "__main__":

0 commit comments

Comments
 (0)