Files
cibuildwheel/cibuildwheel/util.py
T

951 lines
31 KiB
Python
Raw Normal View History

from __future__ import annotations
2021-02-04 14:27:54 -05:00
import contextlib
import fnmatch
import itertools
2019-11-12 23:51:27 +00:00
import os
2020-12-20 19:42:28 +00:00
import re
import shlex
import shutil
import ssl
2021-06-23 10:47:18 -04:00
import subprocess
import sys
2024-05-28 05:31:36 -07:00
import tarfile
2020-11-23 21:00:22 +00:00
import textwrap
2021-02-04 14:27:54 -05:00
import time
2023-04-18 12:38:21 -04:00
import typing
2020-11-23 21:00:22 +00:00
import urllib.request
from collections import defaultdict
2024-06-09 15:45:31 -04:00
from collections.abc import Generator, Iterable, Mapping, MutableMapping, Sequence
2022-04-28 09:19:27 -04:00
from dataclasses import dataclass
2020-11-23 21:00:22 +00:00
from enum import Enum
from functools import lru_cache, total_ordering
2022-04-18 14:24:53 +02:00
from pathlib import Path, PurePath
2024-05-28 05:31:36 -07:00
from tempfile import TemporaryDirectory
from time import sleep
from typing import Any, ClassVar, Final, Literal, TextIO, TypeVar
2024-05-28 05:31:36 -07:00
from zipfile import ZipFile
import bracex
2020-11-23 21:00:22 +00:00
import certifi
from filelock import FileLock
from packaging.requirements import InvalidRequirement, Requirement
2021-01-31 17:14:15 -05:00
from packaging.specifiers import SpecifierSet
2022-04-18 14:24:53 +02:00
from packaging.utils import parse_wheel_filename
2021-01-31 17:14:15 -05:00
from packaging.version import Version
from platformdirs import user_cache_path
2020-11-23 21:00:22 +00:00
2023-04-18 23:29:54 -04:00
from ._compat import tomllib
2024-05-28 05:31:36 -07:00
from .architecture import Architecture
2023-04-18 23:05:34 -04:00
from .typing import PathOrStr, PlatformName
__all__ = [
"MANYLINUX_ARCHS",
2024-06-09 15:45:31 -04:00
"call",
2022-04-26 22:21:27 -04:00
"chdir",
2024-06-09 15:45:31 -04:00
"combine_constraints",
"find_compatible_wheel",
"find_uv",
"format_safe",
"get_build_verbosity_extra_flags",
"prepare_command",
"read_python_configs",
"resources_dir",
"selector_matches",
"shell",
2022-09-06 00:56:20 -04:00
"split_config_settings",
2024-06-09 15:45:31 -04:00
"strtobool",
]
2022-09-16 11:05:05 -04:00
resources_dir: Final[Path] = Path(__file__).parent / "resources"
2021-01-31 17:14:15 -05:00
2022-09-16 11:05:05 -04:00
install_certifi_script: Final[Path] = resources_dir / "install_certifi.py"
free_thread_enable_313: Final[Path] = resources_dir / "free-threaded-enable-313.xml"
test_fail_cwd_file: Final[Path] = resources_dir / "testing_temp_dir_file.py"
2022-09-16 11:05:05 -04:00
MANYLINUX_ARCHS: Final[tuple[str, ...]] = (
2021-09-19 00:19:28 -04:00
"x86_64",
"i686",
"pypy_x86_64",
"aarch64",
"ppc64le",
"s390x",
"pypy_aarch64",
"pypy_i686",
)
2022-09-16 11:05:05 -04:00
MUSLLINUX_ARCHS: Final[tuple[str, ...]] = (
2021-09-19 00:19:28 -04:00
"x86_64",
"i686",
"aarch64",
"ppc64le",
"s390x",
2024-10-01 16:33:57 +02:00
"armv7l",
2021-09-19 00:19:28 -04:00
)
2022-09-16 11:05:05 -04:00
DEFAULT_CIBW_CACHE_PATH: Final[Path] = user_cache_path(appname="cibuildwheel", appauthor="pypa")
CIBW_CACHE_PATH: Final[Path] = Path(
os.environ.get("CIBW_CACHE_PATH", DEFAULT_CIBW_CACHE_PATH)
).resolve()
2022-09-16 11:05:05 -04:00
IS_WIN: Final[bool] = sys.platform.startswith("win")
2023-04-18 12:38:21 -04:00
@typing.overload
def call(
*args: PathOrStr,
env: Mapping[str, str] | None = None,
cwd: PathOrStr | None = None,
capture_stdout: Literal[False] = ...,
) -> None: ...
2023-04-18 12:38:21 -04:00
@typing.overload
def call(
*args: PathOrStr,
env: Mapping[str, str] | None = None,
cwd: PathOrStr | None = None,
capture_stdout: Literal[True],
) -> str: ...
def call(
*args: PathOrStr,
env: Mapping[str, str] | None = None,
cwd: PathOrStr | None = None,
capture_stdout: bool = False,
) -> str | None:
"""
Run subprocess.run, but print the commands first. Takes the commands as
*args. Uses shell=True on Windows due to a bug. Also converts to
Paths to strings, due to Windows behavior at least on older Pythons.
https://bugs.python.org/issue8557
"""
args_ = [str(arg) for arg in args]
# print the command executing for the logs
print("+ " + " ".join(shlex.quote(a) for a in args_))
kwargs: dict[str, Any] = {}
if capture_stdout:
kwargs["universal_newlines"] = True
kwargs["stdout"] = subprocess.PIPE
result = subprocess.run(args_, check=True, shell=IS_WIN, env=env, cwd=cwd, **kwargs)
if not capture_stdout:
return None
2023-04-18 12:38:21 -04:00
return typing.cast(str, result.stdout)
def shell(
*commands: str, env: Mapping[str, str] | None = None, cwd: PathOrStr | None = None
) -> None:
command = " ".join(commands)
print(f"+ {command}")
subprocess.run(command, env=env, cwd=cwd, shell=True, check=True)
2022-09-09 08:34:47 -04:00
def format_safe(template: str, **kwargs: str | os.PathLike[str]) -> str:
2021-10-21 15:18:01 -04:00
"""
Works similarly to `template.format(**kwargs)`, except that unmatched
fields in `template` are passed through untouched.
>>> format_safe('{a} {b}', a='123')
'123 {b}'
>>> format_safe('{a} {b[4]:3f}', a='123')
'123 {b[4]:3f}'
To avoid variable expansion, precede with a single backslash e.g.
>>> format_safe('\\{a} {b}', a='123')
'{a} {b}'
"""
result = template
for key, value in kwargs.items():
find_pattern = re.compile(
rf"""
2021-11-14 18:43:34 +00:00
(?<!\#) # don't match if preceded by a hash
2021-10-21 15:18:01 -04:00
{{ # literal open curly bracket
{re.escape(key)} # the field name
}} # literal close curly bracket
""",
re.VERBOSE,
)
2021-11-14 13:38:41 +00:00
result = re.sub(
pattern=find_pattern,
2022-09-09 08:34:47 -04:00
repl=str(value).replace("\\", r"\\"),
2021-11-14 13:38:41 +00:00
string=result,
)
2021-10-21 15:18:01 -04:00
# transform escaped sequences into their literal equivalents
2021-11-14 18:43:34 +00:00
result = result.replace(f"#{{{key}}}", f"{{{key}}}")
2021-10-21 15:18:01 -04:00
return result
def prepare_command(command: str, **kwargs: PathOrStr) -> str:
2021-05-03 11:45:43 -04:00
"""
2019-11-12 23:34:59 +00:00
Preprocesses a command by expanding variables like {python}.
2019-11-12 23:34:59 +00:00
For example, used in the test_command option to specify the path to the
2021-10-21 15:18:01 -04:00
project's root. Unmatched syntax will mostly be allowed through.
2021-05-03 11:45:43 -04:00
"""
2021-10-21 15:18:01 -04:00
return format_safe(command, python="python", pip="pip", **kwargs)
2017-04-11 22:57:42 +01:00
def get_build_verbosity_extra_flags(level: int) -> list[str]:
if level > 0:
2021-05-03 11:45:43 -04:00
return ["-" + level * "v"]
elif level < 0:
2021-05-03 11:45:43 -04:00
return ["-" + -level * "q"]
else:
return []
2024-06-09 15:45:31 -04:00
def split_config_settings(
config_settings: str, frontend: Literal["pip", "build", "build[uv]"]
) -> list[str]:
2022-09-06 00:56:20 -04:00
config_settings_list = shlex.split(config_settings)
2023-03-10 13:46:42 -05:00
s = "s" if frontend == "pip" else ""
2023-03-10 12:00:51 -05:00
return [f"--config-setting{s}={setting}" for setting in config_settings_list]
2022-09-06 00:56:20 -04:00
def read_python_configs(config: PlatformName) -> list[dict[str, str]]:
2021-05-03 11:45:43 -04:00
input_file = resources_dir / "build-platforms.toml"
2021-10-15 11:30:16 -04:00
with input_file.open("rb") as f:
2022-04-15 21:00:57 -07:00
loaded_file = tomllib.load(f)
results: list[dict[str, str]] = list(loaded_file[config]["python_configurations"])
2021-01-09 15:40:40 -05:00
return results
2021-10-12 02:05:47 +01:00
def selector_matches(patterns: str, string: str) -> bool:
"""
Returns True if `string` is matched by any of the wildcard patterns in
`patterns`.
Matching is according to fnmatch, but with shell-like curly brace
expansion. For example, 'cp{36,37}-*' would match either of 'cp36-*' or
'cp37-*'.
"""
patterns_list = patterns.split()
expanded_patterns = itertools.chain.from_iterable(bracex.expand(p) for p in patterns_list)
return any(fnmatch.fnmatch(string, pat) for pat in expanded_patterns)
2021-10-12 02:05:47 +01:00
# Once we require Python 3.10+, we can add kw_only=True
2022-04-28 09:19:27 -04:00
@dataclass(frozen=True)
2022-06-15 10:12:46 +01:00
class BuildSelector:
2021-01-20 21:22:48 -05:00
"""
This class holds a set of build/skip patterns. You call an instance with a
build identifier, and it returns True if that identifier should be
2021-01-31 17:14:15 -05:00
included. Only call this on valid identifiers, ones that have at least 2
2022-06-15 10:12:46 +01:00
numeric digits before the first dash.
2021-01-20 21:22:48 -05:00
"""
2021-01-31 17:14:15 -05:00
2022-06-15 10:12:46 +01:00
build_config: str
skip_config: str
requires_python: SpecifierSet | None = None
2022-06-15 10:12:46 +01:00
# a pattern that skips prerelease versions, when include_prereleases is False.
2024-08-03 20:46:45 +02:00
PRERELEASE_SKIP: ClassVar[str] = ""
prerelease_pythons: bool = False
2017-04-11 22:57:42 +01:00
free_threaded_support: bool = False
def __call__(self, build_id: str) -> bool:
2021-01-31 17:14:15 -05:00
# Filter build selectors by python_requires if set
if self.requires_python is not None:
2021-05-03 11:45:43 -04:00
py_ver_str = build_id.split("-")[0]
if py_ver_str.endswith("t"):
py_ver_str = py_ver_str[:-1]
2021-01-31 17:14:15 -05:00
major = int(py_ver_str[2])
minor = int(py_ver_str[3:])
version = Version(f"{major}.{minor}.99")
if not self.requires_python.contains(version):
return False
2021-10-12 02:05:47 +01:00
# filter out the prerelease pythons if self.prerelease_pythons is False
if not self.prerelease_pythons and selector_matches(self.PRERELEASE_SKIP, build_id):
2021-10-12 02:05:47 +01:00
return False
# filter out free threaded pythons if self.free_threaded_support is False
if not self.free_threaded_support and selector_matches("*t-*", build_id):
return False
2021-10-12 02:05:47 +01:00
should_build = selector_matches(self.build_config, build_id)
should_skip = selector_matches(self.skip_config, build_id)
2021-10-12 02:05:47 +01:00
return should_build and not should_skip
2017-04-13 15:02:04 +01:00
2022-11-26 15:54:08 +00:00
def options_summary(self) -> Any:
return {
"build_config": self.build_config,
"skip_config": self.skip_config,
"requires_python": str(self.requires_python),
"prerelease_pythons": self.prerelease_pythons,
"free_threaded_support": self.free_threaded_support,
2022-11-26 15:54:08 +00:00
}
2021-01-20 21:22:48 -05:00
2022-04-28 09:19:27 -04:00
@dataclass(frozen=True)
2022-06-15 10:12:46 +01:00
class TestSelector:
"""
A build selector that can only skip tests according to a skip pattern.
"""
2021-01-20 21:22:48 -05:00
2022-06-15 10:12:46 +01:00
skip_config: str
2021-01-20 21:22:48 -05:00
2022-06-15 10:12:46 +01:00
def __call__(self, build_id: str) -> bool:
should_skip = selector_matches(self.skip_config, build_id)
return not should_skip
2022-11-26 15:54:08 +00:00
def options_summary(self) -> Any:
return {"skip_config": self.skip_config}
# Taken from https://stackoverflow.com/a/107717
2020-02-03 20:42:55 +01:00
class Unbuffered:
def __init__(self, stream: TextIO) -> None:
self.stream = stream
def write(self, data: str) -> None:
self.stream.write(data)
self.stream.flush()
def writelines(self, data: Iterable[str]) -> None:
2021-07-14 13:45:04 -04:00
self.stream.writelines(data)
self.stream.flush()
def __getattr__(self, attr: str) -> Any:
return getattr(self.stream, attr)
2020-06-15 01:53:31 +02:00
def download(url: str, dest: Path) -> None:
2021-05-03 11:45:43 -04:00
print(f"+ Download {url} to {dest}")
2020-06-15 01:53:31 +02:00
dest_dir = dest.parent
if not dest_dir.exists():
dest_dir.mkdir(parents=True)
# we've had issues when relying on the host OS' CA certificates on Windows,
# so we use certifi (this sounds odd but requests also does this by default)
2021-05-03 11:45:43 -04:00
cafile = os.environ.get("SSL_CERT_FILE", certifi.where())
context = ssl.create_default_context(cafile=cafile)
repeat_num = 3
for i in range(repeat_num):
try:
with urllib.request.urlopen(url, context=context) as response:
dest.write_bytes(response.read())
return
except OSError:
if i == repeat_num - 1:
raise
sleep(3)
2024-05-28 05:31:36 -07:00
def extract_zip(zip_src: Path, dest: Path) -> None:
with ZipFile(zip_src) as zip_:
for zinfo in zip_.filelist:
zip_.extract(zinfo, dest)
# Set permissions to the same values as they were set in the archive
# We have to do this manually due to
# https://github.com/python/cpython/issues/59999
# But some files in the zipfile seem to have external_attr with 0
# permissions. In that case just use the default value???
permissions = (zinfo.external_attr >> 16) & 0o777
if permissions != 0:
dest.joinpath(zinfo.filename).chmod(permissions)
def extract_tar(tar_src: Path, dest: Path) -> None:
with tarfile.open(tar_src) as tar_:
tar_.extraction_filter = getattr(tarfile, "tar_filter", (lambda member, _: member))
tar_.extractall(dest)
def move_file(src_file: Path, dst_file: Path) -> Path:
"""Moves a file safely while avoiding potential semantic confusion:
1. `dst_file` must point to the target filename, not a directory
2. `dst_file` will be overwritten if it already exists
3. any missing parent directories will be created
Returns the fully resolved Path of the resulting file.
Raises:
NotADirectoryError: If any part of the intermediate path to `dst_file` is an existing file
IsADirectoryError: If `dst_file` points directly to an existing directory
"""
src_file = src_file.resolve(strict=True)
dst_file = dst_file.resolve()
if dst_file.is_dir():
msg = "dst_file must be a valid target filename, not an existing directory."
raise IsADirectoryError(msg)
dst_file.unlink(missing_ok=True)
dst_file.parent.mkdir(parents=True, exist_ok=True)
# using shutil.move() as Path.rename() is not guaranteed to work across filesystem boundaries
# explicit str() needed for Python 3.8
resulting_file = shutil.move(str(src_file), str(dst_file))
return Path(resulting_file).resolve(strict=True)
class DependencyConstraints:
2020-06-15 01:53:31 +02:00
def __init__(self, base_file_path: Path):
assert base_file_path.exists()
self.base_file_path = base_file_path.resolve()
2020-04-10 20:24:52 +02:00
@staticmethod
def with_defaults() -> DependencyConstraints:
2021-05-03 11:45:43 -04:00
return DependencyConstraints(base_file_path=resources_dir / "constraints.txt")
2024-05-28 05:31:36 -07:00
def get_for_python_version(
self, version: str, *, variant: Literal["python", "pyodide"] = "python"
) -> Path:
2021-05-03 11:45:43 -04:00
version_parts = version.split(".")
# try to find a version-specific dependency file e.g. if
2021-02-14 20:44:20 +01:00
# ./constraints.txt is the base, look for ./constraints-python36.txt
2024-05-28 05:31:36 -07:00
specific_stem = self.base_file_path.stem + f"-{variant}{version_parts[0]}{version_parts[1]}"
2020-06-17 00:18:40 +02:00
specific_name = specific_stem + self.base_file_path.suffix
specific_file_path = self.base_file_path.with_name(specific_name)
2020-06-15 01:53:31 +02:00
if specific_file_path.exists():
return specific_file_path
else:
return self.base_file_path
def __repr__(self) -> str:
2021-10-12 02:05:47 +01:00
return f"{self.__class__.__name__}({self.base_file_path!r})"
2021-10-12 02:05:47 +01:00
def __eq__(self, o: object) -> bool:
if not isinstance(o, DependencyConstraints):
return False
2021-10-12 02:05:47 +01:00
return self.base_file_path == o.base_file_path
2021-09-19 00:19:28 -04:00
2022-11-26 15:54:08 +00:00
def options_summary(self) -> Any:
if self == DependencyConstraints.with_defaults():
return "pinned"
else:
return self.base_file_path.name
2021-09-19 00:19:28 -04:00
2024-06-09 15:45:31 -04:00
BuildFrontendName = Literal["pip", "build", "build[uv]"]
2023-08-26 19:37:29 +01:00
@dataclass(frozen=True)
class BuildFrontendConfig:
name: BuildFrontendName
args: Sequence[str] = ()
@staticmethod
def from_config_string(config_string: str) -> BuildFrontendConfig:
config_dict = parse_key_value_string(config_string, ["name"], ["args"])
name = " ".join(config_dict["name"])
2024-06-09 15:45:31 -04:00
if name not in {"pip", "build", "build[uv]"}:
msg = f"Unrecognised build frontend {name!r}, only 'pip', 'build', and 'build[uv]' are supported"
2023-08-26 19:37:29 +01:00
raise ValueError(msg)
name = typing.cast(BuildFrontendName, name)
args = config_dict.get("args") or []
return BuildFrontendConfig(name=name, args=args)
def options_summary(self) -> str | dict[str, str]:
if not self.args:
return self.name
else:
return {"name": self.name, "args": repr(self.args)}
2020-11-23 21:00:22 +00:00
def strtobool(val: str) -> bool:
2021-05-03 11:45:43 -04:00
return val.lower() in {"y", "yes", "t", "true", "on", "1"}
2020-11-23 21:00:22 +00:00
2020-12-31 16:35:26 +00:00
class CIProvider(Enum):
2021-05-03 11:45:43 -04:00
travis_ci = "travis"
appveyor = "appveyor"
circle_ci = "circle_ci"
azure_pipelines = "azure_pipelines"
github_actions = "github_actions"
gitlab = "gitlab"
2022-07-24 01:17:22 +02:00
cirrus_ci = "cirrus_ci"
2021-05-03 11:45:43 -04:00
other = "other"
2020-11-23 21:00:22 +00:00
def detect_ci_provider() -> CIProvider | None:
2021-05-03 11:45:43 -04:00
if "TRAVIS" in os.environ:
2020-11-23 21:00:22 +00:00
return CIProvider.travis_ci
2021-05-03 11:45:43 -04:00
elif "APPVEYOR" in os.environ:
2020-11-23 21:00:22 +00:00
return CIProvider.appveyor
2021-05-03 11:45:43 -04:00
elif "CIRCLECI" in os.environ:
2020-11-23 21:00:22 +00:00
return CIProvider.circle_ci
2021-05-03 11:45:43 -04:00
elif "AZURE_HTTP_USER_AGENT" in os.environ:
2020-11-23 21:00:22 +00:00
return CIProvider.azure_pipelines
2021-05-03 11:45:43 -04:00
elif "GITHUB_ACTIONS" in os.environ:
2020-11-23 21:00:22 +00:00
return CIProvider.github_actions
2021-05-03 11:45:43 -04:00
elif "GITLAB_CI" in os.environ:
2020-11-23 21:00:22 +00:00
return CIProvider.gitlab
2022-07-24 01:17:22 +02:00
elif "CIRRUS_CI" in os.environ:
return CIProvider.cirrus_ci
2021-05-03 11:45:43 -04:00
elif strtobool(os.environ.get("CI", "false")):
2020-11-23 21:00:22 +00:00
return CIProvider.other
else:
return None
2021-01-05 19:02:15 +00:00
def unwrap(text: str) -> str:
2021-05-03 11:45:43 -04:00
"""
2021-01-05 19:02:15 +00:00
Unwraps multi-line text to a single line
2021-05-03 11:45:43 -04:00
"""
# remove initial line indent
text = textwrap.dedent(text)
# remove leading/trailing whitespace
text = text.strip()
# remove consecutive whitespace
2021-05-03 11:45:43 -04:00
return re.sub(r"\s+", " ", text)
2021-02-04 14:27:54 -05:00
2022-04-28 09:19:27 -04:00
@dataclass(frozen=True)
class FileReport:
name: str
size: str
2021-02-04 14:27:54 -05:00
@contextlib.contextmanager
2022-04-26 22:21:27 -04:00
def print_new_wheels(msg: str, output_dir: Path) -> Generator[None, None, None]:
2021-05-03 11:45:43 -04:00
"""
2021-02-04 14:27:54 -05:00
Prints the new items in a directory upon exiting. The message to display
can include {n} for number of wheels, {s} for total number of seconds,
and/or {m} for total number of minutes. Does not print anything if this
exits via exception.
2021-05-03 11:45:43 -04:00
"""
2021-02-04 14:27:54 -05:00
start_time = time.time()
existing_contents = set(output_dir.iterdir())
yield
final_contents = set(output_dir.iterdir())
new_contents = [
FileReport(wheel.name, f"{(wheel.stat().st_size + 1023) // 1024:,d}")
for wheel in final_contents - existing_contents
]
2022-10-07 08:47:31 -04:00
if not new_contents:
return
max_name_len = max(len(f.name) for f in new_contents)
max_size_len = max(len(f.size) for f in new_contents)
2021-02-04 14:27:54 -05:00
n = len(new_contents)
s = time.time() - start_time
m = s / 60
print(
msg.format(n=n, s=s, m=m),
*sorted(
f" {f.name:<{max_name_len}s} {f.size:>{max_size_len}s} kB" for f in new_contents
),
sep="\n",
)
2021-06-23 10:47:18 -04:00
def get_pip_version(env: Mapping[str, str]) -> str:
versions_output_text = call(
"python", "-m", "pip", "freeze", "--all", capture_stdout=True, env=env
2021-06-23 10:47:18 -04:00
)
(pip_version,) = (
version[5:]
for version in versions_output_text.strip().splitlines()
if version.startswith("pip==")
)
return pip_version
2024-05-28 05:31:36 -07:00
@lru_cache(maxsize=None)
def ensure_node(major_version: str) -> Path:
input_file = resources_dir / "nodejs.toml"
with input_file.open("rb") as f:
loaded_file = tomllib.load(f)
version = str(loaded_file[major_version])
base_url = str(loaded_file["url"])
ext = "zip" if IS_WIN else "tar.xz"
platform = "win" if IS_WIN else ("darwin" if sys.platform.startswith("darwin") else "linux")
linux_arch = Architecture.native_arch("linux")
assert linux_arch is not None
arch = {"x86_64": "x64", "i686": "x86", "aarch64": "arm64"}.get(
linux_arch.value, linux_arch.value
)
name = f"node-{version}-{platform}-{arch}"
path = CIBW_CACHE_PATH / name
with FileLock(str(path) + ".lock"):
if not path.exists():
url = f"{base_url}{version}/{name}.{ext}"
with TemporaryDirectory() as tmp_path:
archive = Path(tmp_path) / f"{name}.{ext}"
download(url, archive)
if ext == "zip":
extract_zip(archive, path.parent)
else:
extract_tar(archive, path.parent)
assert path.exists()
if not IS_WIN:
return path / "bin"
return path
@lru_cache(maxsize=None)
2024-05-20 08:21:42 +02:00
def _ensure_virtualenv(version: str) -> Path:
version_parts = version.split(".")
key = f"py{version_parts[0]}{version_parts[1]}"
input_file = resources_dir / "virtualenv.toml"
with input_file.open("rb") as f:
2022-04-15 21:00:57 -07:00
loaded_file = tomllib.load(f)
2024-05-20 08:21:42 +02:00
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
def _parse_constraints_for_virtualenv(
seed_packages: list[str],
dependency_constraint_flags: Sequence[PathOrStr],
) -> dict[str, 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.
"""
assert len(dependency_constraint_flags) in {0, 2}
# only seed pip if other seed packages do not appear in a constraint file
constraints_dict = {"pip": "embed"}
if len(dependency_constraint_flags) == 2:
assert dependency_constraint_flags[0] == "-c"
constraint_path = Path(dependency_constraint_flags[1])
assert constraint_path.exists()
with constraint_path.open(encoding="utf-8") as constraint_file:
for line_ in constraint_file:
line = line_.strip()
2022-10-07 08:47:31 -04:00
if not line:
continue
if line.startswith("#"):
continue
try:
requirement = Requirement(line)
package = requirement.name
if (
package not in seed_packages
or requirement.url is not None
or requirement.marker is not None
or len(requirement.extras) != 0
or len(requirement.specifier) != 1
):
continue
specifier = next(iter(requirement.specifier))
if specifier.operator != "==":
continue
constraints_dict[package] = specifier.version
except InvalidRequirement:
continue
return constraints_dict
def virtualenv(
2024-06-09 15:45:31 -04:00
version: str,
python: Path,
venv_path: Path,
dependency_constraint_flags: Sequence[PathOrStr],
*,
use_uv: bool,
) -> dict[str, str]:
2024-06-09 15:45:31 -04:00
"""
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, and setuptools + wheel if Python < 3.12.
"""
assert python.exists()
2024-06-09 15:45:31 -04:00
if use_uv:
call("uv", "venv", venv_path, "--python", python)
else:
virtualenv_app = _ensure_virtualenv(version)
allowed_seed_packages = ["pip", "setuptools", "wheel"]
constraints = _parse_constraints_for_virtualenv(
allowed_seed_packages, dependency_constraint_flags
)
additional_flags: list[str] = []
for package in allowed_seed_packages:
if package in constraints:
additional_flags.append(f"--{package}={constraints[package]}")
else:
additional_flags.append(f"--no-{package}")
# 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 constraints["pip"] != "embed"
and Version(constraints["pip"]) >= 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,
)
2023-10-27 01:21:20 -04:00
paths = [str(venv_path), str(venv_path / "Scripts")] if IS_WIN else [str(venv_path / "bin")]
env = os.environ.copy()
2023-01-30 15:53:44 -05:00
env["PATH"] = os.pathsep.join([*paths, env["PATH"]])
2024-05-29 02:20:02 -04:00
env["VIRTUAL_ENV"] = str(venv_path)
return env
2022-04-18 14:24:53 +02:00
T = TypeVar("T", bound=PurePath)
def find_compatible_wheel(wheels: Sequence[T], identifier: str) -> T | None:
2022-04-18 14:24:53 +02:00
"""
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.
2022-04-18 14:24:53 +02:00
"""
interpreter, platform = identifier.split("-")
free_threaded = interpreter.endswith("t")
if free_threaded:
interpreter = interpreter[:-1]
2022-04-18 14:24:53 +02:00
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.
2022-04-18 14:24:53 +02:00
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.
2022-04-18 14:24:53 +02:00
continue
2022-04-18 14:24:53 +02:00
if platform.startswith(("manylinux", "musllinux", "macosx")):
# Linux, macOS require the beginning and ending match (macos/manylinux version doesn't need to)
2022-04-18 14:24:53 +02:00
os_, arch = platform.split("_", 1)
if not tag.platform.startswith(os_):
continue
if not tag.platform.endswith(f"_{arch}"):
2022-04-18 14:24:53 +02:00
continue
else:
# Windows should exactly match
2023-01-30 15:53:44 -05:00
if tag.platform != platform:
2022-04-18 14:24:53 +02:00
continue
# If all the filters above pass, then the wheel is a previously built compatible wheel.
2022-04-18 14:24:53 +02:00
return wheel
2022-04-18 14:24:53 +02:00
return None
2022-04-26 22:21:27 -04:00
# Can be replaced by contextlib.chdir in Python 3.11
@contextlib.contextmanager
def chdir(new_path: Path | str) -> Generator[None, None, None]:
2022-04-26 22:21:27 -04:00
"""Non thread-safe context manager to change the current working directory."""
cwd = os.getcwd()
try:
os.chdir(new_path)
yield
finally:
os.chdir(cwd)
2022-12-04 13:51:56 +00:00
def fix_ansi_codes_for_github_actions(text: str) -> str:
"""
Github Actions forgets the current ANSI style on every new line. This
function repeats the current ANSI style on every new line.
"""
ansi_code_regex = re.compile(r"(\033\[[0-9;]*m)")
ansi_codes: list[str] = []
output = ""
for line in text.splitlines(keepends=True):
2022-12-04 13:51:56 +00:00
# add the current ANSI codes to the beginning of the line
output += "".join(ansi_codes) + line
2022-12-04 13:51:56 +00:00
# split the line at each ANSI code
parts = ansi_code_regex.split(line)
# if there are any ANSI codes, save them
if len(parts) > 1:
# iterate over the ANSI codes in this line
for code in parts[1::2]:
if code == "\033[0m":
# reset the list of ANSI codes when the clear code is found
ansi_codes = []
else:
ansi_codes.append(code)
return output
def parse_key_value_string(
2023-08-26 19:37:29 +01:00
key_value_string: str,
positional_arg_names: Sequence[str] | None = None,
kw_arg_names: Sequence[str] | None = None,
) -> dict[str, list[str]]:
"""
Parses a string like "docker; create_args: --some-option=value another-option"
"""
if positional_arg_names is None:
positional_arg_names = []
2023-08-26 19:37:29 +01:00
if kw_arg_names is None:
kw_arg_names = []
all_field_names = [*positional_arg_names, *kw_arg_names]
shlexer = shlex.shlex(key_value_string, posix=True, punctuation_chars=";")
shlexer.commenters = ""
shlexer.whitespace_split = True
parts = list(shlexer)
# parts now looks like
# ['docker', ';', 'create_args:', '--some-option=value', 'another-option']
# split by semicolon
fields = [list(group) for k, group in itertools.groupby(parts, lambda x: x == ";") if not k]
result: defaultdict[str, list[str]] = defaultdict(list)
for field_i, field in enumerate(fields):
# check to see if the option name is specified
field_name, sep, first_value = field[0].partition(":")
if sep:
2023-08-26 19:37:29 +01:00
if field_name not in all_field_names:
msg = f"Failed to parse {key_value_string!r}. Unknown field name {field_name!r}"
raise ValueError(msg)
values = ([first_value] if first_value else []) + field[1:]
else:
try:
field_name = positional_arg_names[field_i]
except IndexError:
msg = f"Failed to parse {key_value_string!r}. Too many positional arguments - expected a maximum of {len(positional_arg_names)}"
raise ValueError(msg) from None
values = field
result[field_name] += values
return dict(result)
2024-06-09 15:45:31 -04:00
def find_uv() -> Path | None:
# Prefer uv in our environment
with contextlib.suppress(ImportError, FileNotFoundError):
# pylint: disable-next=import-outside-toplevel
from uv import find_uv_bin
return Path(find_uv_bin())
uv_on_path = shutil.which("uv")
return Path(uv_on_path) if uv_on_path else None
def combine_constraints(
env: MutableMapping[str, str], /, constraints_path: Path, tmp_dir: Path | None
) -> None:
"""
This will workaround a bug in pip<=21.1.1 or uv<=0.2.0 if a tmp_dir is given.
If set to None, this will use the modern URI method.
"""
if tmp_dir:
if " " in str(constraints_path):
assert " " not in str(tmp_dir)
tmp_file = tmp_dir / "constraints.txt"
tmp_file.write_bytes(constraints_path.read_bytes())
constraints_path = tmp_file
our_constraints = str(constraints_path)
else:
our_constraints = (
constraints_path.as_uri() if " " in str(constraints_path) else str(constraints_path)
)
user_constraints = env.get("PIP_CONSTRAINT")
env["UV_CONSTRAINT"] = env["PIP_CONSTRAINT"] = " ".join(
c for c in [our_constraints, user_constraints] if c
)
@total_ordering
class FlexibleVersion:
version_str: str
version_parts: tuple[int, ...]
suffix: str
def __init__(self, version_str: str) -> None:
self.version_str = version_str
# Split into numeric parts and the optional suffix
match = re.match(r"^[v]?(\d+(\.\d+)*)(.*)$", version_str)
if not match:
msg = f"Invalid version string: {version_str}"
raise ValueError(msg)
version_part, _, suffix = match.groups()
# Convert numeric version part into a tuple of integers
self.version_parts = tuple(map(int, version_part.split(".")))
self.suffix = suffix.strip() if suffix else ""
# Normalize by removing trailing zeros
self.version_parts = self._remove_trailing_zeros(self.version_parts)
def _remove_trailing_zeros(self, parts: tuple[int, ...]) -> tuple[int, ...]:
# Remove trailing zeros for accurate comparisons
# without this, "3.0" would be considered greater than "3"
while parts and parts[-1] == 0:
parts = parts[:-1]
return parts
def __eq__(self, other: object) -> bool:
if not isinstance(other, FlexibleVersion):
raise NotImplementedError()
return (self.version_parts, self.suffix) == (other.version_parts, other.suffix)
def __lt__(self, other: object) -> bool:
if not isinstance(other, FlexibleVersion):
raise NotImplementedError()
return (self.version_parts, self.suffix) < (other.version_parts, other.suffix)
def __repr__(self) -> str:
return f"FlexibleVersion('{self.version_str}')"
def __str__(self) -> str:
return self.version_str