Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ concurrency:

jobs:
lint:
name: Linters (mypy, flake8, etc.)
name: Linters (mypy, ruff, etc.)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ repos:
additional_dependencies: &mypy-dependencies
- bracex
- dependency-groups>=1.2
- humanize
- nox>=2025.2.9
- orjson
- packaging
Expand Down
41 changes: 2 additions & 39 deletions cibuildwheel/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@
import sys
import tarfile
import textwrap
import time
import traceback
import typing
from collections.abc import Generator, Iterable, Sequence
from collections.abc import Iterable, Sequence
from pathlib import Path
from tempfile import mkdtemp
from typing import Any, Literal, TextIO
Expand Down Expand Up @@ -286,42 +285,6 @@ def _compute_platform(args: CommandLineArguments) -> PlatformName:
return _compute_platform_auto()


@contextlib.contextmanager
def print_new_wheels(msg: str, output_dir: Path) -> Generator[None, None, None]:
"""
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.
"""

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
]

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)
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",
)


def build_in_directory(args: CommandLineArguments) -> None:
platform: PlatformName = _compute_platform(args)
if platform == "pyodide" and sys.platform == "win32":
Expand Down Expand Up @@ -382,7 +345,7 @@ def build_in_directory(args: CommandLineArguments) -> None:

tmp_path = Path(mkdtemp(prefix="cibw-run-")).resolve(strict=True)
try:
with print_new_wheels("\n{n} wheels produced in {m:.0f} minutes:", output_dir):
with log.print_summary():
platform_module.build(options, tmp_path)
finally:
# avoid https://github.com/python/cpython/issues/86962 by performing
Expand Down
18 changes: 18 additions & 0 deletions cibuildwheel/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,21 @@ def __init__(self) -> None:
)
super().__init__(message)
self.return_code = 8


class RepairStepProducedMultipleWheelsError(FatalError):
def __init__(self, wheels: list[str]) -> None:
message = textwrap.dedent(
f"""
Build failed because the repair step completed successfully but
produced multiple wheels: {wheels}

Your `repair-wheel-command` is expected to place one repaired
wheel in the {{dest_dir}} directory. See the documentation for
example configurations:

https://cibuildwheel.pypa.io/en/stable/options/#repair-wheel-command
"""
)
super().__init__(message)
self.return_code = 8
117 changes: 95 additions & 22 deletions cibuildwheel/logger.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import codecs
import contextlib
import dataclasses
import io
import os
import re
import sys
import time
from typing import IO, AnyStr, Final
from collections.abc import Generator
from pathlib import Path
from typing import IO, AnyStr, Final, Literal

import humanize

from .ci import CIProvider, detect_ci_provider

Expand Down Expand Up @@ -69,14 +76,41 @@ def __init__(self, *, unicode: bool) -> None:
self.error = "✕" if unicode else "failed"


@dataclasses.dataclass(kw_only=True, frozen=True)
class BuildInfo:
identifier: str
filename: Path | None
duration: float

@staticmethod
def table_header() -> str:
return "| Identifier | Size | Time | Wheel |\n| ---------- | ---- | ---- | ----- |\n"

def table_line(self) -> str:
duration = humanize.naturaldelta(self.duration)
if self.filename:
size = humanize.naturalsize(self.filename.stat().st_size)
return f"| `{self.identifier}` | {size} | {duration} | `{self.filename.name}` |\n"
return f"| `{self.identifier}` | --- | {duration} | *test only* |\n"

def __str__(self) -> str:
duration = humanize.naturaldelta(self.duration)
if self.filename:
size = humanize.naturalsize(self.filename.stat().st_size)
return f"{self.identifier}: {self.filename.name} {size} in {duration}"
return f"{self.identifier}: {duration} (test only)"


class Logger:
fold_mode: str
fold_mode: Literal["azure", "github", "travis", "disabled"]
colors_enabled: bool
unicode_enabled: bool
active_build_identifier: str | None = None
build_start_time: float | None = None
step_start_time: float | None = None
active_fold_group_name: str | None = None
summary: list[BuildInfo]
summary_mode: Literal["github", "generic"]

