nxdev-org commited on
Commit
e6e5fa8
Β·
verified Β·
1 Parent(s): 69c3061

Create pypi_proxy.py

Browse files
Files changed (1) hide show
  1. pypi_proxy.py +739 -0
pypi_proxy.py ADDED
@@ -0,0 +1,739 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Smart PyPI Proxy Server that auto-detects required versions.
4
+ Usage: python pypi_proxy.py [--analyze package_name]
5
+ """
6
+
7
+ from http.server import HTTPServer, BaseHTTPRequestHandler
8
+ import urllib.request
9
+ import urllib.parse
10
+ import json
11
+ import tempfile
12
+ import os
13
+ import shutil
14
+ import re
15
+ import sys
16
+ import argparse
17
+ import zipfile
18
+ import hashlib
19
+ import base64
20
+ from pathlib import Path
21
+ from packaging.specifiers import SpecifierSet
22
+ from packaging.version import Version
23
+
24
+ # IMPORTANT: Only add packages HERE that you want to REPLACE with dummy versions
25
+ DUMMY_PACKAGES = {
26
+ 'torch': 'auto',
27
+ 'torchvision': 'auto',
28
+ 'torchaudio': 'auto',
29
+ }
30
+
31
+ PYPI_INDEX = "https://pypi.org"
32
+ VERSION_CACHE = {}
33
+ METADATA_CACHE = {}
34
+
35
+
36
+ def fetch_package_info(package_name):
37
+ """Fetch package metadata from PyPI."""
38
+ try:
39
+ url = f"{PYPI_INDEX}/pypi/{package_name}/json"
40
+ with urllib.request.urlopen(url, timeout=10) as response:
41
+ return json.loads(response.read())
42
+ except Exception as e:
43
+ print(f" βœ— Error fetching info for {package_name}: {e}")
44
+ return None
45
+
46
+
47
+ def find_compatible_version(package_name, specifier_str=''):
48
+ """Find a version that satisfies the specifier."""
49
+ cache_key = f"{package_name}:{specifier_str}"
50
+ if cache_key in VERSION_CACHE:
51
+ return VERSION_CACHE[cache_key]
52
+
53
+ info = fetch_package_info(package_name)
54
+ if not info:
55
+ return '99.0.0'
56
+
57
+ versions = list(info.get('releases', {}).keys())
58
+ if not versions:
59
+ return '99.0.0'
60
+
61
+ try:
62
+ parsed_versions = []
63
+ for v in versions:
64
+ try:
65
+ parsed_versions.append(Version(v))
66
+ except:
67
+ pass
68
+
69
+ if not parsed_versions:
70
+ return '99.0.0'
71
+
72
+ parsed_versions.sort(reverse=True)
73
+
74
+ if not specifier_str or specifier_str == '*':
75
+ for v in parsed_versions:
76
+ if not v.is_prerelease:
77
+ result = str(v)
78
+ VERSION_CACHE[cache_key] = result
79
+ return result
80
+ result = str(parsed_versions[0])
81
+ VERSION_CACHE[cache_key] = result
82
+ return result
83
+
84
+ spec = SpecifierSet(specifier_str)
85
+ for v in parsed_versions:
86
+ if v in spec and not v.is_prerelease:
87
+ result = str(v)
88
+ VERSION_CACHE[cache_key] = result
89
+ return result
90
+
91
+ for v in parsed_versions:
92
+ if v in spec:
93
+ result = str(v)
94
+ VERSION_CACHE[cache_key] = result
95
+ return result
96
+
97
+ result = str(parsed_versions[0])
98
+ VERSION_CACHE[cache_key] = result
99
+ return result
100
+
101
+ except Exception as e:
102
+ print(f" βœ— Error parsing versions for {package_name}: {e}")
103
+ return '99.0.0'
104
+
105
+
106
+ def get_real_package_metadata(package_name, version=None):
107
+ """Fetch real metadata for a package from PyPI."""
108
+ cache_key = f"meta:{package_name}:{version}"
109
+ if cache_key in METADATA_CACHE:
110
+ return METADATA_CACHE[cache_key]
111
+
112
+ try:
113
+ if version:
114
+ url = f"{PYPI_INDEX}/pypi/{package_name}/{version}/json"
115
+ else:
116
+ url = f"{PYPI_INDEX}/pypi/{package_name}/json"
117
+
118
+ with urllib.request.urlopen(url, timeout=10) as response:
119
+ data = json.loads(response.read())
120
+
121
+ info = data.get('info', {})
122
+ result = {
123
+ 'name': info.get('name', package_name),
124
+ 'version': info.get('version', version or '0.0.0'),
125
+ 'summary': info.get('summary', ''),
126
+ 'home_page': info.get('home_page', ''),
127
+ 'author': info.get('author', ''),
128
+ 'author_email': info.get('author_email', ''),
129
+ 'license': info.get('license', ''),
130
+ 'requires_python': info.get('requires_python', '>=3.8'),
131
+ 'requires_dist': info.get('requires_dist', []) or [],
132
+ 'classifiers': info.get('classifiers', []) or [],
133
+ 'keywords': info.get('keywords', ''),
134
+ 'description': info.get('description', ''),
135
+ 'description_content_type': info.get('description_content_type', 'text/markdown'),
136
+ }
137
+
138
+ METADATA_CACHE[cache_key] = result
139
+ return result
140
+
141
+ except Exception as e:
142
+ print(f" ⚠ Could not fetch metadata for {package_name}: {e}")
143
+ return None
144
+
145
+
146
+ def generate_metadata_file(package_name, version, include_real_deps=False):
147
+ """
148
+ Generate proper METADATA content compliant with PEP 566/PEP 643.
149
+ This is called during wheel creation to ensure valid metadata.
150
+ """
151
+ # Try to get real metadata from PyPI
152
+ real_meta = get_real_package_metadata(package_name, version)
153
+
154
+ # Normalize package name (PEP 503: lowercase, hyphens)
155
+ normalized_name = package_name.lower().replace('_', '-')
156
+
157
+ # Build METADATA content - order matters for some parsers
158
+ lines = []
159
+
160
+ # Required core metadata (PEP 566)
161
+ lines.append("Metadata-Version: 2.1")
162
+ lines.append(f"Name: {normalized_name}")
163
+ lines.append(f"Version: {version}")
164
+
165
+ # Optional but recommended fields
166
+ if real_meta:
167
+ if real_meta.get('summary'):
168
+ lines.append(f"Summary: {real_meta['summary']}")
169
+ else:
170
+ lines.append(f"Summary: Dummy stub for {package_name}")
171
+
172
+ if real_meta.get('home_page'):
173
+ lines.append(f"Home-page: {real_meta['home_page']}")
174
+
175
+ if real_meta.get('author'):
176
+ lines.append(f"Author: {real_meta['author']}")
177
+
178
+ if real_meta.get('author_email'):
179
+ lines.append(f"Author-email: {real_meta['author_email']}")
180
+
181
+ if real_meta.get('license'):
182
+ # Truncate long license text
183
+ license_text = real_meta['license']
184
+ if len(license_text) > 100:
185
+ license_text = license_text[:100].split('\n')[0]
186
+ lines.append(f"License: {license_text}")
187
+
188
+ if real_meta.get('keywords'):
189
+ lines.append(f"Keywords: {real_meta['keywords']}")
190
+
191
+ if real_meta.get('requires_python'):
192
+ lines.append(f"Requires-Python: {real_meta['requires_python']}")
193
+ else:
194
+ lines.append("Requires-Python: >=3.8")
195
+
196
+ # Add classifiers
197
+ for classifier in real_meta.get('classifiers', [])[:20]: # Limit to 20
198
+ lines.append(f"Classifier: {classifier}")
199
+
200
+ # Add dependencies (filtered)
201
+ if include_real_deps:
202
+ for dep in real_meta.get('requires_dist', []):
203
+ # Extract base package name
204
+ dep_name = re.split(r'[\s\[\]<>=!;]', dep)[0].lower().replace('_', '-')
205
+ # Skip if it's a dummy package (we don't want real deps on dummy packages)
206
+ if dep_name not in DUMMY_PACKAGES:
207
+ lines.append(f"Requires-Dist: {dep}")
208
+ else:
209
+ # Fallback when can't fetch from PyPI
210
+ lines.append(f"Summary: Dummy stub package for {package_name}")
211
+ lines.append("Home-page: https://pypi.org/project/{}/".format(package_name))
212
+ lines.append("Author: PyPI Proxy")
213
+ lines.append("Author-email: proxy@localhost")
214
+ lines.append("License: MIT")
215
+ lines.append("Requires-Python: >=3.8")
216
+ lines.append("Classifier: Development Status :: 4 - Beta")
217
+ lines.append("Classifier: Intended Audience :: Developers")
218
+ lines.append("Classifier: Programming Language :: Python :: 3")
219
+
220
+ # Description content type
221
+ lines.append("Description-Content-Type: text/markdown")
222
+
223
+ # Blank line separates headers from body (REQUIRED)
224
+ lines.append("")
225
+
226
+ # Description body
227
+ lines.append(f"# {package_name}")
228
+ lines.append("")
229
+ lines.append(f"**This is a dummy stub package for `{package_name}` version {version}.**")
230
+ lines.append("")
231
+ lines.append("Generated by PyPI Proxy to satisfy dependency requirements without")
232
+ lines.append("downloading the full package (which may be very large).")
233
+ lines.append("")
234
+ lines.append("## Note")
235
+ lines.append("")
236
+ lines.append("This package provides minimal stubs. Importing it will raise an ImportError")
237
+ lines.append("explaining that the real package is not installed.")
238
+
239
+ return "\n".join(lines)
240
+
241
+
242
+ def file_hash_record(content):
243
+ """Generate sha256 hash in RECORD format and file size."""
244
+ if isinstance(content, str):
245
+ content = content.encode('utf-8')
246
+ digest = hashlib.sha256(content).digest()
247
+ # URL-safe base64 without padding
248
+ hash_b64 = base64.urlsafe_b64encode(digest).rstrip(b'=').decode('ascii')
249
+ return f"sha256={hash_b64}", len(content)
250
+
251
+
252
+ def create_dummy_wheel(package_name, version, include_deps=False):
253
+ """
254
+ Create a minimal dummy wheel package with proper METADATA.
255
+ The METADATA is generated using generate_metadata_file() to ensure
256
+ it's readable by uv, pip, and other tools.
257
+ """
258
+ try:
259
+ print(f" πŸ“ Generating metadata for {package_name} {version}...")
260
+
261
+ # Normalize package name for wheel internals (underscore)
262
+ wheel_name = package_name.lower().replace('-', '_')
263
+ dist_info_name = f"{wheel_name}-{version}.dist-info"
264
+
265
+ # Prepare all file contents
266
+ files = {}
267
+
268
+ # 1. Package __init__.py
269
+ init_content = f'''# -*- coding: utf-8 -*-
270
+ """
271
+ Dummy {package_name} package - stub generated by PyPI Proxy.
272
+
273
+ This is NOT the real {package_name} package. It's a minimal stub
274
+ to satisfy dependency requirements without the full installation.
275
+ """
276
+
277
+ __version__ = "{version}"
278
+ __author__ = "PyPI Proxy (stub)"
279
+ __all__ = ["__version__"]
280
+
281
+
282
+ class _StubModule:
283
+ """Stub that raises ImportError on any attribute access."""
284
+
285
+ def __init__(self, name):
286
+ self._name = name
287
+
288
+ def __getattr__(self, attr):
289
+ raise ImportError(
290
+ f"Cannot import '{{attr}}' from dummy stub package '{package_name}'. "
291
+ f"The real '{package_name}' package is not installed. "
292
+ f"Install it with: pip install {package_name}"
293
+ )
294
+
295
+ def __call__(self, *args, **kwargs):
296
+ raise ImportError(
297
+ f"Cannot call dummy stub package '{package_name}'. "
298
+ f"The real '{package_name}' package is not installed."
299
+ )
300
+
301
+
302
+ # Make any submodule access fail gracefully with clear error
303
+ def __getattr__(name):
304
+ if name.startswith('_'):
305
+ raise AttributeError(name)
306
+ return _StubModule(f"{package_name}.{{name}}")
307
+ '''
308
+ files[f"{wheel_name}/__init__.py"] = init_content
309
+
310
+ # 2. METADATA - THE CRITICAL FILE
311
+ # Use generate_metadata_file to create proper, compliant metadata
312
+ metadata_content = generate_metadata_file(package_name, version, include_deps)
313
+ files[f"{dist_info_name}/METADATA"] = metadata_content
314
+ print(f" βœ“ Generated METADATA ({len(metadata_content)} bytes)")
315
+
316
+ # 3. WHEEL file
317
+ wheel_content = """Wheel-Version: 1.0
318
+ Generator: pypi-proxy (1.0.0)
319
+ Root-Is-Purelib: true
320
+ Tag: py3-none-any
321
+ """
322
+ files[f"{dist_info_name}/WHEEL"] = wheel_content
323
+
324
+ # 4. top_level.txt
325
+ top_level_content = f"{wheel_name}\n"
326
+ files[f"{dist_info_name}/top_level.txt"] = top_level_content
327
+
328
+ # 5. INSTALLER
329
+ installer_content = "uv\n"
330
+ files[f"{dist_info_name}/INSTALLER"] = installer_content
331
+
332
+ # 6. direct_url.json (optional but helps some tools)
333
+ direct_url_content = json.dumps({
334
+ "url": f"http://localhost:8080/packages/{wheel_name}-{version}-py3-none-any.whl",
335
+ "archive_info": {}
336
+ }, indent=2)
337
+ files[f"{dist_info_name}/direct_url.json"] = direct_url_content
338
+
339
+ # 7. Build RECORD with proper hashes (must be last)
340
+ record_lines = []
341
+ for filepath, content in files.items():
342
+ if isinstance(content, str):
343
+ content_bytes = content.encode('utf-8')
344
+ else:
345
+ content_bytes = content
346
+ hash_str, size = file_hash_record(content_bytes)
347
+ record_lines.append(f"{filepath},{hash_str},{size}")
348
+
349
+ # RECORD itself has no hash (per PEP 376)
350
+ record_lines.append(f"{dist_info_name}/RECORD,,")
351
+ record_content = "\n".join(record_lines) + "\n"
352
+ files[f"{dist_info_name}/RECORD"] = record_content
353
+
354
+ # Create the wheel zip file
355
+ temp_wheel = tempfile.NamedTemporaryFile(delete=False, suffix='.whl')
356
+ temp_wheel.close()
357
+
358
+ try:
359
+ with zipfile.ZipFile(temp_wheel.name, 'w', zipfile.ZIP_DEFLATED) as wheel:
360
+ for filepath, content in files.items():
361
+ if isinstance(content, str):
362
+ content = content.encode('utf-8')
363
+ # Set proper file permissions in zip
364
+ info = zipfile.ZipInfo(filepath)
365
+ info.external_attr = 0o644 << 16 # rw-r--r--
366
+ wheel.writestr(info, content)
367
+
368
+ # Read the wheel content
369
+ with open(temp_wheel.name, 'rb') as f:
370
+ wheel_bytes = f.read()
371
+
372
+ print(f" βœ“ Created wheel: {len(files)} files, {len(wheel_bytes)} bytes")
373
+
374
+ # Debug: list contents
375
+ print(f" πŸ“¦ Wheel contents:")
376
+ for f in files.keys():
377
+ print(f" - {f}")
378
+
379
+ return wheel_bytes
380
+
381
+ finally:
382
+ # Clean up temp file
383
+ if os.path.exists(temp_wheel.name):
384
+ os.unlink(temp_wheel.name)
385
+
386
+ except Exception as e:
387
+ print(f" βœ— Error creating wheel: {e}")
388
+ import traceback
389
+ traceback.print_exc()
390
+ return None
391
+
392
+
393
+ def analyze_dependencies(package_name):
394
+ """Analyze what versions a package requires for its dependencies."""
395
+ print(f"\nπŸ” Analyzing dependencies for: {package_name}\n")
396
+
397
+ info = fetch_package_info(package_name)
398
+ if not info:
399
+ print("Failed to fetch package info")
400
+ return
401
+
402
+ latest_version = info.get('info', {}).get('version', 'unknown')
403
+ print(f"Latest version: {latest_version}\n")
404
+
405
+ requires_dist = info.get('info', {}).get('requires_dist', [])
406
+ if not requires_dist:
407
+ print("No dependencies found")
408
+ return
409
+
410
+ print("Dependencies:")
411
+ print("-" * 60)
412
+
413
+ for req in requires_dist:
414
+ req_clean = req.split(';')[0].strip()
415
+ match = re.match(r'([a-zA-Z0-9\-_]+)(.*)', req_clean)
416
+ if match:
417
+ dep_name = match.group(1).lower()
418
+ version_spec = match.group(2).strip()
419
+
420
+ if dep_name in DUMMY_PACKAGES:
421
+ if version_spec:
422
+ suggested_version = find_compatible_version(dep_name, version_spec)
423
+ print(f" πŸ“¦ {dep_name:30} {version_spec:20} β†’ {suggested_version}")
424
+ else:
425
+ print(f" πŸ“¦ {dep_name:30} (any version)")
426
+
427
+
428
+ class PyPIProxyHandler(BaseHTTPRequestHandler):
429
+ def log_message(self, format, *args):
430
+ pass
431
+
432
+ def do_HEAD(self):
433
+ path = self.path
434
+ package_name = self._extract_package_name(path)
435
+
436
+ if package_name and package_name in DUMMY_PACKAGES:
437
+ if '/simple/' in path:
438
+ self.send_response(200)
439
+ self.send_header('Content-Type', 'text/html; charset=utf-8')
440
+ self.end_headers()
441
+ elif path.endswith('.whl') or path.endswith('.tar.gz'):
442
+ self.send_response(200)
443
+ self.send_header('Content-Type', 'application/octet-stream')
444
+ self.send_header('Content-Length', '10000')
445
+ self.end_headers()
446
+ else:
447
+ self._proxy_request_head()
448
+ else:
449
+ self._proxy_request_head()
450
+
451
+ def do_GET(self):
452
+ path = self.path
453
+ package_name = self._extract_package_name(path)
454
+
455
+ print(f"\n{'='*60}")
456
+ print(f"πŸ“ REQUEST: {path}")
457
+ if package_name:
458
+ is_dummy = package_name in DUMMY_PACKAGES
459
+ print(f" Package: {package_name}")
460
+ print(f" Is Dummy: {is_dummy}")
461
+
462
+ if package_name and package_name in DUMMY_PACKAGES:
463
+ print(f"πŸ“¦ Handling as DUMMY package")
464
+ if '/simple/' in path:
465
+ self._serve_dummy_simple_page(package_name)
466
+ elif path.endswith('.whl') or path.endswith('.tar.gz'):
467
+ self._serve_dummy_package(package_name)
468
+ else:
469
+ self._proxy_request()
470
+ else:
471
+ if package_name:
472
+ print(f"🌐 Proxying to PyPI")
473
+ self._proxy_request()
474
+
475
+ def _extract_package_name(self, path):
476
+ """Extract and normalize package name from request path."""
477
+ if '/simple/' in path:
478
+ parts = path.split('/simple/')
479
+ if len(parts) > 1:
480
+ name = parts[1].rstrip('/').split('/')[0]
481
+ # Normalize: lowercase, replace underscores with hyphens (PEP 503)
482
+ return name.lower().replace('_', '-')
483
+ elif '/packages/' in path:
484
+ filename = os.path.basename(path)
485
+ # Match package name before version number
486
+ match = re.match(r'^([a-zA-Z0-9_-]+)-\d', filename)
487
+ if match:
488
+ name = match.group(1)
489
+ return name.lower().replace('_', '-')
490
+ return None
491
+
492
+ def _serve_dummy_simple_page(self, package_name):
493
+ """Serve the simple index page for a dummy package."""
494
+ version_spec = DUMMY_PACKAGES.get(package_name, '99.0.0')
495
+
496
+ if version_spec == 'auto':
497
+ version = find_compatible_version(package_name)
498
+ print(f" πŸ€– Auto-detected version: {version}")
499
+ else:
500
+ version = version_spec
501
+
502
+ # Use underscore for wheel filename (PEP 427)
503
+ wheel_name = package_name.replace('-', '_')
504
+
505
+ # Serve multiple versions for compatibility
506
+ versions_to_serve = [version]
507
+ try:
508
+ v = Version(version)
509
+ if v.minor > 0:
510
+ older1 = f"{v.major}.{v.minor - 1}.0"
511
+ versions_to_serve.append(older1)
512
+ if v.minor > 1:
513
+ older2 = f"{v.major}.{v.minor - 2}.0"
514
+ versions_to_serve.append(older2)
515
+ except:
516
+ pass
517
+
518
+ links = []
519
+ for ver in versions_to_serve:
520
+ fname = f"{wheel_name}-{ver}-py3-none-any.whl"
521
+ links.append(
522
+ f' <a href="/packages/{fname}" data-requires-python="&gt;=3.8">{fname}</a><br/>'
523
+ )
524
+
525
+ html = f"""<!DOCTYPE html>
526
+ <html>
527
+ <head>
528
+ <meta charset="utf-8">
529
+ <title>Links for {package_name}</title>
530
+ </head>
531
+ <body>
532
+ <h1>Links for {package_name}</h1>
533
+ {chr(10).join(links)}
534
+ </body>
535
+ </html>
536
+ """
537
+
538
+ content = html.encode('utf-8')
539
+ self.send_response(200)
540
+ self.send_header('Content-Type', 'text/html; charset=utf-8')
541
+ self.send_header('Content-Length', len(content))
542
+ self.send_header('Cache-Control', 'no-cache')
543
+ self.end_headers()
544
+ self.wfile.write(content)
545
+ print(f" βœ“ Served simple page: {package_name}")
546
+ print(f" Versions: {', '.join(versions_to_serve)}")
547
+
548
+ def _serve_dummy_package(self, package_name):
549
+ """Serve the actual dummy wheel file."""
550
+ filename = os.path.basename(self.path)
551
+
552
+ # Extract version from filename
553
+ # Format: {name}-{version}-py3-none-any.whl
554
+ version_match = re.search(r'-(\d+\.\d+\.\d+(?:\.\w+)?)-', filename)
555
+
556
+ if version_match:
557
+ version = version_match.group(1)
558
+ else:
559
+ version_spec = DUMMY_PACKAGES.get(package_name, '99.0.0')
560
+ if version_spec == 'auto':
561
+ version = find_compatible_version(package_name)
562
+ else:
563
+ version = version_spec
564
+
565
+ print(f" πŸ”¨ Building wheel: {package_name} v{version}")
566
+
567
+ # Create the wheel with proper METADATA
568
+ content = create_dummy_wheel(package_name, version, include_deps=False)
569
+
570
+ if content:
571
+ self.send_response(200)
572
+ self.send_header('Content-Type', 'application/zip')
573
+ self.send_header('Content-Length', len(content))
574
+ self.send_header('Content-Disposition', f'attachment; filename="{filename}"')
575
+ self.send_header('Cache-Control', 'no-cache')
576
+ self.end_headers()
577
+ self.wfile.write(content)
578
+ print(f" βœ“ Served wheel: {filename} ({len(content)} bytes)")
579
+ else:
580
+ self.send_error(500, "Failed to build dummy package")
581
+ print(f" βœ— Failed to build wheel")
582
+
583
+ def _proxy_request_head(self):
584
+ url = f"{PYPI_INDEX}{self.path}"
585
+ try:
586
+ req = urllib.request.Request(url, method='HEAD')
587
+ req.add_header('User-Agent', 'pypi-proxy/1.0')
588
+ with urllib.request.urlopen(req, timeout=30) as response:
589
+ self.send_response(response.status)
590
+ for key, value in response.headers.items():
591
+ if key.lower() not in ['transfer-encoding', 'connection']:
592
+ self.send_header(key, value)
593
+ self.end_headers()
594
+ except urllib.error.HTTPError as e:
595
+ self.send_error(e.code, e.reason)
596
+ except Exception as e:
597
+ self.send_error(502, f"Proxy error: {str(e)}")
598
+
599
+ def _proxy_request(self):
600
+ url = f"{PYPI_INDEX}{self.path}"
601
+ try:
602
+ headers = {'User-Agent': 'pypi-proxy/1.0'}
603
+ for key, value in self.headers.items():
604
+ if key.lower() not in ['host', 'connection']:
605
+ headers[key] = value
606
+
607
+ req = urllib.request.Request(url, headers=headers)
608
+ with urllib.request.urlopen(req, timeout=30) as response:
609
+ self.send_response(response.status)
610
+ for key, value in response.headers.items():
611
+ if key.lower() not in ['transfer-encoding', 'connection']:
612
+ self.send_header(key, value)
613
+ self.end_headers()
614
+ shutil.copyfileobj(response, self.wfile)
615
+ except urllib.error.HTTPError as e:
616
+ self.send_error(e.code, e.reason)
617
+ except Exception as e:
618
+ self.send_error(502, f"Proxy error: {str(e)}")
619
+
620
+
621
+ def run_server(port=8080):
622
+ server_address = ('', port)
623
+ httpd = HTTPServer(server_address, PyPIProxyHandler)
624
+
625
+ print(f"""
626
+ ╔══════════════════════════════════════════════════════════════════╗
627
+ β•‘ Smart PyPI Proxy with Auto-Generated METADATA β•‘
628
+ ╠══════════════════════════════════════════════════════════════════╣
629
+ β•‘ Server: http://localhost:{port:<5} β•‘
630
+ β•‘ Index: http://localhost:{port}/simple/ β•‘
631
+ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
632
+
633
+ πŸ“¦ Dummy packages (replaced with tiny stubs):
634
+ """)
635
+ for pkg in sorted(DUMMY_PACKAGES.keys()):
636
+ print(f" β€’ {pkg}")
637
+
638
+ print(f"""
639
+ All other packages are proxied from PyPI.
640
+
641
+ Usage:
642
+ uv pip install --index-url http://localhost:{port}/simple/ <package>
643
+ pip install --index-url http://localhost:{port}/simple/ <package>
644
+
645
+ Features:
646
+ βœ“ Auto-detects latest compatible versions from PyPI
647
+ βœ“ Generates valid METADATA files (PEP 566 compliant)
648
+ βœ“ Creates proper wheel structure with RECORD hashes
649
+
650
+ Press Ctrl+C to stop.
651
+ """)
652
+
653
+ try:
654
+ httpd.serve_forever()
655
+ except KeyboardInterrupt:
656
+ print("\n\nπŸ‘‹ Shutting down server...")
657
+ httpd.shutdown()
658
+
659
+
660
+ def main():
661
+ parser = argparse.ArgumentParser(
662
+ description="PyPI Proxy CLI utility.",
663
+ formatter_class=argparse.RawDescriptionHelpFormatter,
664
+ epilog="""\
665
+ Usage examples:
666
+ python pypi_proxy.py # Run server on default port (8080)
667
+ python pypi_proxy.py --port 9000 # Run server on port 9000
668
+ python pypi_proxy.py analyze PACKAGE # Analyze dependencies
669
+ python pypi_proxy.py test-wheel [PKG] # Test wheel generation
670
+ """
671
+ )
672
+
673
+ # Global option for the server port
674
+ parser.add_argument(
675
+ '-p', '--port',
676
+ type=int,
677
+ default=8080,
678
+ help='Port to run the server on (default: 8080)'
679
+ )
680
+
681
+ # Use subparsers to handle different commands cleanly
682
+ subparsers = parser.add_subparsers(dest='command', help='Subcommands')
683
+
684
+ # 'analyze' command
685
+ analyze_parser = subparsers.add_parser('analyze', help='Analyze dependencies')
686
+ analyze_parser.add_argument('package', help='Package name to analyze')
687
+
688
+ # 'test-wheel' command
689
+ wheel_parser = subparsers.add_parser('test-wheel', help='Test wheel generation')
690
+ wheel_parser.add_argument('package', nargs='?', default='torch', help='Package name (default: torch)')
691
+ wheel_parser.add_argument('version', nargs='?', default='2.0.0', help='Package version (default: 2.0.0)')
692
+
693
+ # 'test-metadata' command
694
+ metadata_parser = subparsers.add_parser('test-metadata', help='Test metadata generation')
695
+ metadata_parser.add_argument('package', nargs='?', default='torch', help='Package name (default: torch)')
696
+ metadata_parser.add_argument('version', nargs='?', default='2.0.0', help='Package version (default: 2.0.0)')
697
+
698
+ # Parse arguments
699
+ args = parser.parse_args()
700
+
701
+ # Route to the correct function based on the command
702
+ if args.command == 'analyze':
703
+ analyze_dependencies(args.package)
704
+
705
+ elif args.command == 'test-wheel':
706
+ pkg = args.package
707
+ ver = args.version
708
+ print(f"Testing wheel generation for {pkg} {ver}...")
709
+ wheel_data = create_dummy_wheel(pkg, ver)
710
+ if wheel_data:
711
+ out_file = f"{pkg.replace('-', '_')}-{ver}-py3-none-any.whl"
712
+ with open(out_file, 'wb') as f:
713
+ f.write(wheel_data)
714
+ print(f"βœ“ Wrote: {out_file}")
715
+
716
+ # Show contents
717
+ print("\nWheel contents:")
718
+ with zipfile.ZipFile(out_file, 'r') as zf:
719
+ for name in zf.namelist():
720
+ info = zf.getinfo(name)
721
+ print(f" {name} ({info.file_size} bytes)")
722
+ # Show METADATA
723
+ for name in zf.namelist():
724
+ if name.endswith('/METADATA'):
725
+ print(f"\n{'='*60}")
726
+ print(f"METADATA content:")
727
+ print('='*60)
728
+ print(zf.read(name).decode('utf-8'))
729
+
730
+ elif args.command == 'test-metadata':
731
+ print(generate_metadata_file(args.package, args.version, include_real_deps=True))
732
+
733
+ else:
734
+ # Default behavior when no valid subcommand is passed
735
+ # Passes the parsed port argument to your server initialization
736
+ run_server(port=args.port)
737
+
738
+ if __name__ == '__main__':
739
+ main()