relenv 0.21.1__py3-none-any.whl → 0.22.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.
Files changed (49) hide show
  1. relenv/__init__.py +14 -2
  2. relenv/__main__.py +12 -6
  3. relenv/_resources/xz/config.h +148 -0
  4. relenv/_resources/xz/readme.md +4 -0
  5. relenv/build/__init__.py +28 -30
  6. relenv/build/common/__init__.py +50 -0
  7. relenv/build/common/_sysconfigdata_template.py +72 -0
  8. relenv/build/common/builder.py +907 -0
  9. relenv/build/common/builders.py +163 -0
  10. relenv/build/common/download.py +324 -0
  11. relenv/build/common/install.py +609 -0
  12. relenv/build/common/ui.py +432 -0
  13. relenv/build/darwin.py +128 -14
  14. relenv/build/linux.py +296 -78
  15. relenv/build/windows.py +259 -44
  16. relenv/buildenv.py +48 -17
  17. relenv/check.py +10 -5
  18. relenv/common.py +499 -163
  19. relenv/create.py +147 -7
  20. relenv/fetch.py +16 -4
  21. relenv/manifest.py +15 -7
  22. relenv/python-versions.json +329 -0
  23. relenv/pyversions.py +817 -30
  24. relenv/relocate.py +101 -55
  25. relenv/runtime.py +452 -253
  26. relenv/toolchain.py +9 -3
  27. {relenv-0.21.1.dist-info → relenv-0.22.0.dist-info}/METADATA +1 -1
  28. relenv-0.22.0.dist-info/RECORD +48 -0
  29. tests/__init__.py +2 -0
  30. tests/_pytest_typing.py +45 -0
  31. tests/conftest.py +42 -36
  32. tests/test_build.py +426 -9
  33. tests/test_common.py +311 -48
  34. tests/test_create.py +149 -6
  35. tests/test_downloads.py +19 -15
  36. tests/test_fips_photon.py +6 -3
  37. tests/test_module_imports.py +44 -0
  38. tests/test_pyversions_runtime.py +177 -0
  39. tests/test_relocate.py +45 -39
  40. tests/test_relocate_module.py +257 -0
  41. tests/test_runtime.py +1802 -6
  42. tests/test_verify_build.py +500 -34
  43. relenv/build/common.py +0 -1609
  44. relenv-0.21.1.dist-info/RECORD +0 -35
  45. {relenv-0.21.1.dist-info → relenv-0.22.0.dist-info}/WHEEL +0 -0
  46. {relenv-0.21.1.dist-info → relenv-0.22.0.dist-info}/entry_points.txt +0 -0
  47. {relenv-0.21.1.dist-info → relenv-0.22.0.dist-info}/licenses/LICENSE.md +0 -0
  48. {relenv-0.21.1.dist-info → relenv-0.22.0.dist-info}/licenses/NOTICE +0 -0
  49. {relenv-0.21.1.dist-info → relenv-0.22.0.dist-info}/top_level.txt +0 -0
relenv/pyversions.py CHANGED
@@ -1,4 +1,4 @@
1
- # Copyright 2025 Broadcom.
1
+ # Copyright 2022-2025 Broadcom.
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
  """
4
4
  Versions utility.
@@ -11,20 +11,35 @@ Versions utility.
11
11
  # )
12
12
  #
13
13
 
14
+ from __future__ import annotations
15
+
16
+ import argparse
14
17
  import hashlib
15
18
  import json
16
19
  import logging
17
- import os
20
+ import os as _os
18
21
  import pathlib
19
22
  import re
20
- import subprocess
21
- import sys
23
+ import subprocess as _subprocess
24
+ import sys as _sys
22
25
  import time
26
+ from typing import Any
23
27
 
24
28
  from relenv.common import Version, check_url, download_url, fetch_url_content
25
29
 
26
30
  log = logging.getLogger(__name__)
27
31
 
32
+ os = _os
33
+ subprocess = _subprocess
34
+ sys = _sys
35
+
36
+ __all__ = [
37
+ "Version",
38
+ "os",
39
+ "subprocess",
40
+ "sys",
41
+ ]
42
+
28
43
  KEYSERVERS = [
29
44
  "keyserver.ubuntu.com",
30
45
  "keys.openpgp.org",
@@ -34,16 +49,16 @@ KEYSERVERS = [
34
49
  ARCHIVE = "https://www.python.org/ftp/python/{version}/Python-{version}.{ext}"
35
50
 
36
51
 
37
- def _ref_version(x):
52
+ def _ref_version(x: str) -> Version:
38
53
  _ = x.split("Python ", 1)[1].split("<", 1)[0]
39
54
  return Version(_)
40
55
 
41
56
 
42
- def _ref_path(x):
57
+ def _ref_path(x: str) -> str:
43
58
  return x.split('href="')[1].split('"')[0]
44
59
 
45
60
 
46
- def _release_urls(version, gzip=False):
61
+ def _release_urls(version: Version, gzip: bool = False) -> tuple[str, str | None]:
47
62
  if gzip:
48
63
  tarball = f"https://www.python.org/ftp/python/{version}/Python-{version}.tgz"
49
64
  else:
@@ -54,7 +69,7 @@ def _release_urls(version, gzip=False):
54
69
  return tarball, f"{tarball}.asc"
55
70
 
56
71
 
57
- def _receive_key(keyid, server):
72
+ def _receive_key(keyid: str, server: str) -> bool:
58
73
  proc = subprocess.run(
59
74
  ["gpg", "--keyserver", server, "--recv-keys", keyid], capture_output=True
60
75
  )
@@ -63,25 +78,28 @@ def _receive_key(keyid, server):
63
78
  return False
64
79
 
65
80
 
66
- def _get_keyid(proc):
81
+ def _get_keyid(proc: subprocess.CompletedProcess[bytes]) -> str | None:
67
82
  try:
68
83
  err = proc.stderr.decode()
69
84
  return err.splitlines()[1].rsplit(" ", 1)[-1]
70
85
  except (AttributeError, IndexError):
71
- return False
86
+ return None
72
87
 
73
88
 
74
- def verify_signature(path, signature):
89
+ def verify_signature(
90
+ path: str | os.PathLike[str],
91
+ signature: str | os.PathLike[str],
92
+ ) -> bool:
75
93
  """
