diff --git a/.github/workflows/test-system.yml b/.github/workflows/test-system.yml index 12b2127ab..a9bb152ac 100644 --- a/.github/workflows/test-system.yml +++ b/.github/workflows/test-system.yml @@ -95,6 +95,54 @@ jobs: - name: "Validate global Python install" run: python scripts/check_system_python.py --uv ./uv + system-test-python-36: + timeout-minutes: 10 + name: "python3.6 on debian buster" + runs-on: ubuntu-latest + container: python:3.6-buster + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: "Download binary" + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: uv-linux-musl-${{ inputs.sha }} + + - name: "Prepare binary" + run: chmod +x ./uv + + - name: "Print Python path" + run: echo $(which python3) + + - name: "Validate global Python install" + run: python3 scripts/check_system_python.py --uv ./uv + + system-test-python-37: + timeout-minutes: 10 + name: "python3.7 on debian buster" + runs-on: ubuntu-latest + container: python:3.7-buster + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: "Download binary" + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: uv-linux-musl-${{ inputs.sha }} + + - name: "Prepare binary" + run: chmod +x ./uv + + - name: "Print Python path" + run: echo $(which python3) + + - name: "Validate global Python install" + run: python3 scripts/check_system_python.py --uv ./uv + # Currently failing, see https://github.com/astral-sh/uv/issues/13811 # system-test-opensuse: # timeout-minutes: 5 diff --git a/crates/uv-python/python/get_interpreter_info.py b/crates/uv-python/python/get_interpreter_info.py index 09e94719a..30735ae3e 100644 --- a/crates/uv-python/python/get_interpreter_info.py +++ b/crates/uv-python/python/get_interpreter_info.py @@ -444,7 +444,7 @@ def get_operating_system_and_architecture(): version = None architecture = version_arch - if sys.version_info < (3, 7): + if sys.version_info < (3, 6): print( json.dumps( { diff --git a/crates/uv-python/python/packaging/README.md b/crates/uv-python/python/packaging/README.md index e136276ca..4684af919 100644 --- a/crates/uv-python/python/packaging/README.md +++ b/crates/uv-python/python/packaging/README.md @@ -4,3 +4,9 @@ This directory contains vendored [pypa/packaging](https://github.com/pypa/packag [cc938f984bbbe43c5734b9656c9837ab3a28191f](https://github.com/pypa/packaging/tree/cc938f984bbbe43c5734b9656c9837ab3a28191f/src/packaging). The files are licensed under BSD-2-Clause OR Apache-2.0. + +## Patches + +The following patches have been applied: + +- [python36-support.patch](./python36-support.patch) diff --git a/crates/uv-python/python/packaging/_elffile.py b/crates/uv-python/python/packaging/_elffile.py index 8dc7fb32a..f1907a595 100644 --- a/crates/uv-python/python/packaging/_elffile.py +++ b/crates/uv-python/python/packaging/_elffile.py @@ -8,8 +8,6 @@ Based on: https://gist.github.com/lyssdod/f51579ae8d93c8657a5564aefc2ffbca ELF header: https://refspecs.linuxfoundation.org/elf/gabi4+/ch4.eheader.html """ -from __future__ import annotations - import enum import os import struct @@ -88,11 +86,11 @@ class ELFFile: except struct.error as e: raise ELFInvalid("unable to parse machine and section information") from e - def _read(self, fmt: str) -> tuple[int, ...]: + def _read(self, fmt: str) -> "tuple[int, ...]": return struct.unpack(fmt, self._f.read(struct.calcsize(fmt))) @property - def interpreter(self) -> str | None: + def interpreter(self) -> "str | None": """ The path recorded in the ``PT_INTERP`` section header. """ diff --git a/crates/uv-python/python/packaging/_manylinux.py b/crates/uv-python/python/packaging/_manylinux.py index 7b52a5581..d3c871aab 100644 --- a/crates/uv-python/python/packaging/_manylinux.py +++ b/crates/uv-python/python/packaging/_manylinux.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import collections import contextlib import functools @@ -19,7 +17,7 @@ EF_ARM_ABI_FLOAT_HARD = 0x00000400 # `os.PathLike` not a generic type until Python 3.9, so sticking with `str` # as the type for `path` until then. @contextlib.contextmanager -def _parse_elf(path: str) -> Generator[ELFFile | None, None, None]: +def _parse_elf(path: str) -> "Generator[ELFFile | None, None, None]": try: with open(path, "rb") as f: yield ELFFile(f) @@ -74,7 +72,7 @@ def _have_compatible_abi(executable: str, archs: Sequence[str]) -> bool: # For now, guess what the highest minor version might be, assume it will # be 50 for testing. Once this actually happens, update the dictionary # with the actual value. -_LAST_GLIBC_MINOR: dict[int, int] = collections.defaultdict(lambda: 50) +_LAST_GLIBC_MINOR: "dict[int, int]" = collections.defaultdict(lambda: 50) class _GLibCVersion(NamedTuple): @@ -82,7 +80,7 @@ class _GLibCVersion(NamedTuple): minor: int -def _glibc_version_string_confstr() -> str | None: +def _glibc_version_string_confstr() -> "str | None": """ Primary implementation of glibc_version_string using os.confstr. """ @@ -92,7 +90,7 @@ def _glibc_version_string_confstr() -> str | None: # https://github.com/python/cpython/blob/fcf1d003bf4f0100c/Lib/platform.py#L175-L183 try: # Should be a string like "glibc 2.17". - version_string: str | None = os.confstr("CS_GNU_LIBC_VERSION") + version_string: "str | None" = os.confstr("CS_GNU_LIBC_VERSION") assert version_string is not None _, version = version_string.rsplit() except (AssertionError, AttributeError, OSError, ValueError): @@ -101,7 +99,7 @@ def _glibc_version_string_confstr() -> str | None: return version -def _glibc_version_string_ctypes() -> str | None: +def _glibc_version_string_ctypes() -> "str | None": """ Fallback implementation of glibc_version_string using ctypes. """ @@ -145,7 +143,7 @@ def _glibc_version_string_ctypes() -> str | None: return version_str -def _glibc_version_string() -> str | None: +def _glibc_version_string() -> "str | None": """Returns glibc version string, or None if not using glibc.""" return _glibc_version_string_confstr() or _glibc_version_string_ctypes() @@ -203,7 +201,7 @@ def _is_compatible(arch: str, version: _GLibCVersion) -> bool: return True -_LEGACY_MANYLINUX_MAP: dict[_GLibCVersion, str] = { +_LEGACY_MANYLINUX_MAP: "dict[_GLibCVersion, str]" = { # CentOS 7 w/ glibc 2.17 (PEP 599) _GLibCVersion(2, 17): "manylinux2014", # CentOS 6 w/ glibc 2.12 (PEP 571) diff --git a/crates/uv-python/python/packaging/_musllinux.py b/crates/uv-python/python/packaging/_musllinux.py index b4ca23804..40a72f05b 100644 --- a/crates/uv-python/python/packaging/_musllinux.py +++ b/crates/uv-python/python/packaging/_musllinux.py @@ -4,8 +4,6 @@ This module implements logic to detect if the currently running Python is linked against musl, and what musl version is used. """ -from __future__ import annotations - import functools import re import subprocess @@ -20,7 +18,7 @@ class _MuslVersion(NamedTuple): minor: int -def _parse_musl_version(output: str) -> _MuslVersion | None: +def _parse_musl_version(output: str) -> "_MuslVersion | None": lines = [n for n in (n.strip() for n in output.splitlines()) if n] if len(lines) < 2 or lines[0][:4] != "musl": return None @@ -31,7 +29,7 @@ def _parse_musl_version(output: str) -> _MuslVersion | None: @functools.lru_cache() -def _get_musl_version(executable: str) -> _MuslVersion | None: +def _get_musl_version(executable: str) -> "_MuslVersion | None": """Detect currently-running musl runtime version. This is done by checking the specified executable's dynamic linking diff --git a/crates/uv-python/python/packaging/python36-support.patch b/crates/uv-python/python/packaging/python36-support.patch new file mode 100644 index 000000000..8196287c5 --- /dev/null +++ b/crates/uv-python/python/packaging/python36-support.patch @@ -0,0 +1,139 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +Subject: [PATCH] python36-support + +Remove `from __future__ import annotations` and quote PEP 604 (`X | Y`) union +annotations and PEP 585 (`dict[K, V]`) lowercase generics so these vendored +modules remain compatible with Python 3.6. + +--- +diff --git a/crates/uv-python/python/packaging/_elffile.py b/crates/uv-python/python/packaging/_elffile.py +index 8dc7fb32a..f1907a595 100644 +--- a/crates/uv-python/python/packaging/_elffile.py ++++ b/crates/uv-python/python/packaging/_elffile.py +@@ -8,8 +8,6 @@ Based on: https://gist.github.com/lyssdod/f51579ae8d93c8657a5564aefc2ffbca + ELF header: https://refspecs.linuxfoundation.org/elf/gabi4+/ch4.eheader.html + """ + +-from __future__ import annotations +- + import enum + import os + import struct +@@ -88,11 +86,11 @@ class ELFFile: + except struct.error as e: + raise ELFInvalid("unable to parse machine and section information") from e + +- def _read(self, fmt: str) -> tuple[int, ...]: ++ def _read(self, fmt: str) -> "tuple[int, ...]": + return struct.unpack(fmt, self._f.read(struct.calcsize(fmt))) + + @property +- def interpreter(self) -> str | None: ++ def interpreter(self) -> "str | None": + """ + The path recorded in the ``PT_INTERP`` section header. + """ +diff --git a/crates/uv-python/python/packaging/_manylinux.py b/crates/uv-python/python/packaging/_manylinux.py +index 7b52a5581..d3c871aab 100644 +--- a/crates/uv-python/python/packaging/_manylinux.py ++++ b/crates/uv-python/python/packaging/_manylinux.py +@@ -1,5 +1,3 @@ +-from __future__ import annotations +- + import collections + import contextlib + import functools +@@ -19,7 +17,7 @@ EF_ARM_ABI_FLOAT_HARD = 0x00000400 + # `os.PathLike` not a generic type until Python 3.9, so sticking with `str` + # as the type for `path` until then. + @contextlib.contextmanager +-def _parse_elf(path: str) -> Generator[ELFFile | None, None, None]: ++def _parse_elf(path: str) -> "Generator[ELFFile | None, None, None]": + try: + with open(path, "rb") as f: + yield ELFFile(f) +@@ -74,7 +72,7 @@ def _have_compatible_abi(executable: str, archs: Sequence[str]) -> bool: + # For now, guess what the highest minor version might be, assume it will + # be 50 for testing. Once this actually happens, update the dictionary + # with the actual value. +-_LAST_GLIBC_MINOR: dict[int, int] = collections.defaultdict(lambda: 50) ++_LAST_GLIBC_MINOR: "dict[int, int]" = collections.defaultdict(lambda: 50) + + + class _GLibCVersion(NamedTuple): +@@ -82,7 +80,7 @@ class _GLibCVersion(NamedTuple): + minor: int + + +-def _glibc_version_string_confstr() -> str | None: ++def _glibc_version_string_confstr() -> "str | None": + """ + Primary implementation of glibc_version_string using os.confstr. + """ +@@ -92,7 +90,7 @@ def _glibc_version_string_confstr() -> str | None: + # https://github.com/python/cpython/blob/fcf1d003bf4f0100c/Lib/platform.py#L175-L183 + try: + # Should be a string like "glibc 2.17". +- version_string: str | None = os.confstr("CS_GNU_LIBC_VERSION") ++ version_string: "str | None" = os.confstr("CS_GNU_LIBC_VERSION") + assert version_string is not None + _, version = version_string.rsplit() + except (AssertionError, AttributeError, OSError, ValueError): +@@ -101,7 +99,7 @@ def _glibc_version_string_confstr() -> str | None: + return version + + +-def _glibc_version_string_ctypes() -> str | None: ++def _glibc_version_string_ctypes() -> "str | None": + """ + Fallback implementation of glibc_version_string using ctypes. + """ +@@ -145,7 +143,7 @@ def _glibc_version_string_ctypes() -> str | None: + return version_str + + +-def _glibc_version_string() -> str | None: ++def _glibc_version_string() -> "str | None": + """Returns glibc version string, or None if not using glibc.""" + return _glibc_version_string_confstr() or _glibc_version_string_ctypes() + +@@ -203,7 +201,7 @@ def _is_compatible(arch: str, version: _GLibCVersion) -> bool: + return True + + +-_LEGACY_MANYLINUX_MAP: dict[_GLibCVersion, str] = { ++_LEGACY_MANYLINUX_MAP: "dict[_GLibCVersion, str]" = { + # CentOS 7 w/ glibc 2.17 (PEP 599) + _GLibCVersion(2, 17): "manylinux2014", + # CentOS 6 w/ glibc 2.12 (PEP 571) +diff --git a/crates/uv-python/python/packaging/_musllinux.py b/crates/uv-python/python/packaging/_musllinux.py +index b4ca23804..40a72f05b 100644 +--- a/crates/uv-python/python/packaging/_musllinux.py ++++ b/crates/uv-python/python/packaging/_musllinux.py +@@ -4,8 +4,6 @@ This module implements logic to detect if the currently running Python is + linked against musl, and what musl version is used. + """ + +-from __future__ import annotations +- + import functools + import re + import subprocess +@@ -20,7 +18,7 @@ class _MuslVersion(NamedTuple): + minor: int + + +-def _parse_musl_version(output: str) -> _MuslVersion | None: ++def _parse_musl_version(output: str) -> "_MuslVersion | None": + lines = [n for n in (n.strip() for n in output.splitlines()) if n] + if len(lines) < 2 or lines[0][:4] != "musl": + return None +@@ -31,7 +29,7 @@ def _parse_musl_version(output: str) -> _MuslVersion | None: + + + @functools.lru_cache() +-def _get_musl_version(executable: str) -> _MuslVersion | None: ++def _get_musl_version(executable: str) -> "_MuslVersion | None": + """Detect currently-running musl runtime version. + + This is done by checking the specified executable's dynamic linking diff --git a/crates/uv-python/src/discovery.rs b/crates/uv-python/src/discovery.rs index 79d6b7429..01ee8ebd9 100644 --- a/crates/uv-python/src/discovery.rs +++ b/crates/uv-python/src/discovery.rs @@ -709,7 +709,7 @@ fn find_all_minor( let minor = captures["minor"].parse().ok(); if let Some(minor) = minor { // Optimization: Skip generally unsupported Python versions without querying. - if minor < 7 { + if minor < 6 { return false; } // Optimization 2: Skip excluded Python (minor) versions without querying. @@ -2793,23 +2793,23 @@ impl VersionRequest { } } Self::MajorMinor(major, minor, _) => { - if (*major, *minor) < (3, 7) { + if (*major, *minor) < (3, 6) { return Err(format!( - "Python <3.7 is not supported but {major}.{minor} was requested." + "Python <3.6 is not supported but {major}.{minor} was requested." )); } } Self::MajorMinorPatch(major, minor, patch, _) => { - if (*major, *minor) < (3, 7) { + if (*major, *minor) < (3, 6) { return Err(format!( - "Python <3.7 is not supported but {major}.{minor}.{patch} was requested." + "Python <3.6 is not supported but {major}.{minor}.{patch} was requested." )); } } Self::MajorMinorPrerelease(major, minor, prerelease, _) => { - if (*major, *minor) < (3, 7) { + if (*major, *minor) < (3, 6) { return Err(format!( - "Python <3.7 is not supported but {major}.{minor}{prerelease} was requested." + "Python <3.6 is not supported but {major}.{minor}{prerelease} was requested." )); } } diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index 301f81b50..94d79dd9d 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -922,9 +922,9 @@ pub enum InterpreterInfoError { BrokenMacVer, #[error("Unknown operating system: `{operating_system}`")] UnknownOperatingSystem { operating_system: String }, - #[error("Python {python_version} is not supported. Please use Python 3.8 or newer.")] + #[error("Python {python_version} is not supported. Please use Python 3.6 or newer.")] UnsupportedPythonVersion { python_version: String }, - #[error("Python executable does not support `-I` flag. Please use Python 3.8 or newer.")] + #[error("Python executable does not support `-I` flag. Please use Python 3.6 or newer.")] UnsupportedPython, #[error( "Python installation is missing `distutils`, which is required for packaging on older Python versions. Your system may package it separately, e.g., as `python{python_major}-distutils` or `python{python_major}.{python_minor}-distutils`." diff --git a/crates/uv/tests/it/python_find.rs b/crates/uv/tests/it/python_find.rs index 97e9da7a2..61dd198b9 100644 --- a/crates/uv/tests/it/python_find.rs +++ b/crates/uv/tests/it/python_find.rs @@ -779,23 +779,23 @@ fn python_find_unsupported_version() { let context = uv_test::test_context_with_versions!(&["3.12"]); // Request a low version - uv_snapshot!(context.filters(), context.python_find().arg("3.6"), @" + uv_snapshot!(context.filters(), context.python_find().arg("3.5"), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - error: Invalid version request: Python <3.7 is not supported but 3.6 was requested. + error: Invalid version request: Python <3.6 is not supported but 3.5 was requested. "); // Request a low version with a patch - uv_snapshot!(context.filters(), context.python_find().arg("3.6.9"), @" + uv_snapshot!(context.filters(), context.python_find().arg("3.5.9"), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - error: Invalid version request: Python <3.7 is not supported but 3.6.9 was requested. + error: Invalid version request: Python <3.6 is not supported but 3.5.9 was requested. "); // Request a really low version @@ -805,7 +805,7 @@ fn python_find_unsupported_version() { ----- stdout ----- ----- stderr ----- - error: Invalid version request: Python <3.7 is not supported but 2.6 was requested. + error: Invalid version request: Python <3.6 is not supported but 2.6 was requested. "); // Request a really low version with a patch @@ -815,7 +815,7 @@ fn python_find_unsupported_version() { ----- stdout ----- ----- stderr ----- - error: Invalid version request: Python <3.7 is not supported but 2.6.8 was requested. + error: Invalid version request: Python <3.6 is not supported but 2.6.8 was requested. "); // Request a future version diff --git a/crates/uv/tests/it/python_list.rs b/crates/uv/tests/it/python_list.rs index 328885852..4a535d65f 100644 --- a/crates/uv/tests/it/python_list.rs +++ b/crates/uv/tests/it/python_list.rs @@ -221,23 +221,23 @@ fn python_list_unsupported_version() { .with_filtered_python_keys(); // Request a low version - uv_snapshot!(context.filters(), context.python_list().arg("3.6"), @" + uv_snapshot!(context.filters(), context.python_list().arg("3.5"), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - error: Invalid version request: Python <3.7 is not supported but 3.6 was requested. + error: Invalid version request: Python <3.6 is not supported but 3.5 was requested. "); // Request a low version with a patch - uv_snapshot!(context.filters(), context.python_list().arg("3.6.9"), @" + uv_snapshot!(context.filters(), context.python_list().arg("3.5.9"), @" success: false exit_code: 2 ----- stdout ----- ----- stderr ----- - error: Invalid version request: Python <3.7 is not supported but 3.6.9 was requested. + error: Invalid version request: Python <3.6 is not supported but 3.5.9 was requested. "); // Request a really low version @@ -247,7 +247,7 @@ fn python_list_unsupported_version() { ----- stdout ----- ----- stderr ----- - error: Invalid version request: Python <3.7 is not supported but 2.6 was requested. + error: Invalid version request: Python <3.6 is not supported but 2.6 was requested. "); // Request a really low version with a patch @@ -257,7 +257,7 @@ fn python_list_unsupported_version() { ----- stdout ----- ----- stderr ----- - error: Invalid version request: Python <3.7 is not supported but 2.6.8 was requested. + error: Invalid version request: Python <3.6 is not supported but 2.6.8 was requested. "); // Request a future version diff --git a/scripts/check_system_python.py b/scripts/check_system_python.py index fb5df4f5e..3518d1578 100755 --- a/scripts/check_system_python.py +++ b/scripts/check_system_python.py @@ -14,12 +14,14 @@ import sys import tempfile -def install_package(*, uv: str, package: str): +def install_package(*, uv: str, package: str, version: str = None): """Install a package into the system Python.""" - logging.info(f"Installing the package `{package}`.") + requirement = f"{package}=={version}" if version is not None else package + + logging.info(f"Installing the package `{requirement}`.") subprocess.run( - [uv, "pip", "install", package, "--system"] + allow_externally_managed, + [uv, "pip", "install", requirement, "--system"] + allow_externally_managed, cwd=temp_dir, check=True, ) @@ -71,6 +73,24 @@ if __name__ == "__main__": ) python = ["--python", args.python] if args.python else [] + # Pin packages to the last versions that support older Python interpreters. + if sys.version_info < (3, 7): + pylint_version = "2.12.2" + numpy_version = "1.19.5" + pydantic_core_version = None + elif sys.version_info < (3, 8): + pylint_version = "2.17.7" + numpy_version = "1.21.6" + pydantic_core_version = "2.14.6" + else: + pylint_version = None + numpy_version = None + pydantic_core_version = None + + pylint_requirement = ( + f"pylint=={pylint_version}" if pylint_version is not None else "pylint" + ) + if args.check_python_version: version = ".".join(map(str, sys.version_info[:3])) if args.check_python_version != version and not version.startswith( @@ -114,7 +134,7 @@ if __name__ == "__main__": # Install the package (`pylint`). logging.info("Installing the package `pylint`.") subprocess.run( - [uv, "pip", "install", "pylint", "--system", "--verbose"] + [uv, "pip", "install", pylint_requirement, "--system", "--verbose"] + allow_externally_managed + python, cwd=temp_dir, @@ -188,7 +208,7 @@ if __name__ == "__main__": env["CONDA_PREFIX"] = "" env["VIRTUAL_ENV"] = "" subprocess.run( - [uv, "pip", "install", "pylint", "--verbose"], + [uv, "pip", "install", pylint_requirement, "--verbose"], cwd=temp_dir, check=True, env=env, @@ -241,7 +261,7 @@ if __name__ == "__main__": # # NumPy doesn't distribute wheels for Python 3.13 or GraalPy (at time of writing). if sys.version_info < (3, 13) and sys.implementation.name != "graalpy": - install_package(uv=uv, package="numpy") + install_package(uv=uv, package="numpy", version=numpy_version) # Attempt to install `pydantic_core`. # This ensures that we can successfully install and recognize a package that may @@ -249,8 +269,14 @@ if __name__ == "__main__": # # `pydantic_core` doesn't distribute wheels for non-CPython interpreters, nor # for Python 3.13 (at time of writing). - if sys.version_info < (3, 13) and sys.implementation.name == "cpython": - install_package(uv=uv, package="pydantic_core") + if ( + sys.version_info >= (3, 7) + and sys.version_info < (3, 13) + and sys.implementation.name == "cpython" + ): + install_package( + uv=uv, package="pydantic_core", version=pydantic_core_version + ) # Next, create a virtual environment with `venv`, to ensure that `uv` can # interoperate with `venv` virtual environments. @@ -265,7 +291,7 @@ if __name__ == "__main__": # Install the package (`pylint`) into the virtual environment. logging.info("Installing into `venv` virtual environment...") subprocess.run( - [uv, "pip", "install", "pylint", "--verbose"], + [uv, "pip", "install", pylint_requirement, "--verbose"], cwd=temp_dir, check=True, env=env, diff --git a/scripts/vendor-packaging.py b/scripts/vendor-packaging.py new file mode 100644 index 000000000..e51fe3c52 --- /dev/null +++ b/scripts/vendor-packaging.py @@ -0,0 +1,168 @@ +"""Vendor select modules from `pypa/packaging`. + +This script clones `pypa/packaging`, checks out a specific commit, copies the +vendored files into `crates/uv-python/python/packaging`, applies all +`*.patch` files in that directory, and regenerates the README. + +Example: + uv run ./scripts/vendor-packaging.py cc938f984bbbe43c5734b9656c9837ab3a28191f +""" + +# /// script +# requires-python = ">=3.12" +# dependencies = [] +# /// + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import tempfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +PACKAGING_DIR = REPO_ROOT / "crates" / "uv-python" / "python" / "packaging" +README_PATH = PACKAGING_DIR / "README.md" +UPSTREAM_REPOSITORY = "https://github.com/pypa/packaging.git" +UPSTREAM_SOURCE_DIR = "src" + +VENDORED_FILES = ( + "__init__.py", + "_elffile.py", + "_manylinux.py", + "_musllinux.py", +) + +LICENSE_FILES = ( + "LICENSE.APACHE", + "LICENSE.BSD", +) + + +def run(command: list[str], *, cwd: Path) -> None: + subprocess.run(command, cwd=cwd, check=True) + + +def capture(command: list[str], *, cwd: Path) -> str: + result = subprocess.run( + command, cwd=cwd, check=True, capture_output=True, text=True + ) + return result.stdout.strip() + + +def copy_vendored_files(*, upstream_root: Path, destination_root: Path) -> None: + upstream_packaging_dir = upstream_root / UPSTREAM_SOURCE_DIR / "packaging" + + for file_name in VENDORED_FILES: + source = upstream_packaging_dir / file_name + destination = destination_root / file_name + shutil.copy2(source, destination) + + for file_name in LICENSE_FILES: + source = upstream_root / file_name + destination = destination_root / file_name + shutil.copy2(source, destination) + + +def install_staged_files(*, staging_root: Path) -> None: + for file_name in VENDORED_FILES: + shutil.copy2(staging_root / file_name, PACKAGING_DIR / file_name) + + for file_name in LICENSE_FILES: + shutil.copy2(staging_root / file_name, PACKAGING_DIR / file_name) + + +def collect_patch_files() -> list[Path]: + return sorted(PACKAGING_DIR.glob("*.patch")) + + +def write_readme(*, commit: str, patch_files: list[Path]) -> None: + patch_lines = "\n".join( + f"- [{patch_path.name}](./{patch_path.name})" for patch_path in patch_files + ) + content = f"""# `pypa/packaging` + +This directory contains vendored [pypa/packaging](https://github.com/pypa/packaging) modules as of +[{commit}](https://github.com/pypa/packaging/tree/{commit}/src/packaging). + +The files are licensed under BSD-2-Clause OR Apache-2.0. + +## Patches + +The following patches have been applied: + +{patch_lines} +""" + README_PATH.write_text(content) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("commit", help="The pypa/packaging commit to vendor") + parser.add_argument( + "--repository", + default=UPSTREAM_REPOSITORY, + help=f"Git repository URL (default: {UPSTREAM_REPOSITORY})", + ) + args = parser.parse_args() + + patch_files = collect_patch_files() + if not patch_files: + raise FileNotFoundError(f"No patch files found in: {PACKAGING_DIR}") + + with tempfile.TemporaryDirectory(prefix="uv-vendor-packaging-") as temp_dir: + temp_path = Path(temp_dir) + upstream_root = temp_path / "packaging" + + print(f"Cloning {args.repository}") + run( + ["git", "clone", "--quiet", args.repository, str(upstream_root)], + cwd=temp_path, + ) + + print(f"Checking out {args.commit}") + run( + [ + "git", + "-c", + "advice.detachedHead=false", + "checkout", + "--quiet", + args.commit, + ], + cwd=upstream_root, + ) + + resolved_commit = capture(["git", "rev-parse", "HEAD"], cwd=upstream_root) + + staging_dir = temp_path / "staging" + staging_dir.mkdir() + + print("Copying vendored files into a staging directory") + copy_vendored_files(upstream_root=upstream_root, destination_root=staging_dir) + + for patch_path in patch_files: + print(f"Applying patch: {patch_path}") + try: + run( + ["git", "apply", "-p5", "--directory=staging", str(patch_path)], + cwd=temp_path, + ) + except subprocess.CalledProcessError as error: + raise RuntimeError( + f"Failed to apply {patch_path.name}. " + f"The patch likely needs to be updated for commit {resolved_commit}." + ) from error + + print(f"Installing vendored files into {PACKAGING_DIR}") + install_staged_files(staging_root=staging_dir) + + print(f"Regenerating {README_PATH}") + write_readme(commit=resolved_commit, patch_files=patch_files) + + print("Done.") + + +if __name__ == "__main__": + main()