def __init__(self) -> None:
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
Expand All @@ -88,25 +122,33 @@ def __init__(self) -> None:

ci_provider = detect_ci_provider()

if ci_provider == CIProvider.azure_pipelines:
self.fold_mode = "azure"
self.colors_enabled = True
match ci_provider:
case CIProvider.azure_pipelines:
self.fold_mode = "azure"
self.colors_enabled = True
self.summary_mode = "generic"

elif ci_provider == CIProvider.github_actions:
self.fold_mode = "github"
self.colors_enabled = True
case CIProvider.github_actions:
self.fold_mode = "github"
self.colors_enabled = True
self.summary_mode = "github"

elif ci_provider == CIProvider.travis_ci:
self.fold_mode = "travis"
self.colors_enabled = True
case CIProvider.travis_ci:
self.fold_mode = "travis"
self.colors_enabled = True
self.summary_mode = "generic"

elif ci_provider == CIProvider.appveyor:
self.fold_mode = "disabled"
self.colors_enabled = True
case CIProvider.appveyor:
self.fold_mode = "disabled"
self.colors_enabled = True
self.summary_mode = "generic"

else:
self.fold_mode = "disabled"
self.colors_enabled = file_supports_color(sys.stdout)
case _:
self.fold_mode = "disabled"
self.colors_enabled = file_supports_color(sys.stdout)
self.summary_mode = "generic"

self.summary = []

def build_start(self, identifier: str) -> None:
self.step_end()
Expand All @@ -120,19 +162,22 @@ def build_start(self, identifier: str) -> None:
self.build_start_time = time.time()
self.active_build_identifier = identifier

def build_end(self) -> None:
def build_end(self, filename: Path | None) -> None:
assert self.build_start_time is not None
assert self.active_build_identifier is not None
self.step_end()

c = self.colors
s = self.symbols
duration = time.time() - self.build_start_time
duration_str = humanize.naturaldelta(duration, minimum_unit="milliseconds")