76
94
  Verify gpg signature.
77
95
  """
78
96
  proc = subprocess.run(["gpg", "--verify", signature, path], capture_output=True)
79
97
  keyid = _get_keyid(proc)
80
98
  if proc.returncode == 0:
81
- print(f"Valid signature {path} {keyid}")
99
+ print(f"Valid signature {path} {keyid or ''}")
82
100
  return True
83
101
  err = proc.stderr.decode()
84
- if "No public key" in err:
102
+ if keyid and "No public key" in err:
85
103
  for server in KEYSERVERS:
86
104
  if _receive_key(keyid, server):
87
105
  print(f"found public key {keyid} on {server}")
@@ -106,7 +124,7 @@ VERSION = None # '3.13.2'
106
124
  UPDATE = False
107
125
 
108
126
 
109
- def digest(file):
127
+ def digest(file: str | os.PathLike[str]) -> str:
110
128
  """
111
129
  SHA-256 digest of file.
112
130
  """
@@ -116,9 +134,9 @@ def digest(file):
116
134
  return hsh.hexdigest()
117
135
 
118
136
 
119
- def _main():
137
+ def _main() -> None:
120
138
 
121
- pyversions = {"versions": []}
139
+ pyversions: dict[str, Any] = {"versions": []}
122
140
 
123
141
  vfile = pathlib.Path(".pyversions")
124
142
  cfile = pathlib.Path(".content")
@@ -130,6 +148,8 @@ def _main():
130
148
  content = fetch_url_content(url)
131
149
  cfile.write_text(content)
132
150
  tsfile.write_text(str(ts))
151
+ pyversions = {"versions": []}
152
+ vfile.write_text(json.dumps(pyversions, indent=1))
133
153
  elif CHECK:
134
154
  ts = int(tsfile.read_text())
135
155
  if check_url(url, timestamp=ts):
@@ -152,7 +172,7 @@ def _main():
152
172
  versions = [_ for _ in parsed_versions if _.major >= 3]
153
173
  cwd = os.getcwd()
154
174
 
155
- out = {}
175
+ out: dict[str, dict[str, str]] = {}
156
176
 
157
177
  for version in versions:
158
178
  if VERSION and Version(VERSION) != version:
@@ -210,7 +230,626 @@ def _main():
210
230
  vfile.write_text(json.dumps(out, indent=1))
211
231
 
212
232
 
213
- def create_pyversions(path):
233
+ def sha256_digest(file: str | os.PathLike[str]) -> str:
234
+ """
235
+ SHA-256 digest of file.
236
+ """
237
+ hsh = hashlib.sha256()
238
+ with open(file, "rb") as fp:
239
+ hsh.update(fp.read())
240
+ return hsh.hexdigest()
241
+
242
+
243
+ def detect_openssl_versions() -> list[str]:
244
+ """
245
+ Detect available OpenSSL versions from GitHub releases.
246
+ """
247
+ url = "https://github.com/openssl/openssl/tags"
248
+ content = fetch_url_content(url)
249
+ # Find tags like openssl-3.5.4
250
+ pattern = r'openssl-(\d+\.\d+\.\d+)"'
251
+ matches = re.findall(pattern, content)
252
+ # Deduplicate and sort
253
+ versions = sorted(
254
+ set(matches), key=lambda v: [int(x) for x in v.split(".")], reverse=True
255
+ )
256
+ return versions
257
+
258
+
259
+ def detect_sqlite_versions() -> list[tuple[str, str]]:
260
+ """
261
+ Detect available SQLite versions from sqlite.org.
262
+
263
+ Returns list of (version, sqliteversion) tuples.
264
+ """
265
+ url = "https://sqlite.org/download.html"
266
+ content = fetch_url_content(url)
267
+ # Find sqlite-autoconf-NNNNNNN.tar.gz
268
+ pattern = r"sqlite-autoconf-(\d{7})\.tar\.gz"
269
+ matches = re.findall(pattern, content)
270
+ # Convert to version format
271
+ versions = []
272
+ for sqlite_ver in set(matches):
273
+ # SQLite version format: 3XXYYZZ where XX=minor, YY=patch, ZZ=subpatch
274
+ if len(sqlite_ver) == 7 and sqlite_ver[0] == "3":
275
+ major = 3
276
+ minor = int(sqlite_ver[1:3])
277
+ patch = int(sqlite_ver[3:5])
278
+ subpatch = int(sqlite_ver[5:7])
279
+ version = f"{major}.{minor}.{patch}.{subpatch}"
280
+ versions.append((version, sqlite_ver))
281
+ return sorted(
282
+ versions, key=lambda x: [int(n) for n in x[0].split(".")], reverse=True
283
+ )
284
+
285
+
286
+ def detect_xz_versions() -> list[str]:
287
+ """
288
+ Detect available XZ versions from tukaani.org.
289
+ """
290
+ url = "https://tukaani.org/xz/"
291
+ content = fetch_url_content(url)
292
+ # Find xz-X.Y.Z.tar.gz
293
+ pattern = r"xz-(\d+\.\d+\.\d+)\.tar\.gz"
294
+ matches = re.findall(pattern, content)
295
+ # Deduplicate and sort
296
+ versions = sorted(
297
+ set(matches), key=lambda v: [int(x) for x in v.split(".")], reverse=True
298
+ )
299
+ return versions
300
+
301
+
302
+ def detect_libffi_versions() -> list[str]:
303
+ """Detect available libffi versions from GitHub releases."""
304
+ url = "https://github.com/libffi/libffi/tags"
305
+ content = fetch_url_content(url)
306
+ pattern = r'v(\d+\.\d+\.\d+)"'
307
+ matches = re.findall(pattern, content)
308
+ return sorted(
309
+ set(matches), key=lambda v: [int(x) for x in v.split(".")], reverse=True
310
+ )
311
+
312
+
313
+ def detect_zlib_versions() -> list[str]:
314
+ """Detect available zlib versions from zlib.net."""
315
+ url = "https://zlib.net/"
316
+ content = fetch_url_content(url)
317
+ pattern = r"zlib-(\d+\.\d+\.\d+)\.tar\.gz"
318
+ matches = re.findall(pattern, content)
319
+ return sorted(
320
+ set(matches), key=lambda v: [int(x) for x in v.split(".")], reverse=True
321
+ )
322
+
323
+
324
+ def detect_bzip2_versions() -> list[str]:
325
+ """Detect available bzip2 versions from sourceware.org."""
326
+ url = "https://sourceware.org/pub/bzip2/"
327
+ content = fetch_url_content(url)
328
+ pattern = r"bzip2-(\d+\.\d+\.\d+)\.tar\.gz"
329
+ matches = re.findall(pattern, content)
330
+ return sorted(
331
+ set(matches), key=lambda v: [int(x) for x in v.split(".")], reverse=True
332
+ )
333
+
334
+
335
+ def detect_ncurses_versions() -> list[str]:
336
+ """Detect available ncurses versions from GNU mirrors."""
337
+ url = "https://mirrors.ocf.berkeley.edu/gnu/ncurses/"
338
+ content = fetch_url_content(url)
339
+ pattern = r"ncurses-(\d+\.\d+)\.tar\.gz"
340
+ matches = re.findall(pattern, content)
341
+ return sorted(
342
+ set(matches), key=lambda v: [int(x) for x in v.split(".")], reverse=True
343
+ )
344
+
345
+
346
+ def detect_readline_versions() -> list[str]:
347
+ """Detect available readline versions from GNU mirrors."""
348
+ url = "https://mirrors.ocf.berkeley.edu/gnu/readline/"
349
+ content = fetch_url_content(url)
350
+ pattern = r"readline-(\d+\.\d+)\.tar\.gz"
351
+ matches = re.findall(pattern, content)
352
+ return sorted(
353
+ set(matches), key=lambda v: [int(x) for x in v.split(".")], reverse=True
354
+ )
355
+
356
+
357
+ def detect_gdbm_versions() -> list[str]:
358
+ """Detect available gdbm versions from GNU mirrors."""
359
+ url = "https://mirrors.ocf.berkeley.edu/gnu/gdbm/"
360
+ content = fetch_url_content(url)
361
+ pattern = r"gdbm-(\d+\.\d+)\.tar\.gz"
362
+ matches = re.findall(pattern, content)
363
+ return sorted(
364
+ set(matches), key=lambda v: [int(x) for x in v.split(".")], reverse=True
365
+ )
366
+
367
+
368
+ def detect_libxcrypt_versions() -> list[str]:
369
+ """Detect available libxcrypt versions from GitHub releases."""
370
+ url = "https://github.com/besser82/libxcrypt/tags"
371
+ content = fetch_url_content(url)
372
+ pattern = r'v(\d+\.\d+\.\d+)"'
373
+ matches = re.findall(pattern, content)
374
+ return sorted(
375
+ set(matches), key=lambda v: [int(x) for x in v.split(".")], reverse=True
376
+ )
377
+
378
+
379
+ def detect_krb5_versions() -> list[str]:
380
+ """Detect available krb5 versions from kerberos.org."""
381
+ url = "https://kerberos.org/dist/krb5/"
382
+ content = fetch_url_content(url)
383
+ # krb5 versions are like 1.22/
384
+ pattern = r"(\d+\.\d+)/"
385
+ matches = re.findall(pattern, content)
386
+ return sorted(
387
+ set(matches), key=lambda v: [int(x) for x in v.split(".")], reverse=True
388
+ )
389
+
390
+
391
+ def detect_uuid_versions() -> list[str]:
392
+ """Detect available libuuid versions from SourceForge."""
393
+ url = "https://sourceforge.net/projects/libuuid/files/"
394
+ content = fetch_url_content(url)
395
+ pattern = r"libuuid-(\d+\.\d+\.\d+)\.tar\.gz"
396
+ matches = re.findall(pattern, content)
397
+ return sorted(
398
+ set(matches), key=lambda v: [int(x) for x in v.split(".")], reverse=True
399
+ )
400
+
401
+
402
+ def detect_tirpc_versions() -> list[str]:
403
+ """Detect available libtirpc versions from SourceForge."""
404
+ url = "https://sourceforge.net/projects/libtirpc/files/libtirpc/"
405
+ content = fetch_url_content(url)
406
+ pattern = r"(\d+\.\d+\.\d+)/"
407
+ matches = re.findall(pattern, content)
408
+ return sorted(
409
+ set(matches), key=lambda v: [int(x) for x in v.split(".")], reverse=True
410
+ )
411
+
412
+
413
+ def detect_expat_versions() -> list[str]:
414
+ """Detect available expat versions from GitHub releases."""
415
+ url = "https://github.com/libexpat/libexpat/tags"
416
+ content = fetch_url_content(url)
417
+ # Expat versions are tagged like R_2_7_3
418
+ pattern = r'R_(\d+)_(\d+)_(\d+)"'
419
+ matches = re.findall(pattern, content)
420
+ # Convert R_2_7_3 to 2.7.3
421
+ versions = [f"{m[0]}.{m[1]}.{m[2]}" for m in matches]
422
+ return sorted(
423
+ set(versions), key=lambda v: [int(x) for x in v.split(".")], reverse=True
424
+ )
425
+
426
+
427
+ def update_dependency_versions(
428
+ path: pathlib.Path, deps_to_update: list[str] | None = None
429
+ ) -> None:
430
+ """
431
+ Update dependency versions in python-versions.json.
432
+
433
+ Downloads tarballs, computes SHA-256, and updates the JSON file.
434
+
435
+ :param path: Path to python-versions.json
436
+ :param deps_to_update: List of dependencies to update (openssl, sqlite, xz), or None for all
437
+ """
438
+ cwd = os.getcwd()
439
+
440
+ # Read existing data
441
+ if path.exists():
442
+ all_data = json.loads(path.read_text())
443
+ if "python" in all_data:
444
+ pydata = all_data["python"]
445
+ dependencies = all_data.get("dependencies", {})
446
+ else:
447
+ # Old format
448
+ pydata = all_data
449
+ dependencies = {}
450
+ else:
451
+ pydata = {}
452
+ dependencies = {}
453
+
454
+ # Determine which dependencies to update
455
+ if deps_to_update is None:
456
+ # By default, update commonly-changed dependencies
457
+ # Full list: openssl, sqlite, xz, libffi, zlib, bzip2, ncurses,
458
+ # readline, gdbm, libxcrypt, krb5, uuid, tirpc, expat
459
+ deps_to_update = [
460
+ "openssl",
461
+ "sqlite",
462
+ "xz",
463
+ "libffi",
464
+ "zlib",
465
+ "ncurses",
466
+ "readline",
467
+ "gdbm",
468
+ "libxcrypt",
469
+ "krb5",
470
+ "bzip2",
471
+ "uuid",
472
+ "tirpc",
473
+ "expat",
474
+ ]
475
+
476
+ # Update OpenSSL
477
+ if "openssl" in deps_to_update:
478
+ print("Checking OpenSSL versions...")
479
+ openssl_versions = detect_openssl_versions()
480
+ if openssl_versions:
481
+ latest = openssl_versions[0]
482
+ print(f"Latest OpenSSL: {latest}")
483
+ if "openssl" not in dependencies:
484
+ dependencies["openssl"] = {}
485
+ if latest not in dependencies["openssl"]:
486
+ url = f"https://github.com/openssl/openssl/releases/download/openssl-{latest}/openssl-{latest}.tar.gz"
487
+ print(f"Downloading {url}...")
488
+ download_path = download_url(url, cwd)
489
+ checksum = sha256_digest(download_path)
490
+ print(f"SHA-256: {checksum}")
491
+ url_template = (
492
+ "https://github.com/openssl/openssl/releases/download/"
493
+ "openssl-{version}/openssl-{version}.tar.gz"
494
+ )
495
+ dependencies["openssl"][latest] = {
496
+ "url": url_template,
497
+ "sha256": checksum,
498
+ "platforms": ["linux", "darwin"],
499
+ }
500
+ # Clean up download
501
+ os.remove(download_path)
502
+
503
+ # Update SQLite
504
+ if "sqlite" in deps_to_update:
505
+ print("Checking SQLite versions...")
506
+ sqlite_versions = detect_sqlite_versions()
507
+ if sqlite_versions:
508
+ latest_version, latest_sqliteversion = sqlite_versions[0]
509
+ print(
510
+ f"Latest SQLite: {latest_version} (sqlite version {latest_sqliteversion})"
511
+ )
512
+ if "sqlite" not in dependencies:
513
+ dependencies["sqlite"] = {}
514
+ if latest_version not in dependencies["sqlite"]:
515
+ # SQLite URLs include year, try current year
516
+ import datetime
517
+
518
+ year = datetime.datetime.now().year
519
+ url = f"https://sqlite.org/{year}/sqlite-autoconf-{latest_sqliteversion}.tar.gz"
520
+ print(f"Downloading {url}...")
521
+ try:
522
+ download_path = download_url(url, cwd)
523
+ checksum = sha256_digest(download_path)
524
+ print(f"SHA-256: {checksum}")
525
+ # Store URL with actual year and {version} placeholder (not {sqliteversion})
526
+ # The build scripts pass sqliteversion value as "version" parameter
527
+ dependencies["sqlite"][latest_version] = {
528
+ "url": f"https://sqlite.org/{year}/sqlite-autoconf-{{version}}.tar.gz",
529
+ "sha256": checksum,
530
+ "sqliteversion": latest_sqliteversion,
531
+ "platforms": ["linux", "darwin", "win32"],
532
+ }
533
+ # Clean up download
534
+ os.remove(download_path)
535
+ except Exception as e:
536
+ print(f"Failed to download SQLite: {e}")
537
+
538
+ # Update XZ
539
+ if "xz" in deps_to_update:
540
+ print("Checking XZ versions...")
541
+ xz_versions = detect_xz_versions()
542
+ if xz_versions:
543
+ latest = xz_versions[0]
544
+ print(f"Latest XZ: {latest}")
545
+ if "xz" not in dependencies:
546
+ dependencies["xz"] = {}
547
+ if latest not in dependencies["xz"]:
548
+ url = f"http://tukaani.org/xz/xz-{latest}.tar.gz"
549
+ print(f"Downloading {url}...")
550
+ download_path = download_url(url, cwd)
551
+ checksum = sha256_digest(download_path)
552
+ print(f"SHA-256: {checksum}")
553
+ dependencies["xz"][latest] = {
554
+ "url": "http://tukaani.org/xz/xz-{version}.tar.gz",
555
+ "sha256": checksum,
556
+ "platforms": ["linux", "darwin", "win32"],
557
+ }
558
+ # Clean up download
559
+ os.remove(download_path)
560
+
561
+ # Update libffi
562
+ if "libffi" in deps_to_update:
563
+ print("Checking libffi versions...")
564
+ libffi_versions = detect_libffi_versions()
565
+ if libffi_versions:
566
+ latest = libffi_versions[0]
567
+ print(f"Latest libffi: {latest}")
568
+ if "libffi" not in dependencies:
569
+ dependencies["libffi"] = {}
570
+ if latest not in dependencies["libffi"]:
571
+ url = f"https://github.com/libffi/libffi/releases/download/v{latest}/libffi-{latest}.tar.gz"
572
+ print(f"Downloading {url}...")
573
+ try:
574
+ download_path = download_url(url, cwd)
575
+ checksum = sha256_digest(download_path)
576
+ print(f"SHA-256: {checksum}")
577
+ dependencies["libffi"][latest] = {
578
+ "url": "https://github.com/libffi/libffi/releases/download/v{version}/libffi-{version}.tar.gz",
579
+ "sha256": checksum,
580
+ "platforms": ["linux"],
581
+ }
582
+ os.remove(download_path)
583
+ except Exception as e:
584
+ print(f"Failed to download libffi: {e}")
585
+
586
+ # Update zlib
587
+ if "zlib" in deps_to_update:
588
+ print("Checking zlib versions...")
589
+ zlib_versions = detect_zlib_versions()
590
+ if zlib_versions:
591
+ latest = zlib_versions[0]
592
+ print(f"Latest zlib: {latest}")
593
+ if "zlib" not in dependencies:
594
+ dependencies["zlib"] = {}
595
+ if latest not in dependencies["zlib"]:
596
+ url = f"https://zlib.net/fossils/zlib-{latest}.tar.gz"
597
+ print(f"Downloading {url}...")
598
+ try:
599
+ download_path = download_url(url, cwd)
600
+ checksum = sha256_digest(download_path)
601
+ print(f"SHA-256: {checksum}")
602
+ dependencies["zlib"][latest] = {
603
+ "url": "https://zlib.net/fossils/zlib-{version}.tar.gz",
604
+ "sha256": checksum,
605
+ "platforms": ["linux"],
606
+ }
607
+ os.remove(download_path)
608
+ except Exception as e:
609
+ print(f"Failed to download zlib: {e}")
610
+
611
+ # Update ncurses
612
+ if "ncurses" in deps_to_update:
613
+ print("Checking ncurses versions...")
614
+ ncurses_versions = detect_ncurses_versions()
615
+ if ncurses_versions:
616
+ latest = ncurses_versions[0]
617
+ print(f"Latest ncurses: {latest}")
618
+ if "ncurses" not in dependencies:
619
+ dependencies["ncurses"] = {}
620
+ if latest not in dependencies["ncurses"]:
621
+ url = f"https://mirrors.ocf.berkeley.edu/gnu/ncurses/ncurses-{latest}.tar.gz"
622
+ print(f"Downloading {url}...")
623
+ try:
624
+ download_path = download_url(url, cwd)
625
+ checksum = sha256_digest(download_path)
626
+ print(f"SHA-256: {checksum}")
627
+ dependencies["ncurses"][latest] = {
628
+ "url": "https://mirrors.ocf.berkeley.edu/gnu/ncurses/ncurses-{version}.tar.gz",
629
+ "sha256": checksum,
630
+ "platforms": ["linux"],
631
+ }
632
+ os.remove(download_path)
633
+ except Exception as e:
634
+ print(f"Failed to download ncurses: {e}")
635
+
636
+ # Update readline
637
+ if "readline" in deps_to_update:
638
+ print("Checking readline versions...")
639
+ readline_versions = detect_readline_versions()
640
+ if readline_versions:
641
+ latest = readline_versions[0]
642
+ print(f"Latest readline: {latest}")
643
+ if "readline" not in dependencies:
644
+ dependencies["readline"] = {}
645
+ if latest not in dependencies["readline"]:
646
+ url = f"https://mirrors.ocf.berkeley.edu/gnu/readline/readline-{latest}.tar.gz"
647
+ print(f"Downloading {url}...")
648
+ try:
649
+ download_path = download_url(url, cwd)
650
+ checksum = sha256_digest(download_path)
651
+ print(f"SHA-256: {checksum}")
652
+ dependencies["readline"][latest] = {
653
+ "url": "https://mirrors.ocf.berkeley.edu/gnu/readline/readline-{version}.tar.gz",
654
+ "sha256": checksum,
655
+ "platforms": ["linux"],
656
+ }
657
+ os.remove(download_path)
658
+ except Exception as e:
659
+ print(f"Failed to download readline: {e}")
660
+
661
+ # Update gdbm
662
+ if "gdbm" in deps_to_update:
663
+ print("Checking gdbm versions...")
664
+ gdbm_versions = detect_gdbm_versions()
665
+ if gdbm_versions:
666
+ latest = gdbm_versions[0]
667
+ print(f"Latest gdbm: {latest}")
668
+ if "gdbm" not in dependencies:
669
+ dependencies["gdbm"] = {}
670
+ if latest not in dependencies["gdbm"]:
671
+ url = f"https://mirrors.ocf.berkeley.edu/gnu/gdbm/gdbm-{latest}.tar.gz"
672
+ print(f"Downloading {url}...")
673
+ try:
674
+ download_path = download_url(url, cwd)
675
+ checksum = sha256_digest(download_path)
676
+ print(f"SHA-256: {checksum}")
677
+ dependencies["gdbm"][latest] = {
678
+ "url": "https://mirrors.ocf.berkeley.edu/gnu/gdbm/gdbm-{version}.tar.gz",
679
+ "sha256": checksum,
680
+ "platforms": ["linux"],
681
+ }
682
+ os.remove(download_path)
683
+ except Exception as e:
684
+ print(f"Failed to download gdbm: {e}")
685
+
686
+ # Update libxcrypt
687
+ if "libxcrypt" in deps_to_update:
688
+ print("Checking libxcrypt versions...")
689
+ libxcrypt_versions = detect_libxcrypt_versions()
690
+ if libxcrypt_versions:
691
+ latest = libxcrypt_versions[0]
692
+ print(f"Latest libxcrypt: {latest}")
693
+ if "libxcrypt" not in dependencies:
694
+ dependencies["libxcrypt"] = {}
695
+ if latest not in dependencies["libxcrypt"]:
696
+ url = f"https://github.com/besser82/libxcrypt/releases/download/v{latest}/libxcrypt-{latest}.tar.xz"
697
+ print(f"Downloading {url}...")
698
+ try:
699
+ download_path = download_url(url, cwd)
700
+ checksum = sha256_digest(download_path)
701
+ print(f"SHA-256: {checksum}")
702
+ dependencies["libxcrypt"][latest] = {
703
+ "url": (
704
+ "https://github.com/besser82/libxcrypt/releases/"
705
+ "download/v{version}/libxcrypt-{version}.tar.xz"
706
+ ),
707
+ "sha256": checksum,
708
+ "platforms": ["linux"],
709
+ }
710
+ os.remove(download_path)
711
+ except Exception as e:
712
+ print(f"Failed to download libxcrypt: {e}")
713
+
714
+ # Update krb5
715
+ if "krb5" in deps_to_update:
716
+ print("Checking krb5 versions...")
717
+ krb5_versions = detect_krb5_versions()
718
+ if krb5_versions:
719
+ latest = krb5_versions[0]
720
+ print(f"Latest krb5: {latest}")
721
+ if "krb5" not in dependencies:
722
+ dependencies["krb5"] = {}
723
+ if latest not in dependencies["krb5"]:
724
+ url = f"https://kerberos.org/dist/krb5/{latest}/krb5-{latest}.tar.gz"
725
+ print(f"Downloading {url}...")
726
+ try:
727
+ download_path = download_url(url, cwd)
728
+ checksum = sha256_digest(download_path)
729
+ print(f"SHA-256: {checksum}")
730
+ dependencies["krb5"][latest] = {
731
+ "url": "https://kerberos.org/dist/krb5/{version}/krb5-{version}.tar.gz",
732
+ "sha256": checksum,
733
+ "platforms": ["linux"],
734
+ }
735
+ os.remove(download_path)
736
+ except Exception as e:
737
+ print(f"Failed to download krb5: {e}")
738
+
739
+ # Update bzip2
740
+ if "bzip2" in deps_to_update:
741
+ print("Checking bzip2 versions...")
742
+ bzip2_versions = detect_bzip2_versions()
743
+ if bzip2_versions:
744
+ latest = bzip2_versions[0]
745
+ print(f"Latest bzip2: {latest}")
746
+ if "bzip2" not in dependencies:
747
+ dependencies["bzip2"] = {}
748
+ if latest not in dependencies["bzip2"]:
749
+ url = f"https://sourceware.org/pub/bzip2/bzip2-{latest}.tar.gz"
750
+ print(f"Downloading {url}...")
751
+ try:
752
+ download_path = download_url(url, cwd)
753
+ checksum = sha256_digest(download_path)
754
+ print(f"SHA-256: {checksum}")
755
+ dependencies["bzip2"][latest] = {
756
+ "url": "https://sourceware.org/pub/bzip2/bzip2-{version}.tar.gz",
757
+ "sha256": checksum,
758
+ "platforms": ["linux", "darwin"],
759
+ }
760
+ os.remove(download_path)
761
+ except Exception as e:
762
+ print(f"Failed to download bzip2: {e}")
763
+
764
+ # Update uuid
765
+ if "uuid" in deps_to_update:
766
+ print("Checking uuid versions...")
767
+ uuid_versions = detect_uuid_versions()
768
+ if uuid_versions:
769
+ latest = uuid_versions[0]
770
+ print(f"Latest uuid: {latest}")
771
+ if "uuid" not in dependencies:
772
+ dependencies["uuid"] = {}
773
+ if latest not in dependencies["uuid"]:
774
+ url = f"https://sourceforge.net/projects/libuuid/files/libuuid-{latest}.tar.gz"
775
+ print(f"Downloading {url}...")
776
+ try:
777
+ download_path = download_url(url, cwd)
778
+ checksum = sha256_digest(download_path)
779
+ print(f"SHA-256: {checksum}")
780
+ dependencies["uuid"][latest] = {
781
+ "url": "https://sourceforge.net/projects/libuuid/files/libuuid-{version}.tar.gz",
782
+ "sha256": checksum,
783
+ "platforms": ["linux"],
784
+ }
785
+ os.remove(download_path)
786
+ except Exception as e:
787
+ print(f"Failed to download uuid: {e}")
788
+
789
+ # Update tirpc
790
+ if "tirpc" in deps_to_update:
791
+ print("Checking tirpc versions...")
792
+ tirpc_versions = detect_tirpc_versions()
793
+ if tirpc_versions:
794
+ latest = tirpc_versions[0]
795
+ print(f"Latest tirpc: {latest}")
796
+ if "tirpc" not in dependencies:
797
+ dependencies["tirpc"] = {}
798
+ if latest not in dependencies["tirpc"]:
799
+ url = f"https://sourceforge.net/projects/libtirpc/files/libtirpc-{latest}.tar.bz2"
800
+ print(f"Downloading {url}...")
801
+ try:
802
+ download_path = download_url(url, cwd)
803
+ checksum = sha256_digest(download_path)
804
+ print(f"SHA-256: {checksum}")
805
+ dependencies["tirpc"][latest] = {
806
+ "url": "https://sourceforge.net/projects/libtirpc/files/libtirpc-{version}.tar.bz2",
807
+ "sha256": checksum,
808
+ "platforms": ["linux"],
809
+ }
810
+ os.remove(download_path)
811
+ except Exception as e:
812
+ print(f"Failed to download tirpc: {e}")
813
+
814
+ # Update expat
815
+ if "expat" in deps_to_update:
816
+ print("Checking expat versions...")
817
+ expat_versions = detect_expat_versions()
818
+ if expat_versions:
819
+ latest = expat_versions[0]
820
+ print(f"Latest expat: {latest}")
821
+ if "expat" not in dependencies:
822
+ dependencies["expat"] = {}
823
+ if latest not in dependencies["expat"]:
824
+ # Expat uses R_X_Y_Z format for releases
825
+ version_tag = latest.replace(".", "_")
826
+ url = f"https://github.com/libexpat/libexpat/releases/download/R_{version_tag}/expat-{latest}.tar.xz"
827
+ print(f"Downloading {url}...")
828
+ try:
829
+ download_path = download_url(url, cwd)
830
+ checksum = sha256_digest(download_path)
831
+ print(f"SHA-256: {checksum}")
832
+ # Store URL template with placeholder for version
833
+ # Build scripts will construct actual URL dynamically from version
834
+ dependencies["expat"][latest] = {
835
+ "url": (
836
+ f"https://github.com/libexpat/libexpat/releases/"
837
+ f"download/R_{version_tag}/expat-{{version}}.tar.xz"
838
+ ),
839
+ "sha256": checksum,
840
+ "platforms": ["linux", "darwin", "win32"],
841
+ }
842
+ os.remove(download_path)
843
+ except Exception as e:
844
+ print(f"Failed to download expat: {e}")
845
+
846
+ # Write updated data
847
+ all_data = {"python": pydata, "dependencies": dependencies}
848
+ path.write_text(json.dumps(all_data, indent=1))
849
+ print(f"Updated {path}")
850
+
851
+
852
+ def create_pyversions(path: pathlib.Path) -> None:
214
853
  """
