diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 3268898d4..0bdea9190 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -29,6 +29,7 @@ repos:
additional_dependencies: &mypy-dependencies
- bracex
- dependency-groups>=1.2
+ - humanize
- nox>=2025.2.9
- orjson
- packaging
diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py
index 934d80c4e..7585ca830 100644
--- a/cibuildwheel/__main__.py
+++ b/cibuildwheel/__main__.py
@@ -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
@@ -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":
@@ -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(options=options):
platform_module.build(options, tmp_path)
finally:
# avoid https://github.com/python/cpython/issues/86962 by performing
diff --git a/cibuildwheel/ci.py b/cibuildwheel/ci.py
index 03205b7a4..500d435eb 100644
--- a/cibuildwheel/ci.py
+++ b/cibuildwheel/ci.py
@@ -4,6 +4,8 @@
from .util.helpers import strtobool
+ANSI_CODE_REGEX = re.compile(r"(\033\[[0-9;]*m)")
+
class CIProvider(Enum):
# official support
@@ -46,7 +48,7 @@ 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 = ""
@@ -55,7 +57,7 @@ def fix_ansi_codes_for_github_actions(text: str) -> str:
output += "".join(ansi_codes) + line
# split the line at each ANSI code
- parts = ansi_code_regex.split(line)
+ 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
@@ -67,3 +69,11 @@ def fix_ansi_codes_for_github_actions(text: str) -> str:
ansi_codes.append(code)
return output
+
+
+def filter_ansi_codes(text: str, /) -> str:
+ """
+ Remove ANSI codes from text.
+ """
+
+ return ANSI_CODE_REGEX.sub("", text)
diff --git a/cibuildwheel/logger.py b/cibuildwheel/logger.py
index b2a9a3115..6ed3b761c 100644
--- a/cibuildwheel/logger.py
+++ b/cibuildwheel/logger.py
@@ -1,11 +1,24 @@
import codecs
+import contextlib
+import dataclasses
+import functools
+import hashlib
+import io
import os
import re
import sys
+import textwrap
import time
-from typing import IO, AnyStr, Final, Literal
+from collections.abc import Generator
+from pathlib import Path
+from typing import IO, TYPE_CHECKING, AnyStr, Final, Literal
-from .ci import CIProvider, detect_ci_provider
+import humanize
+
+from .ci import CIProvider, detect_ci_provider, filter_ansi_codes
+
+if TYPE_CHECKING:
+ from .options import Options
FoldPattern = tuple[str, str]
DEFAULT_FOLD_PATTERN: Final[FoldPattern] = ("{name}", "")
@@ -69,6 +82,33 @@ 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
+
+ @functools.cached_property
+ def size(self) -> str | None:
+ if self.filename is None:
+ return None
+ return humanize.naturalsize(self.filename.stat().st_size)
+
+ @functools.cached_property
+ def sha256(self) -> str | None:
+ if self.filename is None:
+ return None
+ with self.filename.open("rb") as f:
+ digest = hashlib.file_digest(f, "sha256")
+ return digest.hexdigest()
+
+ def __str__(self) -> str:
+ duration = humanize.naturaldelta(self.duration)
+ if self.filename:
+ return f"{self.identifier}: {self.filename.name} {self.size} in {duration}, SHA256={self.sha256}"
+ return f"{self.identifier}: {duration} (test only)"
+
+
class Logger:
fold_mode: Literal["azure", "github", "travis", "disabled"]
colors_enabled: bool
@@ -77,6 +117,7 @@ class Logger:
build_start_time: float | None = None
step_start_time: float | None = None
active_fold_group_name: str | None = None
+ summary: list[BuildInfo]
def __init__(self) -> None:
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
@@ -88,25 +129,28 @@ 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
- 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
- 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
- elif ci_provider == CIProvider.appveyor:
- self.fold_mode = "disabled"
- self.colors_enabled = True
+ case CIProvider.appveyor:
+ self.fold_mode = "disabled"
+ self.colors_enabled = True
- 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 = []
def build_start(self, identifier: str) -> None:
self.step_end()
@@ -120,7 +164,7 @@ 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()
@@ -128,11 +172,14 @@ def build_end(self) -> None:
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
@@ -147,6 +194,7 @@ def step_end(self, success: bool = True) -> None:
c = self.colors
s = self.symbols
duration = time.time() - self.step_start_time
+
if success:
print(f"{c.green}{s.done} {c.end}{duration:.2f}s".rjust(78))
else:
@@ -183,6 +231,26 @@ 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, *, options: "Options") -> Generator[None, None, None]:
+ start = time.time()
+ yield
+ duration = time.time() - start
+ if summary_path := os.environ.get("GITHUB_STEP_SUMMARY"):
+ github_summary = self._github_step_summary(duration=duration, options=options)
+ Path(summary_path).write_text(filter_ansi_codes(github_summary), encoding="utf-8")
+
+ n = len(self.summary)
+ s = "s" if n > 1 else ""
+ duration_str = humanize.naturaldelta(duration)
+ print()
+ self._start_fold_group(f"{n} wheel{s} produced in {duration_str}")
+ 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
@@ -222,6 +290,72 @@ def _fold_group_identifier(name: str) -> str:
# lowercase, shorten
return identifier.lower()[:20]
+ def _github_step_summary(self, duration: float, options: "Options") -> str:
+ """
+ Returns the GitHub step summary, in markdown format.
+ """
+ out = io.StringIO()
+ options_summary = options.summary(
+ identifiers=[bi.identifier for bi in self.summary], skip_unset=True
+ )
+ out.write(
+ textwrap.dedent("""\
+ ### 🎡 cibuildwheel
+
+
+ Build options
+
+
+ ```yaml
+ {options_summary}
+ ```
+
+
| Wheel | +Size | +Build identifier | +Time | +SHA256 | +
|---|