Files
cibuildwheel/cibuildwheel/util/packaging.py
T

192 lines
7.0 KiB
Python

from __future__ import annotations
import shlex
from dataclasses import dataclass, field
from pathlib import Path, PurePath
from typing import TypeVar
from packaging.utils import parse_wheel_filename
from cibuildwheel.util import resources
from cibuildwheel.util.cmd import call
from cibuildwheel.util.helpers import parse_key_value_string, unwrap
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from typing import Literal, Self
@dataclass(kw_only=True)
class DependencyConstraints:
base_file_path: Path | None = None
packages: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
if self.packages and self.base_file_path is not None:
msg = "Cannot specify both a file and packages in the dependency constraints"
raise ValueError(msg)
if self.base_file_path is not None:
if not self.base_file_path.exists():
msg = f"Dependency constraints file not found: {self.base_file_path}"
raise FileNotFoundError(msg)
self.base_file_path = self.base_file_path.resolve()
@classmethod
def pinned(cls) -> Self:
return cls(base_file_path=resources.CONSTRAINTS)
@classmethod
def latest(cls) -> Self:
return cls()
@classmethod
def from_config_string(cls, config_string: str) -> Self:
if config_string == "pinned":
return cls.pinned()
if config_string == "latest" or not config_string:
return cls.latest()
if config_string.startswith(("file:", "packages:")):
# we only do the table-style parsing if it looks like a table,
# because this option used to be only a file path. We don't want
# to break existing configurations, whose file paths might include
# special characters like ':' or ' ', which would require quoting
# if they were to be passed as a parse_key_value_string positional
# argument.
return cls.from_table_style_config_string(config_string)
return cls(base_file_path=Path(config_string))
@classmethod
def from_table_style_config_string(cls, config_string: str) -> Self:
config_dict = parse_key_value_string(config_string, kw_arg_names=["file", "packages"])
files = config_dict.get("file")
packages = config_dict.get("packages") or []
if files and packages:
msg = "Cannot specify both a file and packages in dependency-versions"
raise ValueError(msg)
if files:
if len(files) > 1:
msg = unwrap("""
Only one file can be specified in dependency-versions.
If you intended to pass only one, perhaps you need to quote the path?
""")
raise ValueError(msg)
return cls(base_file_path=Path(files[0]))
return cls(packages=packages)
def get_for_python_version(
self, *, version: str, variant: Literal["python", "pyodide"] = "python", tmp_dir: Path
) -> Path | None:
if self.packages:
constraint_file = tmp_dir / "constraints.txt"
constraint_file.write_text("\n".join(self.packages))
return constraint_file
if self.base_file_path is not None:
version_parts = version.split(".")
# try to find a version-specific dependency file e.g. if
# ./constraints.txt is the base, look for ./constraints-python36.txt
specific_stem = (
self.base_file_path.stem + f"-{variant}{version_parts[0]}{version_parts[1]}"
)
specific_name = specific_stem + self.base_file_path.suffix
specific_file_path = self.base_file_path.with_name(specific_name)
if specific_file_path.exists():
return specific_file_path
else:
return self.base_file_path
return None
def options_summary(self) -> str | dict[str, str]:
if self == DependencyConstraints.pinned():
return "pinned"
elif self.packages:
return {"packages": " ".join(shlex.quote(p) for p in self.packages)}
elif self.base_file_path is not None:
return self.base_file_path.name
else:
return "latest"
def get_pip_version(env: Mapping[str, str]) -> str:
versions_output_text = call(
"python", "-m", "pip", "freeze", "--all", capture_stdout=True, env=env
)
(pip_version,) = (
version[5:]
for version in versions_output_text.strip().splitlines()
if version.startswith("pip==")
)
return pip_version
T = TypeVar("T", bound=PurePath)
def find_compatible_wheel(wheels: Sequence[T], identifier: str) -> T | None:
"""
Finds a wheel with an abi3 or a none ABI tag in `wheels` compatible with the Python interpreter
specified by `identifier` that is previously built.
"""
interpreter, platform = identifier.split("-", 1)
interpreter = interpreter.split("_")[0]
free_threaded = interpreter.endswith("t")
if free_threaded:
interpreter = interpreter[:-1]
for wheel in wheels:
_, _, _, tags = parse_wheel_filename(wheel.name)
for tag in tags:
if tag.abi == "abi3" and not free_threaded:
# ABI3 wheels must start with cp3 for impl and tag
if not (interpreter.startswith("cp3") and tag.interpreter.startswith("cp3")):
continue
elif tag.abi == "none":
# CPythonless wheels must include py3 tag
if tag.interpreter[:3] != "py3":
continue
else:
# Other types of wheels are not detected, this is looking for previously built wheels.
continue
if tag.interpreter != "py3" and int(tag.interpreter[3:]) > int(interpreter[3:]):
# If a minor version number is given, it has to be lower than the current one.
continue
if platform.startswith(("manylinux", "musllinux", "macosx", "android", "ios")):
# On these platforms the wheel tag includes a platform version number, which we
# should ignore.
os_, arch = platform.split("_", 1)
if not tag.platform.startswith(os_):
continue
if not tag.platform.endswith(f"_{arch}"):
continue
elif platform.startswith("pyodide"):
# each Pyodide version has its own platform tag
continue
# Windows should exactly match
elif tag.platform != platform:
continue
# If all the filters above pass, then the wheel is a previously built compatible wheel.
return wheel
return None
def is_abi3_wheel(wheel_name: str) -> bool:
"""Check if a wheel uses the abi3 stable ABI based on its filename."""
_, _, _, tags = parse_wheel_filename(wheel_name)
return any(tag.abi == "abi3" for tag in tags)