215
854
  Create python-versions.json file.
216
855
  """
@@ -222,15 +861,24 @@ def create_pyversions(path):
222
861
  versions = [_ for _ in parsed_versions if _.major >= 3]
223
862
 
224
863
  if path.exists():
225
- data = json.loads(path.read_text())
864
+ all_data = json.loads(path.read_text())
865
+ # Handle both old format (flat dict) and new format (nested)
866
+ if "python" in all_data:
867
+ pydata = all_data["python"]
868
+ dependencies = all_data.get("dependencies", {})
869
+ else:
870
+ # Old format - convert to new
871
+ pydata = all_data
872
+ dependencies = {}
226
873
  else:
227
- data = {}
874
+ pydata = {}
875
+ dependencies = {}
228
876
 
229
877
  for version in versions:
230
878
  if version >= Version("3.14"):
231
879
  continue
232
880
 
233
- if str(version) in data:
881
+ if str(version) in pydata:
234
882
  continue
235
883
 
236
884
  if version <= Version("3.2") and version.micro == 0:
@@ -246,23 +894,34 @@ def create_pyversions(path):
246
894
  verified = verify_signature(download_path, sig_path)
247
895
  if verified:
248
896
  print(f"Version {version} has digest {digest(download_path)}")
249
- data[str(version)] = digest(download_path)
897
+ pydata[str(version)] = digest(download_path)
250
898
  else:
251
899
  raise Exception("Signature failed to verify: {url}")
252
900
 
253
- path.write_text(json.dumps(data, indent=1))
901
+ # Write in new structured format
902
+ all_data = {"python": pydata, "dependencies": dependencies}
903
+ path.write_text(json.dumps(all_data, indent=1))
254
904
 
255
- # path.write_text(json.dumps({"versions": [str(_) for _ in versions]}))
256
- path.write_text(json.dumps(data, indent=1))
905
+ # Final write in new structured format
906
+ all_data = {"python": pydata, "dependencies": dependencies}
907
+ path.write_text(json.dumps(all_data, indent=1))
257
908
 
258
909
 
259
- def python_versions(minor=None, create=False, update=False):
910
+ def python_versions(
911
+ minor: str | None = None,
912
+ *,
913
+ create: bool = False,
914
+ update: bool = False,
915
+ ) -> dict[Version, str]:
260
916
  """