print()
print(
f"{c.green}{s.done} {c.end}{self.active_build_identifier} finished in {duration:.2f}s"
print(f"{c.green}{s.done} {c.end}{self.active_build_identifier} finished in {duration_str}")
self.summary.append(
BuildInfo(identifier=self.active_build_identifier, filename=filename, duration=duration)
)

self.build_start_time = None
self.active_build_identifier = None

Expand All @@ -147,10 +192,11 @@ def step_end(self, success: bool = True) -> None:
c = self.colors
s = self.symbols
duration = time.time() - self.step_start_time
duration_str = humanize.naturaldelta(duration)
if success:
print(f"{c.green}{s.done} {c.end}{duration:.2f}s".rjust(78))
print(f"{c.green}{s.done} {c.end}{duration_str}".rjust(78))
else:
print(f"{c.red}{s.error} {c.end}{duration:.2f}s".rjust(78))
print(f"{c.red}{s.error} {c.end}{duration_str}".rjust(78))

self.step_start_time = None

Expand Down Expand Up @@ -183,6 +229,33 @@ def error(self, error: BaseException | str) -> None:
c = self.colors
print(f"cibuildwheel: {c.bright_red}error{c.end}: {error}\n", file=sys.stderr)

@contextlib.contextmanager
def print_summary(self) -> Generator[None, None, None]:
start = time.time()
yield
if self.summary_mode == "github":
string_io = io.StringIO()
string_io.write("## 🎡 Wheels\n\n")
string_io.write(BuildInfo.table_header())

for build_info in self.summary:
string_io.write(build_info.table_line())
string_io.write("\n")
Path(os.environ["GITHUB_STEP_SUMMARY"]).write_text(
string_io.getvalue(), encoding="utf-8"
)

n = len(self.summary)
s = "s" if n > 1 else ""
n_str = humanize.apnumber(n).title()
duration = humanize.naturaldelta(time.time() - start)
self._start_fold_group(f"{n_str} wheel{s} produced in {duration}")
for build_info in self.summary:
print(" ", build_info)
self._end_fold_group()

self.summary = []

@property
def step_active(self) -> bool:
return self.step_start_time is not None
Expand Down
3 changes: 2 additions & 1 deletion cibuildwheel/platforms/ios.py
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,7 @@ def build(options: Options, tmp_path: Path) -> None:
log.step_end()

# We're all done here; move it to output (overwrite existing)
output_wheel: Path | None = None
if compatible_wheel is None:
output_wheel = build_options.output_dir.joinpath(built_wheel.name)
moved_wheel = move_file(built_wheel, output_wheel)
Expand All @@ -683,7 +684,7 @@ def build(options: Options, tmp_path: Path) -> None:
# Clean up
shutil.rmtree(identifier_tmp_dir)

log.build_end()
log.build_end(output_wheel)
except subprocess.CalledProcessError as error:
msg = f"Command {error.cmd} failed with code {error.returncode}. {error.stdout or ''}"
raise errors.FatalError(msg) from error
40 changes: 18 additions & 22 deletions cibuildwheel/platforms/linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ def build_in_container(
print(
f"\nFound previously built wheel {compatible_wheel.name}, that's compatible with {config.identifier}. Skipping build step..."
)
repaired_wheels = [compatible_wheel]
repaired_wheel = compatible_wheel
else:
if build_options.before_build:
log.step("Running before_build...")
Expand Down Expand Up @@ -325,14 +325,16 @@ def build_in_container(
else:
container.call(["mv", built_wheel, repaired_wheel_dir])

repaired_wheels = container.glob(repaired_wheel_dir, "*.whl")
match container.glob(repaired_wheel_dir, "*.whl"):
case []:
raise errors.RepairStepProducedNoWheelError()
case [repaired_wheel]:
pass
case too_many:
raise errors.RepairStepProducedMultipleWheelsError([p.name for p in too_many])

if not repaired_wheels:
raise errors.RepairStepProducedNoWheelError()

for repaired_wheel in repaired_wheels:
if repaired_wheel.name in {wheel.name for wheel in built_wheels}:
raise errors.AlreadyBuiltWheelError(repaired_wheel.name)
if repaired_wheel.name in {wheel.name for wheel in built_wheels}:
raise errors.AlreadyBuiltWheelError(repaired_wheel.name)

if build_options.test_command and build_options.test_selector(config.identifier):
log.step("Testing wheel...")
Expand Down Expand Up @@ -374,14 +376,8 @@ def build_in_container(
container.call(["sh", "-c", before_test_prepared], env=virtualenv_env)

# Install the wheel we just built
# Note: If auditwheel produced two wheels, it's because the earlier produced wheel
# conforms to multiple manylinux standards. These multiple versions of the wheel are
# functionally the same, differing only in name, wheel metadata, and possibly include
# different external shared libraries. so it doesn't matter which one we run the tests on.
# Let's just pick the first one.
wheel_to_test = repaired_wheels[0]
container.call(
[*pip, "install", str(wheel_to_test) + build_options.test_extras],
[*pip, "install", str(repaired_wheel) + build_options.test_extras],
env=virtualenv_env,
)

Expand All @@ -394,7 +390,7 @@ def build_in_container(
build_options.test_command,
project=container_project_path,
package=container_package_dir,
wheel=wheel_to_test,
wheel=repaired_wheel,
)

test_cwd = testing_temp_dir / "test_cwd"
Expand All @@ -417,15 +413,15 @@ def build_in_container(
# clean up test environment
container.call(["rm", "-rf", testing_temp_dir])

# move repaired wheels to output
# move repaired wheel to output
output_wheel: Path | None = None
if compatible_wheel is None:
container.call(["mkdir", "-p", container_output_dir])
container.call(["mv", *repaired_wheels, container_output_dir])
built_wheels.extend(
container_output_dir / repaired_wheel.name for repaired_wheel in repaired_wheels
)
container.call(["mv", repaired_wheel, container_output_dir])
built_wheels.append(container_output_dir / repaired_wheel.name)
output_wheel = options.globals.output_dir / repaired_wheel.name

log.build_end()
log.build_end(output_wheel)

log.step("Copying wheels back to host...")
# copy the output back into the host
Expand Down
Loading
Loading