Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions relenv/build/darwin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions relenv/build/linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions relenv/build/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
24 changes: 22 additions & 2 deletions relenv/python-versions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
]
}
}
}
}
}
126 changes: 86 additions & 40 deletions relenv/pyversions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'<a href="/downloads/.*">Python.*</a>', 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())
Expand Down Expand Up @@ -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",
Expand All @@ -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")
Expand All @@ -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 = [
Expand All @@ -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
Expand All @@ -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")

Expand Down
23 changes: 23 additions & 0 deletions tests/test_pyversions_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
<html>
<a href="/downloads/release/python-3145/">Python 3.14.5</a>
<a href="/downloads/release/python-3132/">Python 3.13.2</a>
<a href="/downloads/release/python-2718/">Python 2.7.18</a>
</html>
"""

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)
Expand Down
Loading