261
917
  List python versions.
262
918
  """
263
919
  packaged = pathlib.Path(__file__).parent / "python-versions.json"
264
920
  local = pathlib.Path("~/.local/relenv/python-versions.json")
265
921
 
922
+ if update:
923
+ create = True
924
+
266
925
  if create:
267
926
  create_pyversions(packaged)
268
927
 
@@ -274,15 +933,23 @@ def python_versions(minor=None, create=False, update=False):
274
933
  readfrom = packaged
275
934
  else:
276
935
  raise RuntimeError("No versions file found")
277
- pyversions = json.loads(readfrom.read_text())
936
+ data = json.loads(readfrom.read_text())
937
+ # Handle both old format (flat dict) and new format (nested with "python" key)
938
+ pyversions = (
939
+ data.get("python", data)
940
+ if isinstance(data, dict) and "python" in data
941
+ else data
942
+ )
278
943
  versions = [Version(_) for _ in pyversions]
279
944
  if minor:
280
945
  mv = Version(minor)
281
946
  versions = [_ for _ in versions if _.major == mv.major and _.minor == mv.minor]
282
- return {_: pyversions[str(_)] for _ in versions}
947
+ return {version: pyversions[str(version)] for version in versions}
283
948
 
284
949
 
285
- def setup_parser(subparsers):
950
+ def setup_parser(
951
+ subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
952
+ ) -> None:
286
953
  """
