diff --git a/relenv/build/__init__.py b/relenv/build/__init__.py index 14613727..a5d91c0a 100644 --- a/relenv/build/__init__.py +++ b/relenv/build/__init__.py @@ -15,8 +15,13 @@ from . import darwin, linux, windows from .common import builds -from ..common import DEFAULT_PYTHON, build_arch -from ..pyversions import Version, python_versions +from ..common import build_arch +from ..pyversions import ( + Version, + get_default_python_version, + python_versions, + resolve_python_version, +) def platform_module() -> ModuleType: @@ -62,11 +67,12 @@ def setup_parser( "logs, src, build, and previous tarball." ), ) + default_version = get_default_python_version() build_subparser.add_argument( "--python", - default=DEFAULT_PYTHON, + default=default_version, type=str, - help="The python version [default: %(default)s]", + help="The python version (e.g., 3.10, 3.13.7) [default: %(default)s]", ) build_subparser.add_argument( "--no-cleanup", @@ -146,27 +152,17 @@ def main(args: argparse.Namespace) -> None: print(f"Unsupported platform: {sys.platform}") sys.exit(1) - requested = Version(args.python) - - if requested.micro: + try: + build_version_str = resolve_python_version(args.python) + except RuntimeError as e: + print(f"Error: {e}") pyversions = python_versions() - if requested not in pyversions: - print(f"Unknown version {requested}") - strversions = "\n".join([str(_) for _ in pyversions]) - print(f"Known versions are:\n{strversions}") - sys.exit(1) - build_version = requested - else: - pyversions = python_versions(args.python) - build_version = sorted(list(pyversions.keys()))[-1] - - # print(pyversions) - # print(pyversions[0].major) - # print(pyversions[0].minor) - # print(pyversions[0].micro) - # print(pyversions[0].pre) - # print(pyversions[0].post) - # print(pyversions) + strversions = "\n".join([str(_) for _ in pyversions]) + print(f"Known versions are:\n{strversions}") + sys.exit(1) + + build_version = Version(build_version_str) + pyversions = python_versions() print(f"Build Python {build_version}") # XXX diff --git a/relenv/common.py b/relenv/common.py index 70f88f38..f73817ae 100644 --- a/relenv/common.py +++ b/relenv/common.py @@ -40,8 +40,6 @@ MODULE_DIR = pathlib.Path(__file__).resolve().parent -DEFAULT_PYTHON = "3.10.18" - LINUX = "linux" WIN32 = "win32" DARWIN = "darwin" diff --git a/relenv/create.py b/relenv/create.py index d8b5c3e6..e9145f3c 100644 --- a/relenv/create.py +++ b/relenv/create.py @@ -23,7 +23,11 @@ format_shebang, relative_interpreter, ) -from .pyversions import Version, python_versions +from .pyversions import ( + get_default_python_version, + python_versions, + resolve_python_version, +) @contextlib.contextmanager @@ -73,11 +77,12 @@ def setup_parser( type=str, help="The host architecture [default: %(default)s]", ) + default_version = get_default_python_version() subparser.add_argument( "--python", - default="3.10.17", + default=default_version, type=str, - help="The python version [default: %(default)s]", + help="The python version (e.g., 3.10, 3.13.7) [default: %(default)s]", ) @@ -106,8 +111,9 @@ def create( else: writeto = pathlib.Path(name).resolve() + # Version should be provided by main(), but handle None just in case if version is None: - version = "3.10.17" + version = get_default_python_version() if pathlib.Path(writeto).exists(): raise CreateException("The requested path already exists.") @@ -253,31 +259,17 @@ def main(args: argparse.Namespace) -> None: "Warning: Cross compilation support is experimental and is not fully tested or working!" ) - # Resolve version (support minor version like "3.12" or full version like "3.12.7") - requested = Version(args.python) - - if requested.micro: - # Full version specified (e.g., "3.12.7") + try: + create_version = resolve_python_version(args.python) + except RuntimeError as e: + print(f"Error: {e}") pyversions = python_versions() - if requested not in pyversions: - print(f"Unknown version {requested}") - strversions = "\n".join([str(_) for _ in pyversions]) - print(f"Known versions are:\n{strversions}") - sys.exit(1) - create_version = requested - else: - # Minor version specified (e.g., "3.12"), resolve to latest - pyversions = python_versions(args.python) - if not pyversions: - print(f"Unknown minor version {requested}") - all_versions = python_versions() - strversions = "\n".join([str(_) for _ in all_versions]) - print(f"Known versions are:\n{strversions}") - sys.exit(1) - create_version = sorted(list(pyversions.keys()))[-1] + strversions = "\n".join([str(_) for _ in pyversions]) + print(f"Known versions are:\n{strversions}") + sys.exit(1) try: - create(name, arch=args.arch, version=str(create_version)) + create(name, arch=args.arch, version=create_version) except CreateException as exc: print(exc) sys.exit(1) diff --git a/relenv/fetch.py b/relenv/fetch.py index 59ae25a0..b814c48e 100644 --- a/relenv/fetch.py +++ b/relenv/fetch.py @@ -16,7 +16,6 @@ from .common import ( CHECK_HOSTS, DATA_DIR, - DEFAULT_PYTHON, __version__, build_arch, check_url, @@ -24,6 +23,7 @@ get_triplet, work_dir, ) +from .pyversions import get_default_python_version, resolve_python_version def setup_parser( @@ -45,11 +45,12 @@ def setup_parser( type=str, help="Architecture to download. [default: %(default)s]", ) + default_version = get_default_python_version() subparser.add_argument( "--python", - default=DEFAULT_PYTHON, + default=default_version, type=str, - help="The python version [default: %(default)s]", + help="The python version (e.g., 3.10, 3.13.7) [default: %(default)s]", ) @@ -87,7 +88,8 @@ def main(args: argparse.Namespace) -> None: """ version = os.environ.get("RELENV_FETCH_VERSION", __version__) triplet = get_triplet(machine=args.arch) - python = args.python + # args.python will be the default version or user-specified version + python = resolve_python_version(args.python) check_hosts = CHECK_HOSTS if os.environ.get("RELENV_FETCH_HOST", ""): check_hosts = [os.environ["RELENV_FETCH_HOST"]] diff --git a/relenv/pyversions.py b/relenv/pyversions.py index e191afcc..f501631c 100644 --- a/relenv/pyversions.py +++ b/relenv/pyversions.py @@ -947,6 +947,54 @@ def python_versions( return {version: pyversions[str(version)] for version in versions} +def get_default_python_version() -> str: + """ + Get the default Python version to use when none is specified. + + :return: The default Python version string (e.g., "3.10.19") + """ + # Default to latest 3.10 version + pyversions = python_versions("3.10") + if not pyversions: + raise RuntimeError("No 3.10 versions found") + latest = sorted(list(pyversions.keys()))[-1] + return str(latest) + + +def resolve_python_version(version_spec: str | None = None) -> str: + """ + Resolve a Python version specification to a full version string. + + If version_spec is None, returns the latest Python 3.10 version. + If version_spec is partial (e.g., "3.10"), returns the latest micro version. + If version_spec is full (e.g., "3.10.19"), returns it as-is after validation. + + :param version_spec: Version specification (None, "3.10", or "3.10.19") + :return: Full version string (e.g., "3.10.19") + :raises RuntimeError: If the version is not found + """ + if version_spec is None: + # Default to latest 3.10 version + return get_default_python_version() + + requested = Version(version_spec) + + if requested.micro is not None: + # Full version specified - validate it exists + pyversions = python_versions() + if requested not in pyversions: + raise RuntimeError(f"Unknown version {requested}") + return str(requested) + else: + # Partial version (major.minor) - get latest micro + pyversions = python_versions(version_spec) + if not pyversions: + raise RuntimeError(f"Unknown minor version {requested}") + # Return the latest version for this major.minor + latest = sorted(list(pyversions.keys()))[-1] + return str(latest) + + def setup_parser( subparsers: argparse._SubParsersAction[argparse.ArgumentParser], ) -> None: diff --git a/tests/test_pyversions_runtime.py b/tests/test_pyversions_runtime.py index 1158ce28..0daf8907 100644 --- a/tests/test_pyversions_runtime.py +++ b/tests/test_pyversions_runtime.py @@ -175,3 +175,80 @@ def fake_fetch(url: str) -> str: assert "5.6.3" in versions # Verify sorting (latest first) assert versions[0] == "5.8.1" + + +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) + assert result.startswith("3.10.") + # Verify it's a valid version in the registry + versions = pyversions.python_versions("3.10") + assert pyversions.Version(result) in versions + # Verify it's the latest 3.10 version + latest = sorted(list(versions.keys()))[-1] + assert result == str(latest) + + +def test_resolve_python_version_partial_minor() -> None: + """Test that partial versions (3.10) resolve to latest micro.""" + result = pyversions.resolve_python_version("3.10") + assert result.startswith("3.10.") + # Verify it resolves to the latest micro version + versions = pyversions.python_versions("3.10") + latest = sorted(list(versions.keys()))[-1] + assert result == str(latest) + + +def test_resolve_python_version_different_minors() -> None: + """Test resolution works for different minor versions.""" + result_311 = pyversions.resolve_python_version("3.11") + assert result_311.startswith("3.11.") + + result_313 = pyversions.resolve_python_version("3.13") + assert result_313.startswith("3.13.") + + # Verify they're different + assert result_311 != result_313 + + # Verify each is the latest for its minor version + versions_311 = pyversions.python_versions("3.11") + latest_311 = sorted(list(versions_311.keys()))[-1] + assert result_311 == str(latest_311) + + versions_313 = pyversions.python_versions("3.13") + latest_313 = sorted(list(versions_313.keys()))[-1] + assert result_313 == str(latest_313) + + +def test_resolve_python_version_full_version() -> None: + """Test that full versions are validated and returned as-is.""" + # Get any valid version from the registry + all_versions = pyversions.python_versions() + some_version = str(next(iter(all_versions))) + + result = pyversions.resolve_python_version(some_version) + assert result == some_version + + +def test_resolve_python_version_invalid_full_version() -> None: + """Test that invalid full versions raise RuntimeError.""" + with pytest.raises(RuntimeError, match="Unknown version"): + pyversions.resolve_python_version("3.10.999") + + +def test_resolve_python_version_invalid_minor_version() -> None: + """Test that invalid minor versions raise RuntimeError.""" + with pytest.raises(RuntimeError, match="Unknown minor version"): + pyversions.resolve_python_version("3.99") + + +def test_resolve_python_version_consistency() -> None: + """Test that resolve_python_version is idempotent for full versions.""" + # Get a valid full version from the registry + all_versions = pyversions.python_versions() + some_version = str(next(iter(all_versions))) + + # Resolving a full version twice should give the same result + first = pyversions.resolve_python_version(some_version) + second = pyversions.resolve_python_version(first) + assert first == second