* WIP - initial punt at audit command * Add `abi3audit` as a dependency * Add helper functions to check stable ABI wheels * Run `abi3audit` for macOS and Windows wheels * Copy out of container for repairing? * Add some notes that `cibuildwheel` runs `abi3audit` * Add basic unit tests * Add a basic C extension with `Py_LIMITED_API` * Add a test project that violates Stable ABI * Fix linux test * Skip abi3 wheel tests for Pyodide * Patch the correct subprocess module * wrap cleanup of abi3audit dir * Write the docs for the new options * Move to above testing in docs * Implement audit-requires and audit-command * Some cleanups after self-review * Add default value * fix type errors * the key is `audit-command`, not `audit` * Add a variety of tests for audit requires options * Add `test_audit_requires` similar to `test_test_requires` * Add some configurability-related audit tests * Fix parsing error with options docs leaving out commands * Better way to extract version (maybe helps Pyodide?) * Fix a case of unbound `use_uv` * Standardise: rename to `abi3_wheel` * Fix audit command run message * Simplify custom audit command a bit * Remove unnecessary skip for Pyodide * Pyodide should have no default audit command * More accurate skip messages for Pyodide skips * Wheels are audited after they are repaired * Regenerate constraints to include `abi3audit` * Fix typos * Some attempts for Windows fixes * Check `pyvenv.cfg` instead of directory existence * Add validation for lack of wheel placeholders * Try yet another Windows `uv` fix * Regenerate diagram and re-trigger Azure CI * Add missing `import sys` for abi3 C extension tests * Remove audit-command at the global level * Clarify `abi3audit` pinning a little bit * Regen constraints * Discard changes to cibuildwheel/resources/constraints-pyodide312.txt * Discard changes to cibuildwheel/resources/constraints-pyodide313.txt * try opt-in uv again * fix issue on windows on Python 3.13 related to nested venvs On win / python 3.13, virtualenv creates a venv where the 'home' points back to the venv that sys.executable was running in, rather than the root install. that seemingly leads to problems with package resolution, where pip.exe couldn't find the pip python package. this appears to fix it! * Update constraints * chore: revert python-discovery bump Assisted-by: OpenCode:glm-5.1 Signed-off-by: Henry Schreiner <henryfs@princeton.edu> * fix: restore workaround for graalpy Assisted-by: OpenCode:glm-5.1 Signed-off-by: Henry Schreiner <henryfs@princeton.edu> --------- Signed-off-by: Henry Schreiner <henryfs@princeton.edu> Co-authored-by: Agriya Khetarpal <74401230+agriyakhetarpal@users.noreply.github.com> Co-authored-by: Henry Schreiner <henryfs@princeton.edu>
186 lines
6.9 KiB
Python
186 lines
6.9 KiB
Python
import shlex
|
|
from collections.abc import Mapping, Sequence
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path, PurePath
|
|
from typing import Literal, Self, 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
|
|
|
|
|
|
@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)
|