|
| 1 | +""" |
| 2 | +Download Python development headers for a given version into a temporary directory. |
| 3 | +
|
| 4 | +Works without root on Ubuntu, RHEL, Fedora, and OpenSUSE by downloading (not installing) |
| 5 | +the appropriate package and extracting it locally. |
| 6 | +""" |
| 7 | + |
| 8 | +from __future__ import annotations |
| 9 | + |
| 10 | +import subprocess |
| 11 | +from pathlib import Path |
| 12 | + |
| 13 | +import distro |
| 14 | + |
| 15 | + |
| 16 | +def _run_command(command: list[str], **kwargs: object) -> subprocess.CompletedProcess: |
| 17 | + """ |
| 18 | + Run a command with subprocess.run and check for errors. |
| 19 | +
|
| 20 | + command: The command to run, as a list of arguments. |
| 21 | + kwargs: Additional keyword arguments to pass to subprocess.run. |
| 22 | +
|
| 23 | + Returns the CompletedProcess object returned by subprocess.run. |
| 24 | + """ |
| 25 | + return subprocess.run(command, check=True, capture_output=True, text=True, **kwargs) |
| 26 | + |
| 27 | + |
| 28 | +def _get_download_package_urls(version: str) -> str: |
| 29 | + """ |
| 30 | + Return the URL of a distribution package file. |
| 31 | + """ |
| 32 | + |
| 33 | + match distro.id(): |
| 34 | + case dist if dist in {"ubuntu", "debian"}: |
| 35 | + |
| 36 | + return ( |
| 37 | + _run_command( |
| 38 | + ["apt-get", "--print-uris", "download", f"libpython{version}-dev"] |
| 39 | + ) |
| 40 | + .stdout.splitlines()[0] |
| 41 | + .split()[0] |
| 42 | + .strip("'") |
| 43 | + ) |
| 44 | + |
| 45 | + case dist if dist in {"fedora", "rhel", "rocky", "centos", "amzn"}: |
| 46 | + |
| 47 | + def dnf(package_name: str) -> str: |
| 48 | + # TODO support on ARM |
| 49 | + return _run_command( |
| 50 | + [ |
| 51 | + "dnf", |
| 52 | + "repoquery", |
| 53 | + "--location", |
| 54 | + package_name, |
| 55 | + "--archlist", |
| 56 | + "x86_64", |
| 57 | + ] |
| 58 | + ).stdout.strip() |
| 59 | + |
| 60 | + rpms = dnf(f"python{version}-devel") |
| 61 | + if not rpms: |
| 62 | + # The package may be named python3-devel if it's the default |
| 63 | + # Python version for the current distro version. |
| 64 | + rpms = dnf("python3-devel") |
| 65 | + assert version in rpms, f"Expected to find version {version} in package name: {rpms}" |
| 66 | + return rpms.splitlines()[ |
| 67 | + 0 |
| 68 | + ] # Take the first result if there are multiple matches. |
| 69 | + |
| 70 | + case _: |
| 71 | + raise ValueError(f"Unsupported distribution: {distro.id()}") |
| 72 | + |
| 73 | + |
| 74 | +def _extract_deb(package_path: Path, extract_dir: Path) -> None: |
| 75 | + """Extract a .deb package into the given directory.""" |
| 76 | + _run_command(["dpkg-deb", "-x", str(package_path), str(extract_dir)]) |
| 77 | + |
| 78 | + |
| 79 | +def _extract_rpm(package_path: Path, extract_dir: Path) -> None: |
| 80 | + """Extract a .rpm package into the given directory.""" |
| 81 | + with subprocess.Popen( |
| 82 | + ["rpm2cpio", str(package_path)], stdout=subprocess.PIPE |
| 83 | + ) as rpm2cpio: |
| 84 | + _run_command(["cpio", "-idm"], stdin=rpm2cpio.stdout, cwd=extract_dir) |
| 85 | + rpm2cpio.wait() |
| 86 | + if rpm2cpio.returncode != 0: |
| 87 | + raise subprocess.CalledProcessError( |
| 88 | + rpm2cpio.returncode, ["rpm2cpio", str(package_path)] |
| 89 | + ) |
| 90 | + |
| 91 | + |
| 92 | +def python_dev_headers( |
| 93 | + version: str, storage_dir: Path, uri_override: str | None = None |
| 94 | +) -> Path: |
| 95 | + """ |
| 96 | + Return the path to the include directory containing Python headers. |
| 97 | +
|
| 98 | + Downloads the appropriate python-dev/python-devel package for the current Linux |
| 99 | + distribution if necessary, extracts it, and returns the path to the |
| 100 | + extracted headers. |
| 101 | +
|
| 102 | + Args: |
| 103 | + version: The Python version string, e.g. "3.11". |
| 104 | + storage_dir: The directory to use for downloading and extracting packages. |
| 105 | + uri_override: If provided, this URI will be used instead of determining |
| 106 | + the package URL based on the distribution. This is intended for testing. |
| 107 | +
|
| 108 | + Returns: |
| 109 | + The path to the directory containing Python.h and other headers. |
| 110 | +
|
| 111 | + Raises: |
| 112 | + ValueError: If the current distribution is not supported. |
| 113 | + subprocess.CalledProcessError: If downloading or extracting the package fails. |
| 114 | + FileNotFoundError: If Python.h cannot be found after extraction. |
| 115 | + """ |
| 116 | + download_dir = storage_dir / "packages" |
| 117 | + download_dir.mkdir(exist_ok=True) |
| 118 | + |
| 119 | + uri = uri_override or _get_download_package_urls(version) |
| 120 | + |
| 121 | + # Fetch the package file to the download directory. |
| 122 | + package_path = download_dir / uri.split("/")[-1] |
| 123 | + if not package_path.exists(): |
| 124 | + _run_command(["curl", "-L", "-o", str(package_path), uri]) |
| 125 | + |
| 126 | + extract_dir = storage_dir / (package_path.stem + "_extracted") |
| 127 | + if not extract_dir.exists(): |
| 128 | + if package_path.suffix == ".deb": |
| 129 | + extract = _extract_deb |
| 130 | + elif package_path.suffix == ".rpm": |
| 131 | + extract = _extract_rpm |
| 132 | + else: |
| 133 | + raise ValueError( |
| 134 | + f"""Unknown package format: {package_path.suffix}. Expected .deb or .rpm.""" |
| 135 | + ) |
| 136 | + |
| 137 | + extract_dir.mkdir(exist_ok=True) |
| 138 | + extract(package_path, extract_dir) |
| 139 | + |
| 140 | + return extract_dir |
0 commit comments