287
954
  Setup the subparser for the ``versions`` command.
288
955
 
@@ -314,12 +981,132 @@ def setup_parser(subparsers):
314
981
  type=str,
315
982
  help="The python version [default: %(default)s]",
316
983
  )
984
+ subparser.add_argument(
985
+ "--check-deps",
986
+ default=False,
987
+ action="store_true",
988
+ help="Check for new dependency versions",
989
+ )
990
+ subparser.add_argument(
991
+ "--update-deps",
992
+ default=False,
993
+ action="store_true",
994
+ help="Update dependency versions (downloads and computes checksums)",
995
+ )
317
996
 
318
997
 
319
- def main(args):
998
+ def main(args: argparse.Namespace) -> None:
320
999
  """
321
1000
  Versions utility main method.
322
1001
  """
1002
+ packaged = pathlib.Path(__file__).parent / "python-versions.json"
1003
+
1004
+ # Handle dependency operations
1005
+ if args.check_deps:
1006
+ print("Checking for new dependency versions...\n")
1007
+
1008
+ # Load current versions from JSON
1009
+ with open(packaged) as f:
1010
+ data = json.load(f)
1011
+
1012
+ current_deps = data.get("dependencies", {})
1013
+ updates_available = []
1014
+ up_to_date = []
1015
+
1016
+ # Detect terminal capabilities for fancy vs ASCII output
1017
+ use_unicode = True
1018
+ if sys.platform == "win32":
1019
+ # Check if we're in a modern terminal that supports Unicode
1020
+ import os
1021
+
1022
+ # Windows Terminal and modern PowerShell support Unicode
1023
+ wt_session = os.environ.get("WT_SESSION")
1024
+ term_program = os.environ.get("TERM_PROGRAM")
1025
+ if not wt_session and not term_program:
1026
+ # Likely cmd.exe or old PowerShell, use ASCII
1027
+ use_unicode = False
1028
+
1029
+ if use_unicode:
1030
+ ok_symbol = "✓"
1031
+ update_symbol = "⚠"
1032
+ new_symbol = "✗"
1033
+ arrow = "→"
1034
+ else:
1035
+ ok_symbol = "[OK] "
1036
+ update_symbol = "[UPDATE]"
1037
+ new_symbol = "[NEW] "
1038
+ arrow = "->"
1039
+
1040
+ # Check each dependency
1041
+ checks = [
1042
+ ("openssl", "OpenSSL", detect_openssl_versions),
1043
+ ("sqlite", "SQLite", detect_sqlite_versions),
1044
+ ("xz", "XZ", detect_xz_versions),
1045
+ ("libffi", "libffi", detect_libffi_versions),
1046
+ ("zlib", "zlib", detect_zlib_versions),
1047
+ ("ncurses", "ncurses", detect_ncurses_versions),
1048
+ ("readline", "readline", detect_readline_versions),
1049
+ ("gdbm", "gdbm", detect_gdbm_versions),
1050
+ ("libxcrypt", "libxcrypt", detect_libxcrypt_versions),
1051
+ ("krb5", "krb5", detect_krb5_versions),
1052
+ ("bzip2", "bzip2", detect_bzip2_versions),
1053
+ ("uuid", "uuid", detect_uuid_versions),
1054
+ ("tirpc", "tirpc", detect_tirpc_versions),
1055
+ ("expat", "expat", detect_expat_versions),
1056
+ ]
1057
+
1058
+ for dep_key, dep_name, detect_func in checks:
1059
+ detected = detect_func()
1060
+ if not detected:
1061
+ continue
1062
+
1063
+ # Handle SQLite's tuple return
1064
+ if dep_key == "sqlite":
1065
+ latest_version = detected[0][0] # type: ignore[index]
1066
+ else:
1067
+ latest_version = detected[0] # type: ignore[index]
1068
+
1069
+ # Get current version from JSON
1070
+ current_version = None
1071
+ if dep_key in current_deps:
1072
+ versions = sorted(current_deps[dep_key].keys(), reverse=True)
1073
+ if versions:
1074
+ current_version = versions[0]
1075
+
1076
+ # Compare versions
1077
+ if current_version == latest_version:
1078
+ print(
1079
+ f"{ok_symbol} {dep_name:12} {current_version:15} " f"(up-to-date)"
1080
+ )
1081
+ up_to_date.append(dep_name)
1082
+ elif current_version:
1083
+ print(
1084
+ f"{update_symbol} {dep_name:12} {current_version:15} "
1085
+ f"{arrow} {latest_version} (update available)"
1086
+ )
1087
+ updates_available.append((dep_name, current_version, latest_version))
1088
+ else:
1089
+ print(
1090
+ f"{new_symbol} {dep_name:12} {'(not tracked)':15} "
1091
+ f"{arrow} {latest_version}"
1092
+ )
1093
+ updates_available.append((dep_name, None, latest_version))
1094
+
1095
+ # Summary
1096
+ print(f"\n{'=' * 60}")
1097
+ print(f"Summary: {len(up_to_date)} up-to-date, ", end="")
1098
+ print(f"{len(updates_available)} updates available")
1099
+
1100
+ if updates_available:
1101
+ print("\nTo update dependencies, run:")
1102
+ print(" python3 -m relenv versions --update-deps")
1103
+
1104
+ sys.exit(0)
1105
+
1106
+ if args.update_deps:
1107
+ update_dependency_versions(packaged)
1108
+ sys.exit(0)
1109
+
323
1110
  if args.update:
324
1111
  python_versions(create=True)
325
1112
  if args.list: