Files
cibuildwheel/cibuildwheel/venv.py
T
79244d366c feet: general approach to auditing wheels with abi3audit default (#2805)
* 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>
2026-05-14 07:41:14 -07:00

208 lines
7.3 KiB
Python

import contextlib
import functools
import os
import shutil
import sys
import tomllib
from collections.abc import Sequence
from pathlib import Path
from typing import Final, cast
from filelock import FileLock
from packaging.markers import default_environment
from packaging.requirements import InvalidRequirement, Requirement
from packaging.version import Version
from cibuildwheel.util import resources
from cibuildwheel.util.cmd import call
from cibuildwheel.util.file import CIBW_CACHE_PATH, download
_IS_WIN: Final[bool] = sys.platform.startswith("win")
def target_marker_env(
*,
implementation_id: str,
) -> dict[str, str]:
"""
Build a PEP 508 marker environment dict for the target Python,
overriding the host's values with the target implementation info.
"""
env = cast("dict[str, str]", default_environment())
if implementation_id.startswith("gp"):
env["implementation_name"] = "graalpy"
env["platform_python_implementation"] = "GraalPy"
elif implementation_id.startswith("pp"):
env["implementation_name"] = "pypy"
env["platform_python_implementation"] = "PyPy"
return env
@functools.cache
def _ensure_virtualenv(version: str) -> tuple[Path, Version]:
version_parts = version.split(".")
key = f"py{version_parts[0]}{version_parts[1]}"
with resources.VIRTUALENV.open("rb") as f:
loaded_file = tomllib.load(f)
configuration = loaded_file.get(key, loaded_file["default"])
version = str(configuration["version"])
url = str(configuration["url"])
path = CIBW_CACHE_PATH / f"virtualenv-{version}.pyz"
with FileLock(str(path) + ".lock"):
if not path.exists():
download(url, path)
return (path, Version(version))
def constraint_flags(
dependency_constraint: Path | None,
) -> Sequence[str]:
"""
Returns the flags to pass to pip for the given dependency constraint.
"""
return ["-c", dependency_constraint.as_uri()] if dependency_constraint else []
def _parse_pip_constraint_for_virtualenv(
constraint_path: Path | None,
marker_env: dict[str, str] | None = None,
) -> str:
"""
Parses the constraints file referenced by `dependency_constraint_flags` and returns a dict where
the key is the package name, and the value is the constraint version.
If a package version cannot be found, its value is "embed" meaning that virtualenv will install
its bundled version, already available locally.
The function does not try to be too smart and just handles basic constraints.
If it can't get an exact version, the real constraint will be handled by the
{macos|windows}.setup_python function.
If marker_env is provided, marker-bearing constraints are evaluated against it;
otherwise, marker-bearing constraints are skipped.
"""
env: dict[str, str] = (
marker_env if marker_env is not None else cast("dict[str, str]", default_environment())
)
if constraint_path:
assert constraint_path.exists()
with constraint_path.open(encoding="utf-8") as constraint_file:
for line_ in constraint_file:
line = line_.strip()
if not line:
continue
if line.startswith("#"):
continue
try:
requirement = Requirement(line)
package = requirement.name
if (
package != "pip"
or requirement.url is not None
or len(requirement.extras) != 0
or len(requirement.specifier) != 1
):
continue
if requirement.marker is not None and not requirement.marker.evaluate(env):
continue
specifier = next(iter(requirement.specifier))
if specifier.operator != "==":
continue
return specifier.version
except InvalidRequirement:
continue
return "embed"
def virtualenv(
version: str,
python: Path,
venv_path: Path,
dependency_constraint: Path | None,
*,
use_uv: bool,
env: dict[str, str] | None = None,
pip_version: str | None = None,
marker_env: dict[str, str] | None = None,
) -> dict[str, str]:
"""
Create a virtual environment. If `use_uv` is True,
dependency_constraint_flags are ignored since nothing is installed in the
venv. Otherwise, pip is installed.
"""
# virtualenv may fail if this is a symlink.
python = python.resolve()
assert python.exists()
if use_uv:
uv_path = find_uv()
assert uv_path is not None
call(uv_path, "venv", venv_path, "--python", python)
else:
virtualenv_app, virtualenv_version = _ensure_virtualenv(version)
if pip_version is None:
pip_version = _parse_pip_constraint_for_virtualenv(dependency_constraint, marker_env)
additional_flags = [f"--pip={pip_version}", "--no-setuptools"]
if virtualenv_version < Version("20.31") or Version(version) < Version("3.9"):
additional_flags.append("--no-wheel")
# Using symlinks to pre-installed seed packages is really the fastest way to get a virtual
# environment. The initial cost is a bit higher but reusing is much faster.
# Windows does not always allow symlinks so just disabling for now.
# Requires pip>=19.3 so disabling for "embed" because this means we don't know what's the
# version of pip that will end-up installed.
# c.f. https://virtualenv.pypa.io/en/latest/cli_interface.html#section-seeder
if not _IS_WIN and pip_version != "embed" and Version(pip_version) >= Version("19.3"):
additional_flags.append("--symlink-app-data")
call(
sys.executable,
"-sS", # just the stdlib, https://github.com/pypa/virtualenv/issues/2133#issuecomment-1003710125
virtualenv_app,
"--activators=",
"--no-periodic-update",
*additional_flags,
"--python",
python,
venv_path,
)
venv_env = activate_virtualenv(venv_path, env=env)
if not use_uv and pip_version == "embed":
call(
"python",
"-m",
"pip",
"install",
"--upgrade",
"pip",
*constraint_flags(dependency_constraint),
env=venv_env,
cwd=venv_path,
)
return venv_env
def activate_virtualenv(
venv_path: Path,
env: dict[str, str] | None = None,
) -> dict[str, str]:
"""
Return a copy of the environment with the virtualenv at `venv_path` activated.
"""
paths = [str(venv_path), str(venv_path / "Scripts")] if _IS_WIN else [str(venv_path / "bin")]
venv_env = os.environ.copy() if env is None else env.copy()
venv_env["PATH"] = os.pathsep.join([*paths, venv_env["PATH"]])
venv_env["VIRTUAL_ENV"] = str(venv_path)
return venv_env
def find_uv() -> Path | None:
# Prefer uv in our environment
with contextlib.suppress(ImportError, FileNotFoundError):
from uv import find_uv_bin # noqa: PLC0415
return Path(find_uv_bin())
uv_on_path = shutil.which("uv")
return Path(uv_on_path) if uv_on_path else None