Files
cibuildwheel/cibuildwheel/util.py
T

333 lines
10 KiB
Python
Raw Normal View History

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 ssl
2021-06-23 10:47:18 -04:00
import subprocess
import sys
2020-11-23 21:00:22 +00:00
import textwrap
2021-02-04 14:27:54 -05:00
import time
2020-11-23 21:00:22 +00:00
import urllib.request
from enum import Enum
2020-06-15 01:53:31 +02:00
from pathlib import Path
from time import sleep
2021-10-12 02:05:47 +01:00
from typing import Dict, Iterator, List, Optional
import bracex
2020-11-23 21:00:22 +00:00
import certifi
2021-10-15 11:30:16 -04:00
import tomli
2021-01-31 17:14:15 -05:00
from packaging.specifiers import SpecifierSet
from packaging.version import Version
2020-11-23 21:00:22 +00:00
2021-10-12 02:05:47 +01:00
from .typing import Literal, PathOrStr, PlatformName
2021-05-03 11:45:43 -04:00
resources_dir = Path(__file__).parent / "resources"
2021-01-31 17:14:15 -05:00
2021-01-09 15:40:40 -05:00
install_certifi_script = resources_dir / "install_certifi.py"
2021-06-23 10:47:18 -04:00
BuildFrontend = Literal["pip", "build"]
2021-09-19 00:19:28 -04:00
MANYLINUX_ARCHS = (
"x86_64",
"i686",
"pypy_x86_64",
"aarch64",
"ppc64le",
"s390x",
"pypy_aarch64",
"pypy_i686",
)
MUSLLINUX_ARCHS = (
"x86_64",
"i686",
"aarch64",
"ppc64le",
"s390x",
)
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
project's root.
2021-05-03 11:45:43 -04:00
"""
return command.format(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 []
2021-01-09 15:40:40 -05: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:
loaded_file = tomli.load(f)
2021-05-03 11:45:43 -04:00
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: List[str] = patterns.split()
patterns_list = itertools.chain.from_iterable(bracex.expand(p) for p in patterns_list) # type: ignore[assignment]
return any(fnmatch.fnmatch(string, pat) for pat in patterns_list)
2021-01-20 21:22:48 -05:00
class IdentifierSelector:
"""
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
numeric digits before the first dash. If a pre-release version X.Y is present,
you can filter it with prerelease="XY".
2021-01-20 21:22:48 -05:00
"""
2021-01-31 17:14:15 -05:00
# a pattern that skips prerelease versions, when include_prereleases is False.
2021-08-07 18:28:58 +01:00
PRERELEASE_SKIP = ""
2021-04-30 17:56:34 -04:00
def __init__(
self,
*,
build_config: str,
skip_config: str,
requires_python: Optional[SpecifierSet] = None,
prerelease_pythons: bool = False,
2021-04-30 17:56:34 -04:00
):
2021-10-12 02:05:47 +01:00
self.build_config = build_config
self.skip_config = skip_config
2021-01-31 17:14:15 -05:00
self.requires_python = requires_python
self.prerelease_pythons = prerelease_pythons
2017-04-11 22:57:42 +01:00
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]
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(
BuildSelector.PRERELEASE_SKIP, 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
def __repr__(self) -> str:
2021-10-12 02:05:47 +01:00
result = f"{self.__class__.__name__}(build_config={self.build_config!r}"
2021-10-12 02:05:47 +01:00
if self.skip_config:
result += f", skip_config={self.skip_config!r}"
if self.prerelease_pythons:
result += ", prerelease_pythons=True"
result += ")"
return result
2021-01-20 21:22:48 -05:00
class BuildSelector(IdentifierSelector):
pass
2021-01-31 17:14:15 -05:00
# Note that requires-python is not needed for TestSelector, as you can't test
# what you can't build.
2021-01-20 21:22:48 -05:00
class TestSelector(IdentifierSelector):
def __init__(self, *, skip_config: str):
super().__init__(build_config="*", skip_config=skip_config)
# Taken from https://stackoverflow.com/a/107717
2020-02-03 20:42:55 +01:00
class Unbuffered:
def __init__(self, stream): # type: ignore[no-untyped-def]
self.stream = stream
def write(self, data): # type: ignore[no-untyped-def]
self.stream.write(data)
self.stream.flush()
def writelines(self, data): # type: ignore[no-untyped-def]
2021-07-14 13:45:04 -04:00
self.stream.writelines(data)
self.stream.flush()
def __getattr__(self, attr): # type: ignore[no-untyped-def]
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:
response = urllib.request.urlopen(url, context=context)
2019-11-12 23:51:27 +00:00
except Exception:
if i == repeat_num - 1:
raise
sleep(3)
continue
break
try:
2020-06-15 01:53:31 +02:00
dest.write_bytes(response.read())
finally:
response.close()
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
2021-05-03 11:45:43 -04:00
def with_defaults() -> "DependencyConstraints":
return DependencyConstraints(base_file_path=resources_dir / "constraints.txt")
2020-06-15 01:53:31 +02:00
def get_for_python_version(self, version: str) -> 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
2021-05-03 11:45:43 -04:00
specific_stem = self.base_file_path.stem + f"-python{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
class NonPlatformWheelError(Exception):
def __init__(self) -> None:
2021-04-30 17:56:34 -04:00
message = textwrap.dedent(
2021-05-03 11:45:43 -04:00
"""
cibuildwheel: Build failed because a pure Python wheel was generated.
If you intend to build a pure-Python wheel, you don't need cibuildwheel - use
`pip wheel -w DEST_DIR .` instead.
If you expected a platform wheel, check your project configuration, or run
cibuildwheel with CIBW_BUILD_VERBOSITY=1 to view build logs.
2021-05-03 11:45:43 -04:00
"""
2021-04-30 17:56:34 -04:00
)
super().__init__(message)
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"
other = "other"
2020-11-23 21:00:22 +00:00
def detect_ci_provider() -> Optional[CIProvider]:
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
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
@contextlib.contextmanager
def print_new_wheels(msg: str, output_dir: Path) -> Iterator[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 = final_contents - existing_contents
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}" for f in new_contents), sep="\n")
2021-06-23 10:47:18 -04:00
def get_pip_version(env: Dict[str, str]) -> str:
# we use shell=True here for windows, even though we don't need a shell due to a bug
# https://bugs.python.org/issue8557
shell = sys.platform.startswith("win")
versions_output_text = subprocess.check_output(
["python", "-m", "pip", "freeze", "--all"], universal_newlines=True, shell=shell, env=env
)
(pip_version,) = (
version[5:]
for version in versions_output_text.strip().splitlines()
if version.startswith("pip==")
)
return pip_version