fix: do not use uv on PyPy 3.8 uv 0.2.25+ does not work with PyPy 3.8 Given PyPy 3.8 is EOL, just disable uv for this config rather than ask for it to be supported again in uv.
594 lines
23 KiB
Python
594 lines
23 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import platform as platform_module
|
|
import shutil
|
|
import subprocess
|
|
import textwrap
|
|
from collections.abc import MutableMapping, Sequence, Set
|
|
from dataclasses import dataclass
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
|
|
from filelock import FileLock
|
|
from packaging.version import Version
|
|
|
|
from . import errors
|
|
from ._compat.typing import assert_never
|
|
from .architecture import Architecture
|
|
from .environment import ParsedEnvironment
|
|
from .logger import log
|
|
from .options import Options
|
|
from .typing import PathOrStr
|
|
from .util import (
|
|
CIBW_CACHE_PATH,
|
|
BuildFrontendConfig,
|
|
BuildFrontendName,
|
|
BuildSelector,
|
|
call,
|
|
combine_constraints,
|
|
download,
|
|
extract_zip,
|
|
find_compatible_wheel,
|
|
find_uv,
|
|
get_build_verbosity_extra_flags,
|
|
get_pip_version,
|
|
move_file,
|
|
prepare_command,
|
|
read_python_configs,
|
|
shell,
|
|
split_config_settings,
|
|
test_fail_cwd_file,
|
|
unwrap,
|
|
virtualenv,
|
|
)
|
|
|
|
|
|
def get_nuget_args(
|
|
version: str, arch: str, free_threaded: bool, output_directory: Path
|
|
) -> list[str]:
|
|
package_name = {
|
|
"32": "pythonx86",
|
|
"64": "python",
|
|
"ARM64": "pythonarm64",
|
|
# Aliases for platform.machine() return values
|
|
"x86": "pythonx86",
|
|
"AMD64": "python",
|
|
}[arch]
|
|
if free_threaded:
|
|
package_name = f"{package_name}-freethreaded"
|
|
return [
|
|
package_name,
|
|
"-Version",
|
|
version,
|
|
"-FallbackSource",
|
|
"https://api.nuget.org/v3/index.json",
|
|
"-OutputDirectory",
|
|
str(output_directory),
|
|
]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PythonConfiguration:
|
|
version: str
|
|
arch: str
|
|
identifier: str
|
|
url: str | None = None
|
|
|
|
|
|
def get_python_configurations(
|
|
build_selector: BuildSelector,
|
|
architectures: Set[Architecture],
|
|
) -> list[PythonConfiguration]:
|
|
full_python_configs = read_python_configs("windows")
|
|
|
|
python_configurations = [PythonConfiguration(**item) for item in full_python_configs]
|
|
|
|
map_arch = {"32": Architecture.x86, "64": Architecture.AMD64, "ARM64": Architecture.ARM64}
|
|
|
|
# skip builds as required
|
|
python_configurations = [
|
|
c
|
|
for c in python_configurations
|
|
if build_selector(c.identifier) and map_arch[c.arch] in architectures
|
|
]
|
|
|
|
return python_configurations
|
|
|
|
|
|
@lru_cache(maxsize=None)
|
|
def _ensure_nuget() -> Path:
|
|
nuget = CIBW_CACHE_PATH / "nuget.exe"
|
|
with FileLock(str(nuget) + ".lock"):
|
|
if not nuget.exists():
|
|
download("https://dist.nuget.org/win-x86-commandline/latest/nuget.exe", nuget)
|
|
return nuget
|
|
|
|
|
|
def install_cpython(configuration: PythonConfiguration, arch: str | None = None) -> Path:
|
|
version = configuration.version
|
|
free_threaded = "t-" in configuration.identifier
|
|
if arch is None:
|
|
arch = configuration.arch
|
|
base_output_dir = CIBW_CACHE_PATH / "nuget-cpython"
|
|
nuget_args = get_nuget_args(version, arch, free_threaded, base_output_dir)
|
|
installation_path = base_output_dir / (nuget_args[0] + "." + version) / "tools"
|
|
free_threaded_str = "-freethreaded" if free_threaded else ""
|
|
with FileLock(str(base_output_dir) + f"-{version}{free_threaded_str}-{arch}.lock"):
|
|
if not installation_path.exists():
|
|
nuget = _ensure_nuget()
|
|
call(nuget, "install", *nuget_args)
|
|
return installation_path / "python.exe"
|
|
|
|
|
|
def install_pypy(tmp: Path, arch: str, url: str) -> Path:
|
|
assert arch == "64"
|
|
assert "win64" in url
|
|
# Inside the PyPy zip file is a directory with the same name
|
|
zip_filename = url.rsplit("/", 1)[-1]
|
|
extension = ".zip"
|
|
assert zip_filename.endswith(extension)
|
|
installation_path = CIBW_CACHE_PATH / zip_filename[: -len(extension)]
|
|
with FileLock(str(installation_path) + ".lock"):
|
|
if not installation_path.exists():
|
|
pypy_zip = tmp / zip_filename
|
|
download(url, pypy_zip)
|
|
# Extract to the parent directory because the zip file still contains a directory
|
|
extract_zip(pypy_zip, installation_path.parent)
|
|
return installation_path / "python.exe"
|
|
|
|
|
|
def setup_setuptools_cross_compile(
|
|
tmp: Path,
|
|
python_configuration: PythonConfiguration,
|
|
python_libs_base: Path,
|
|
env: MutableMapping[str, str],
|
|
) -> None:
|
|
distutils_cfg = tmp / "extra-setup.cfg"
|
|
env["DIST_EXTRA_CONFIG"] = str(distutils_cfg)
|
|
log.notice(f"Setting DIST_EXTRA_CONFIG={distutils_cfg} for cross-compilation")
|
|
|
|
# Ensure our additional import libraries are made available, and explicitly
|
|
# set the platform name
|
|
map_plat = {"32": "win32", "64": "win-amd64", "ARM64": "win-arm64"}
|
|
plat_name = map_plat[python_configuration.arch]
|
|
|
|
# Set environment variable so that setuptools._distutils.get_platform()
|
|
# identifies the target, not the host
|
|
vscmd_arg_tgt_arch = {"32": "x86", "64": "x64", "ARM64": "arm64"}
|
|
current_tgt_arch = vscmd_arg_tgt_arch[python_configuration.arch]
|
|
if (env.get("VSCMD_ARG_TGT_ARCH") or current_tgt_arch) != current_tgt_arch:
|
|
msg = f"VSCMD_ARG_TGT_ARCH must be set to {current_tgt_arch!r}, got {env['VSCMD_ARG_TGT_ARCH']!r}. If you're setting up MSVC yourself (e.g. using vcvarsall.bat or msvc-dev-cmd), make sure to target the right architecture. Alternatively, run cibuildwheel without configuring MSVC, and let the build backend handle it."
|
|
raise errors.FatalError(msg)
|
|
env["VSCMD_ARG_TGT_ARCH"] = current_tgt_arch
|
|
|
|
# (This file must be default/locale encoding, so we can't pass 'encoding')
|
|
distutils_cfg.write_text(
|
|
textwrap.dedent(
|
|
f"""\
|
|
[build]
|
|
plat_name={plat_name}
|
|
[build_ext]
|
|
library_dirs={python_libs_base}
|
|
plat_name={plat_name}
|
|
[bdist_wheel]
|
|
plat_name={plat_name}
|
|
"""
|
|
)
|
|
)
|
|
|
|
# setuptools builds require explicit override of PYD extension
|
|
# This is because it always gets the extension from the running
|
|
# interpreter, and has no logic to construct it. Currently, CPython's
|
|
# extensions follow our identifiers, but if they ever diverge in the
|
|
# future, we will need to store new data
|
|
log.notice(
|
|
f"Setting SETUPTOOLS_EXT_SUFFIX=.{python_configuration.identifier}.pyd for cross-compilation"
|
|
)
|
|
env["SETUPTOOLS_EXT_SUFFIX"] = f".{python_configuration.identifier}.pyd"
|
|
|
|
# Cross-compilation requires fixes that only exist in setuptools's copy of
|
|
# distutils, so ensure that it is activated
|
|
# Since not all projects can handle the newer distutils, display a warning
|
|
# to help them figure out what may have gone wrong if this breaks for them
|
|
log.notice("Setting SETUPTOOLS_USE_DISTUTILS=local as it is required for cross-compilation")
|
|
env["SETUPTOOLS_USE_DISTUTILS"] = "local"
|
|
|
|
|
|
# These cross-compile setup functions have the same signature by design
|
|
def setup_rust_cross_compile(
|
|
tmp: Path, # noqa: ARG001
|
|
python_configuration: PythonConfiguration,
|
|
python_libs_base: Path, # noqa: ARG001
|
|
env: MutableMapping[str, str],
|
|
) -> None:
|
|
# Assume that MSVC will be used, because we already know that we are
|
|
# cross-compiling. MinGW users can set CARGO_BUILD_TARGET themselves
|
|
# and we will respect the existing value.
|
|
cargo_target = {
|
|
"64": "x86_64-pc-windows-msvc",
|
|
"32": "i686-pc-windows-msvc",
|
|
"ARM64": "aarch64-pc-windows-msvc",
|
|
}.get(python_configuration.arch)
|
|
|
|
# CARGO_BUILD_TARGET is the variable used by Cargo and setuptools_rust
|
|
if env.get("CARGO_BUILD_TARGET"):
|
|
if env["CARGO_BUILD_TARGET"] != cargo_target:
|
|
log.notice("Not overriding CARGO_BUILD_TARGET as it has already been set")
|
|
# No message if it was set to what we were planning to set it to
|
|
elif cargo_target:
|
|
log.notice(f"Setting CARGO_BUILD_TARGET={cargo_target} for cross-compilation")
|
|
env["CARGO_BUILD_TARGET"] = cargo_target
|
|
else:
|
|
log.warning(
|
|
f"Unable to configure Rust cross-compilation for architecture {python_configuration.arch}"
|
|
)
|
|
|
|
|
|
def setup_python(
|
|
tmp: Path,
|
|
python_configuration: PythonConfiguration,
|
|
dependency_constraint_flags: Sequence[PathOrStr],
|
|
environment: ParsedEnvironment,
|
|
build_frontend: BuildFrontendName,
|
|
) -> tuple[Path, dict[str, str]]:
|
|
tmp.mkdir()
|
|
implementation_id = python_configuration.identifier.split("-")[0]
|
|
python_libs_base = None
|
|
log.step(f"Installing Python {implementation_id}...")
|
|
if implementation_id.startswith("cp"):
|
|
native_arch = platform_module.machine()
|
|
base_python = install_cpython(python_configuration)
|
|
if python_configuration.arch == "ARM64" != native_arch:
|
|
# To cross-compile for ARM64, we need a native CPython to run the
|
|
# build, and a copy of the ARM64 import libraries ('.\libs\*.lib')
|
|
# for any extension modules.
|
|
python_libs_base = base_python.parent / "libs"
|
|
log.step(f"Installing native Python {native_arch} for cross-compilation...")
|
|
base_python = install_cpython(python_configuration, arch=native_arch)
|
|
elif implementation_id.startswith("pp"):
|
|
assert python_configuration.url is not None
|
|
base_python = install_pypy(tmp, python_configuration.arch, python_configuration.url)
|
|
else:
|
|
msg = "Unknown Python implementation"
|
|
raise ValueError(msg)
|
|
assert base_python.exists()
|
|
|
|
use_uv = (
|
|
build_frontend == "build[uv]"
|
|
and Version(python_configuration.version) >= Version("3.8")
|
|
and not python_configuration.identifier.startswith("pp38-")
|
|
)
|
|
uv_path = find_uv()
|
|
|
|
log.step("Setting up build environment...")
|
|
venv_path = tmp / "venv"
|
|
env = virtualenv(
|
|
python_configuration.version,
|
|
base_python,
|
|
venv_path,
|
|
dependency_constraint_flags,
|
|
use_uv=use_uv,
|
|
)
|
|
|
|
# set up environment variables for run_with_env
|
|
env["PYTHON_VERSION"] = python_configuration.version
|
|
env["PYTHON_ARCH"] = python_configuration.arch
|
|
env["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
|
|
|
|
# upgrade pip to the version matching our constraints
|
|
# if necessary, reinstall it to ensure that it's available on PATH as 'pip.exe'
|
|
if not use_uv:
|
|
call(
|
|
"python",
|
|
"-m",
|
|
"pip",
|
|
"install",
|
|
"--upgrade",
|
|
"pip",
|
|
*dependency_constraint_flags,
|
|
env=env,
|
|
cwd=venv_path,
|
|
)
|
|
|
|
# update env with results from CIBW_ENVIRONMENT
|
|
env = environment.as_dictionary(prev_environment=env)
|
|
|
|
# check what Python version we're on
|
|
call("where", "python", env=env)
|
|
call("python", "--version", env=env)
|
|
call("python", "-c", "\"import struct; print(struct.calcsize('P') * 8)\"", env=env)
|
|
where_python = call("where", "python", env=env, capture_stdout=True).splitlines()[0].strip()
|
|
if where_python != str(venv_path / "Scripts" / "python.exe"):
|
|
msg = "python available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert python above it."
|
|
raise errors.FatalError(msg)
|
|
|
|
# check what pip version we're on
|
|
if not use_uv:
|
|
assert (venv_path / "Scripts" / "pip.exe").exists()
|
|
where_pip = call("where", "pip", env=env, capture_stdout=True).splitlines()[0].strip()
|
|
if where_pip.strip() != str(venv_path / "Scripts" / "pip.exe"):
|
|
msg = "pip available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert pip above it."
|
|
raise errors.FatalError(msg)
|
|
|
|
call("pip", "--version", env=env)
|
|
|
|
log.step("Installing build tools...")
|
|
if build_frontend == "build":
|
|
call(
|
|
"pip",
|
|
"install",
|
|
"--upgrade",
|
|
"build[virtualenv]",
|
|
*dependency_constraint_flags,
|
|
env=env,
|
|
)
|
|
elif build_frontend == "build[uv]":
|
|
assert uv_path is not None
|
|
call(
|
|
uv_path,
|
|
"pip",
|
|
"install",
|
|
"--upgrade",
|
|
"build[virtualenv]",
|
|
*dependency_constraint_flags,
|
|
env=env,
|
|
)
|
|
|
|
if python_libs_base:
|
|
# Set up the environment for various backends to enable cross-compilation
|
|
setup_setuptools_cross_compile(tmp, python_configuration, python_libs_base, env)
|
|
setup_rust_cross_compile(tmp, python_configuration, python_libs_base, env)
|
|
|
|
return base_python, env
|
|
|
|
|
|
def build(options: Options, tmp_path: Path) -> None:
|
|
python_configurations = get_python_configurations(
|
|
options.globals.build_selector, options.globals.architectures
|
|
)
|
|
|
|
if not python_configurations:
|
|
return
|
|
|
|
try:
|
|
before_all_options_identifier = python_configurations[0].identifier
|
|
before_all_options = options.build_options(before_all_options_identifier)
|
|
|
|
if before_all_options.before_all:
|
|
log.step("Running before_all...")
|
|
env = before_all_options.environment.as_dictionary(prev_environment=os.environ)
|
|
before_all_prepared = prepare_command(
|
|
before_all_options.before_all, project=".", package=options.globals.package_dir
|
|
)
|
|
shell(before_all_prepared, env=env)
|
|
|
|
built_wheels: list[Path] = []
|
|
|
|
for config in python_configurations:
|
|
build_options = options.build_options(config.identifier)
|
|
build_frontend = build_options.build_frontend or BuildFrontendConfig("pip")
|
|
use_uv = (
|
|
build_frontend.name == "build[uv]"
|
|
and Version(config.version) >= Version("3.8")
|
|
and not config.identifier.startswith("pp38-")
|
|
)
|
|
log.build_start(config.identifier)
|
|
|
|
identifier_tmp_dir = tmp_path / config.identifier
|
|
identifier_tmp_dir.mkdir()
|
|
built_wheel_dir = identifier_tmp_dir / "built_wheel"
|
|
repaired_wheel_dir = identifier_tmp_dir / "repaired_wheel"
|
|
|
|
dependency_constraint_flags: Sequence[PathOrStr] = []
|
|
if build_options.dependency_constraints:
|
|
dependency_constraint_flags = [
|
|
"-c",
|
|
build_options.dependency_constraints.get_for_python_version(config.version),
|
|
]
|
|
|
|
# install Python
|
|
base_python, env = setup_python(
|
|
identifier_tmp_dir / "build",
|
|
config,
|
|
dependency_constraint_flags,
|
|
build_options.environment,
|
|
build_frontend.name,
|
|
)
|
|
if not use_uv:
|
|
pip_version = get_pip_version(env)
|
|
|
|
compatible_wheel = find_compatible_wheel(built_wheels, config.identifier)
|
|
if compatible_wheel:
|
|
log.step_end()
|
|
print(
|
|
f"\nFound previously built wheel {compatible_wheel.name}, that's compatible with {config.identifier}. Skipping build step..."
|
|
)
|
|
repaired_wheel = compatible_wheel
|
|
else:
|
|
# run the before_build command
|
|
if build_options.before_build:
|
|
log.step("Running before_build...")
|
|
before_build_prepared = prepare_command(
|
|
build_options.before_build,
|
|
project=".",
|
|
package=options.globals.package_dir,
|
|
)
|
|
shell(before_build_prepared, env=env)
|
|
|
|
log.step("Building wheel...")
|
|
built_wheel_dir.mkdir()
|
|
|
|
extra_flags = split_config_settings(
|
|
build_options.config_settings, build_frontend.name
|
|
)
|
|
extra_flags += build_frontend.args
|
|
|
|
build_env = env.copy()
|
|
if not use_uv:
|
|
build_env["VIRTUALENV_PIP"] = pip_version
|
|
|
|
if build_options.dependency_constraints:
|
|
constraints_path = build_options.dependency_constraints.get_for_python_version(
|
|
config.version
|
|
)
|
|
combine_constraints(build_env, constraints_path, identifier_tmp_dir)
|
|
|
|
if build_frontend.name == "pip":
|
|
extra_flags += get_build_verbosity_extra_flags(build_options.build_verbosity)
|
|
# Path.resolve() is needed. Without it pip wheel may try to fetch package from pypi.org
|
|
# see https://github.com/pypa/cibuildwheel/pull/369
|
|
call(
|
|
"python",
|
|
"-m",
|
|
"pip",
|
|
"wheel",
|
|
options.globals.package_dir.resolve(),
|
|
f"--wheel-dir={built_wheel_dir}",
|
|
"--no-deps",
|
|
*extra_flags,
|
|
env=build_env,
|
|
)
|
|
elif build_frontend.name == "build" or build_frontend.name == "build[uv]":
|
|
if not 0 <= build_options.build_verbosity < 2:
|
|
msg = f"build_verbosity {build_options.build_verbosity} is not supported for build frontend. Ignoring."
|
|
log.warning(msg)
|
|
if use_uv and "--no-isolation" not in extra_flags and "-n" not in extra_flags:
|
|
extra_flags.append("--installer=uv")
|
|
call(
|
|
"python",
|
|
"-m",
|
|
"build",
|
|
build_options.package_dir,
|
|
"--wheel",
|
|
f"--outdir={built_wheel_dir}",
|
|
*extra_flags,
|
|
env=build_env,
|
|
)
|
|
else:
|
|
assert_never(build_frontend)
|
|
|
|
built_wheel = next(built_wheel_dir.glob("*.whl"))
|
|
|
|
# repair the wheel
|
|
repaired_wheel_dir.mkdir()
|
|
|
|
if built_wheel.name.endswith("none-any.whl"):
|
|
raise errors.NonPlatformWheelError()
|
|
|
|
if build_options.repair_command:
|
|
log.step("Repairing wheel...")
|
|
repair_command_prepared = prepare_command(
|
|
build_options.repair_command,
|
|
wheel=built_wheel,
|
|
dest_dir=repaired_wheel_dir,
|
|
)
|
|
shell(repair_command_prepared, env=env)
|
|
else:
|
|
shutil.move(str(built_wheel), repaired_wheel_dir)
|
|
|
|
repaired_wheel = next(repaired_wheel_dir.glob("*.whl"))
|
|
|
|
if repaired_wheel.name in {wheel.name for wheel in built_wheels}:
|
|
raise errors.AlreadyBuiltWheelError(repaired_wheel.name)
|
|
|
|
test_selected = options.globals.test_selector(config.identifier)
|
|
if test_selected and config.arch == "ARM64" != platform_module.machine():
|
|
log.warning(
|
|
unwrap(
|
|
"""
|
|
While arm64 wheels can be built on other platforms, they cannot
|
|
be tested. An arm64 runner is required. To silence this warning,
|
|
set `CIBW_TEST_SKIP: "*-win_arm64"`.
|
|
"""
|
|
)
|
|
)
|
|
# skip this test
|
|
elif test_selected and build_options.test_command:
|
|
log.step("Testing wheel...")
|
|
# set up a virtual environment to install and test from, to make sure
|
|
# there are no dependencies that were pulled in at build time.
|
|
if not use_uv:
|
|
call("pip", "install", "virtualenv", *dependency_constraint_flags, env=env)
|
|
|
|
venv_dir = identifier_tmp_dir / "venv-test"
|
|
|
|
if use_uv:
|
|
call("uv", "venv", venv_dir, f"--python={base_python}", env=env)
|
|
else:
|
|
# Use pip version from the initial env to ensure determinism
|
|
venv_args = ["--no-periodic-update", f"--pip={pip_version}"]
|
|
# In Python<3.12, setuptools & wheel are installed as well, use virtualenv embedded ones
|
|
if Version(config.version) < Version("3.12"):
|
|
venv_args.extend(("--setuptools=embed", "--wheel=embed"))
|
|
call("python", "-m", "virtualenv", *venv_args, venv_dir, env=env)
|
|
|
|
virtualenv_env = env.copy()
|
|
virtualenv_env["PATH"] = os.pathsep.join(
|
|
[
|
|
str(venv_dir / "Scripts"),
|
|
virtualenv_env["PATH"],
|
|
]
|
|
)
|
|
virtualenv_env["VIRTUAL_ENV"] = str(venv_dir)
|
|
|
|
# check that we are using the Python from the virtual environment
|
|
call("where", "python", env=virtualenv_env)
|
|
|
|
if build_options.before_test:
|
|
before_test_prepared = prepare_command(
|
|
build_options.before_test,
|
|
project=".",
|
|
package=build_options.package_dir,
|
|
)
|
|
shell(before_test_prepared, env=virtualenv_env)
|
|
|
|
pip = ["uv", "pip"] if use_uv else ["pip"]
|
|
|
|
# install the wheel
|
|
call(
|
|
*pip,
|
|
"install",
|
|
str(repaired_wheel) + build_options.test_extras,
|
|
env=virtualenv_env,
|
|
)
|
|
|
|
# test the wheel
|
|
if build_options.test_requires:
|
|
call(*pip, "install", *build_options.test_requires, env=virtualenv_env)
|
|
|
|
# run the tests from a temp dir, with an absolute path in the command
|
|
# (this ensures that Python runs the tests against the installed wheel
|
|
# and not the repo code)
|
|
test_command_prepared = prepare_command(
|
|
build_options.test_command,
|
|
project=Path(".").resolve(),
|
|
package=options.globals.package_dir.resolve(),
|
|
wheel=repaired_wheel,
|
|
)
|
|
test_cwd = identifier_tmp_dir / "test_cwd"
|
|
test_cwd.mkdir()
|
|
(test_cwd / "test_fail.py").write_text(test_fail_cwd_file.read_text())
|
|
|
|
shell(test_command_prepared, cwd=test_cwd, env=virtualenv_env)
|
|
|
|
# we're all done here; move it to output (remove if already exists)
|
|
if compatible_wheel is None:
|
|
output_wheel = build_options.output_dir.joinpath(repaired_wheel.name)
|
|
moved_wheel = move_file(repaired_wheel, output_wheel)
|
|
if moved_wheel != output_wheel.resolve():
|
|
log.warning(
|
|
"{repaired_wheel} was moved to {moved_wheel} instead of {output_wheel}"
|
|
)
|
|
built_wheels.append(output_wheel)
|
|
|
|
# clean up
|
|
# (we ignore errors because occasionally Windows fails to unlink a file and we
|
|
# don't want to abort a build because of that)
|
|
shutil.rmtree(identifier_tmp_dir, ignore_errors=True)
|
|
|
|
log.build_end()
|
|
except subprocess.CalledProcessError as error:
|
|
msg = f"Command {error.cmd} failed with code {error.returncode}. {error.stdout or ''}"
|
|
raise errors.FatalError(msg) from error
|