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.
57Writes public/data/ldpov/python/catalog.json.
68"""
79
8- import base64
910import datetime
1011import json
1112import os
1213import re
1314import 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" )
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
8846def 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
12797if __name__ == "__main__" :
0 commit comments