diff --git a/relenv/build/darwin.py b/relenv/build/darwin.py
index 4016fcdf..ab6c8a4b 100644
--- a/relenv/build/darwin.py
+++ b/relenv/build/darwin.py
@@ -106,6 +106,20 @@ def update_expat(dirs: Dirs, env: MutableMapping[str, str]) -> None:
for target_file in updated_files:
os.utime(target_file, (now, now))
+ # For expat >= 2.8.0, new entropy source files are required but not compiled
+ # by Python's build system. Include them directly in xmlparse.c.
+ xmlparse_c = expat_dir / "xmlparse.c"
+ if xmlparse_c.exists():
+ with open(str(xmlparse_c), "a") as f:
+ f.write("\n/* Relenv: include new entropy sources for expat >= 2.8.0 */\n")
+ f.write('#if defined(_WIN32)\n#include "random_rand_s.c"\n#endif\n')
+ f.write('#if defined(HAVE_GETENTROPY)\n#include "random_getentropy.c"\n#endif\n')
+ f.write("#if defined(HAVE_GETRANDOM) || defined(HAVE_SYSCALL_GETRANDOM)\n")
+ f.write('#include "random_getrandom.c"\n#endif\n')
+ f.write('#if defined(HAVE_ARC4RANDOM_BUF)\n#include "random_arc4random_buf.c"\n#endif\n')
+ f.write('#if defined(HAVE_ARC4RANDOM)\n#include "random_arc4random.c"\n#endif\n')
+ f.write('#if !defined(_WIN32) && defined(XML_DEV_URANDOM)\n#include "random_dev_urandom.c"\n#endif\n')
+
# Update SBOM with correct checksums for updated expat files
files_to_update = {}
for target_file in updated_files:
diff --git a/relenv/build/linux.py b/relenv/build/linux.py
index 032bee0d..b4f6780e 100644
--- a/relenv/build/linux.py
+++ b/relenv/build/linux.py
@@ -463,6 +463,20 @@ def update_expat(dirs: Dirs, env: EnvMapping) -> None:
for target_file in updated_files:
os.utime(target_file, (now, now))
+ # For expat >= 2.8.0, new entropy source files are required but not compiled
+ # by Python's build system. Include them directly in xmlparse.c.
+ xmlparse_c = expat_dir / "xmlparse.c"
+ if xmlparse_c.exists():
+ with open(str(xmlparse_c), "a") as f:
+ f.write("\n/* Relenv: include new entropy sources for expat >= 2.8.0 */\n")
+ f.write('#if defined(_WIN32)\n#include "random_rand_s.c"\n#endif\n')
+ f.write('#if defined(HAVE_GETENTROPY)\n#include "random_getentropy.c"\n#endif\n')
+ f.write("#if defined(HAVE_GETRANDOM) || defined(HAVE_SYSCALL_GETRANDOM)\n")
+ f.write('#include "random_getrandom.c"\n#endif\n')
+ f.write('#if defined(HAVE_ARC4RANDOM_BUF)\n#include "random_arc4random_buf.c"\n#endif\n')
+ f.write('#if defined(HAVE_ARC4RANDOM)\n#include "random_arc4random.c"\n#endif\n')
+ f.write('#if !defined(_WIN32) && defined(XML_DEV_URANDOM)\n#include "random_dev_urandom.c"\n#endif\n')
+
# Update SBOM with correct checksums for updated expat files
files_to_update = {}
for target_file in updated_files:
diff --git a/relenv/build/windows.py b/relenv/build/windows.py
index 1c9fb210..d7bd61d4 100644
--- a/relenv/build/windows.py
+++ b/relenv/build/windows.py
@@ -348,6 +348,20 @@ def update_expat(dirs: Dirs, env: EnvMapping) -> None:
for target_file in updated_files:
os.utime(target_file, (now, now))
+ # For expat >= 2.8.0, new entropy source files are required but not compiled
+ # by Python's build system. Include them directly in xmlparse.c.
+ xmlparse_c = expat_dir / "xmlparse.c"
+ if xmlparse_c.exists():
+ with open(str(xmlparse_c), "a") as f:
+ f.write("\n/* Relenv: include new entropy sources for expat >= 2.8.0 */\n")
+ f.write('#if defined(_WIN32)\n#include "random_rand_s.c"\n#endif\n')
+ f.write('#if defined(HAVE_GETENTROPY)\n#include "random_getentropy.c"\n#endif\n')
+ f.write("#if defined(HAVE_GETRANDOM) || defined(HAVE_SYSCALL_GETRANDOM)\n")
+ f.write('#include "random_getrandom.c"\n#endif\n')
+ f.write('#if defined(HAVE_ARC4RANDOM_BUF)\n#include "random_arc4random_buf.c"\n#endif\n')
+ f.write('#if defined(HAVE_ARC4RANDOM)\n#include "random_arc4random.c"\n#endif\n')
+ f.write('#if !defined(_WIN32) && defined(XML_DEV_URANDOM)\n#include "random_dev_urandom.c"\n#endif\n')
+
# Update SBOM with correct checksums for updated expat files
files_to_update = {f"Modules/expat/{f.name}": f for f in updated_files}
if bash_refresh.exists():
diff --git a/relenv/python-versions.json b/relenv/python-versions.json
index 7ceb98b9..09559b2f 100644
--- a/relenv/python-versions.json
+++ b/relenv/python-versions.json
@@ -194,7 +194,8 @@
"3.14.3": "83eed62ba54742382542474db798717e6ee6b3f2",
"3.14.2": "b21c499c9e0250c1bfabc29a08c160018d2f6f57",
"3.14.1": "da8bd5ae7a346b80db64bac2dc2c9d9da3ca6eac",
- "3.14.0": "8a1ae36a2c4212637401af93c8a7856d126156e3"
+ "3.14.0": "8a1ae36a2c4212637401af93c8a7856d126156e3",
+ "3.14.5": "550bd85f05ba3a75d710e716100db174f13601f6"
},
"dependencies": {
"perl": {
@@ -290,6 +291,16 @@
"darwin",
"win32"
]
+ },
+ "3.53.1.0": {
+ "url": "https://sqlite.org/2026/sqlite-autoconf-{version}.tar.gz",
+ "sha256": "83e6b2020a034e9a7ad4a72feea59e1ad52f162e09cbd26735a3ffb98359fc4f",
+ "sqliteversion": "3530100",
+ "platforms": [
+ "linux",
+ "darwin",
+ "win32"
+ ]
}
},
"xz": {
@@ -499,7 +510,16 @@
"darwin",
"win32"
]
+ },
+ "2.8.1": {
+ "url": "https://github.com/libexpat/libexpat/releases/download/R_2_8_1/expat-{version}.tar.xz",
+ "sha256": "10b195ee78160a908388180a8fe3603d4e9a12f4755fbf5f3816b23a9d750da0",
+ "platforms": [
+ "linux",
+ "darwin",
+ "win32"
+ ]
}
}
}
-}
\ No newline at end of file
+}
diff --git a/relenv/pyversions.py b/relenv/pyversions.py
index 41e0d21f..51c9632b 100644
--- a/relenv/pyversions.py
+++ b/relenv/pyversions.py
@@ -995,16 +995,23 @@ def update_dependency_versions(path: pathlib.Path, deps_to_update: list[str] | N
print(f"Updated {path}")
-def create_pyversions(path: pathlib.Path) -> None:
+def detect_python_versions() -> list[Version]:
"""
- Create python-versions.json file.
+ Detect available Python versions from python.org.
"""
url = "https://www.python.org/downloads/"
content = fetch_url_content(url)
matched = re.findall(r'Python.*', content)
- cwd = os.getcwd()
parsed_versions = sorted([_ref_version(_) for _ in matched], reverse=True)
- versions = [_ for _ in parsed_versions if _.major >= 3]
+ return [_ for _ in parsed_versions if _.major >= 3]
+
+
+def create_pyversions(path: pathlib.Path) -> None:
+ """
+ Create python-versions.json file.
+ """
+ versions = detect_python_versions()
+ cwd = os.getcwd()
if path.exists():
all_data = json.loads(path.read_text())
@@ -1169,6 +1176,13 @@ def setup_parser(
action="store_true",
help="List versions",
)
+ subparser.add_argument(
+ "-c",
+ "--check",
+ default=False,
+ action="store_true",
+ help="Check for new python versions",
+ )
subparser.add_argument(
"--version",
default="3.14",
@@ -1195,6 +1209,62 @@ def main(args: argparse.Namespace) -> None:
"""
packaged = pathlib.Path(__file__).parent / "python-versions.json"
+ # Detect terminal capabilities for fancy vs ASCII output
+ use_unicode = True
+ if sys.platform == "win32":
+ # Check if we're in a modern terminal that supports Unicode
+ import os
+
+ # Windows Terminal and modern PowerShell support Unicode
+ wt_session = os.environ.get("WT_SESSION")
+ term_program = os.environ.get("TERM_PROGRAM")
+ if not wt_session and not term_program:
+ # Likely cmd.exe or old PowerShell, use ASCII
+ use_unicode = False
+
+ if use_unicode:
+ ok_symbol = "✓"
+ update_symbol = "⚠"
+ new_symbol = "✗"
+ arrow = "→"
+ else:
+ ok_symbol = "[OK] "
+ update_symbol = "[UPDATE]"
+ new_symbol = "[NEW] "
+ arrow = "->"
+
+ if args.check:
+ print("Checking for new python versions...\n")
+
+ # Load current versions from JSON
+ with open(packaged) as f:
+ data = json.load(f)
+
+ current_py = data.get("python", data)
+ py_updates = []
+ py_up_to_date = []
+
+ py_detected = detect_python_versions()
+ for version in py_detected:
+ vstr = str(version)
+ if vstr in current_py:
+ print(f"{ok_symbol} Python {vstr:12} (up-to-date)")
+ py_up_to_date.append(vstr)
+ else:
+ print(f"{new_symbol} Python {vstr:12} (new version available)")
+ py_updates.append(vstr)
+
+ # Summary
+ print(f"\n{'=' * 60}")
+ print(f"Summary: {len(py_up_to_date)} up-to-date, ", end="")
+ print(f" {len(py_updates)} new versions available")
+
+ if py_updates:
+ print("\nTo update python versions, run:")
+ print(" python3 -m relenv versions --update")
+
+ sys.exit(0)
+
# Handle dependency operations
if args.check_deps:
print("Checking for new dependency versions...\n")
@@ -1204,32 +1274,8 @@ def main(args: argparse.Namespace) -> None:
data = json.load(f)
current_deps = data.get("dependencies", {})
- updates_available = []
- up_to_date = []
-
- # Detect terminal capabilities for fancy vs ASCII output
- use_unicode = True
- if sys.platform == "win32":
- # Check if we're in a modern terminal that supports Unicode
- import os
-
- # Windows Terminal and modern PowerShell support Unicode
- wt_session = os.environ.get("WT_SESSION")
- term_program = os.environ.get("TERM_PROGRAM")
- if not wt_session and not term_program:
- # Likely cmd.exe or old PowerShell, use ASCII
- use_unicode = False
-
- if use_unicode:
- ok_symbol = "✓"
- update_symbol = "⚠"
- new_symbol = "✗"
- arrow = "→"
- else:
- ok_symbol = "[OK] "
- update_symbol = "[UPDATE]"
- new_symbol = "[NEW] "
- arrow = "->"
+ dep_updates = []
+ dep_up_to_date = []
# Check each dependency
checks = [
@@ -1253,15 +1299,15 @@ def main(args: argparse.Namespace) -> None:
]
for dep_key, dep_name, detect_func in checks:
- detected = detect_func()
- if not detected:
+ dep_detected = detect_func()
+ if not dep_detected:
continue
# Handle SQLite's tuple return
if dep_key == "sqlite":
- latest_version = detected[0][0] # type: ignore[index]
+ latest_version = dep_detected[0][0] # type: ignore[index]
else:
- latest_version = detected[0] # type: ignore[index]
+ latest_version = dep_detected[0] # type: ignore[index]
# Get current version from JSON
current_version = None
@@ -1273,20 +1319,20 @@ def main(args: argparse.Namespace) -> None:
# Compare versions
if current_version == latest_version:
print(f"{ok_symbol} {dep_name:12} {current_version:15} (up-to-date)")
- up_to_date.append(dep_name)
+ dep_up_to_date.append(dep_name)
elif current_version:
print(f"{update_symbol} {dep_name:12} {current_version:15} {arrow} {latest_version} (update available)")
- updates_available.append((dep_name, current_version, latest_version))
+ dep_updates.append((dep_name, current_version, latest_version))
else:
print(f"{new_symbol} {dep_name:12} {'(not tracked)':15} {arrow} {latest_version}")
- updates_available.append((dep_name, None, latest_version))
+ dep_updates.append((dep_name, None, latest_version))
# Summary
print(f"\n{'=' * 60}")
- print(f"Summary: {len(up_to_date)} up-to-date, ", end="")
- print(f"{len(updates_available)} updates available")
+ print(f"Summary: {len(dep_up_to_date)} up-to-date, ", end="")
+ print(f"{len(dep_updates)} updates available")
- if updates_available:
+ if dep_updates:
print("\nTo update dependencies, run:")
print(" python3 -m relenv versions --update-deps")
diff --git a/tests/test_pyversions_runtime.py b/tests/test_pyversions_runtime.py
index e6c8afe9..52e924e7 100644
--- a/tests/test_pyversions_runtime.py
+++ b/tests/test_pyversions_runtime.py
@@ -192,6 +192,29 @@ def fake_fetch(url: str) -> str:
assert versions[0] == "5.8.1"
+def test_detect_python_versions(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Test Python version detection from python.org."""
+ mock_html = """
+
+ Python 3.14.5
+ Python 3.13.2
+ Python 2.7.18
+
+ """
+
+ def fake_fetch(url: str) -> str:
+ return mock_html
+
+ monkeypatch.setattr(pyversions, "fetch_url_content", fake_fetch)
+ versions = pyversions.detect_python_versions()
+ assert isinstance(versions, list)
+ assert any(str(v) == "3.14.5" for v in versions)
+ assert any(str(v) == "3.13.2" for v in versions)
+ assert not any(str(v) == "2.7.18" for v in versions) # Should filter major < 3
+ # Verify sorting (latest first)
+ assert str(versions[0]) == "3.14.5"
+
+
def test_resolve_python_version_none_defaults_to_latest_310() -> None:
"""Test that None resolves to the latest 3.10 version."""
result = pyversions.resolve_python_version(None)