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} + ``` + +
+ + """).format(options_summary=options_summary) + ) + n_wheels = len([b for b in self.summary if b.filename]) + wheel_rows = "\n".join( + "" + f"{'' + b.filename.name + '' if b.filename else '*Build only*'}" + f"{b.size or 'N/A'}" + f"{b.identifier}" + f"{humanize.naturaldelta(b.duration)}" + f"{b.sha256 or 'N/A'}" + "" + for b in self.summary + ) + out.write( + textwrap.dedent("""\ + + + + + + + + + + + + {wheel_rows} + +
WheelSizeBuild identifierTimeSHA256
+
{n} wheel{s} created in {duration_str}
+ """).format( + wheel_rows=wheel_rows, + n=n_wheels, + duration_str=humanize.naturaldelta(duration), + s="s" if n_wheels > 1 else "", + ) + ) + + out.write("\n") + out.write("---") + out.write("\n") + return out.getvalue() + @property def colors(self) -> Colors: return Colors(enabled=self.colors_enabled) diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index e88998b29..6656df008 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -907,14 +907,18 @@ def defaults(self) -> Self: defaults=True, ) - def summary(self, identifiers: Iterable[str]) -> str: + def summary(self, identifiers: Iterable[str], skip_unset: bool = False) -> str: lines = [] global_option_names = sorted(f.name for f in dataclasses.fields(self.globals)) for option_name in global_option_names: option_value = getattr(self.globals, option_name) default_value = getattr(self.defaults.globals, option_name) - lines.append(self.option_summary(option_name, option_value, default_value)) + line = self.option_summary( + option_name, option_value, default_value, skip_unset=skip_unset + ) + if line is not None: + lines.append(line) build_options = self.build_options(identifier=None) build_options_defaults = self.defaults.build_options(identifier=None) @@ -934,9 +938,15 @@ def summary(self, identifiers: Iterable[str]) -> str: i: getattr(build_options_for_identifier[i], option_name) for i in identifiers } - lines.append( - self.option_summary(option_name, option_value, default_value, overrides=overrides) + line = self.option_summary( + option_name, + option_value, + default_value, + overrides=overrides, + skip_unset=skip_unset, ) + if line is not None: + lines.append(line) return "\n".join(lines) @@ -946,7 +956,8 @@ def option_summary( option_value: Any, default_value: Any, overrides: Mapping[str, Any] | None = None, - ) -> str: + skip_unset: bool = False, + ) -> str | None: """ Return a summary of the option value, including any overrides, with ANSI 'dim' color if it's the default. @@ -960,6 +971,10 @@ def option_summary( overrides_value_strs = {k: v for k, v in overrides_value_strs.items() if v != value_str} has_been_set = (value_str != default_value_str) or overrides_value_strs + + if skip_unset and not has_been_set: + return None + c = log.colors result = c.gray if not has_been_set else "" diff --git a/cibuildwheel/platforms/ios.py b/cibuildwheel/platforms/ios.py index b0511a2a4..f4cf6b60d 100644 --- a/cibuildwheel/platforms/ios.py +++ b/cibuildwheel/platforms/ios.py @@ -673,6 +673,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) @@ -685,7 +686,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 diff --git a/cibuildwheel/platforms/linux.py b/cibuildwheel/platforms/linux.py index 1a388d95f..8d25d73d0 100644 --- a/cibuildwheel/platforms/linux.py +++ b/cibuildwheel/platforms/linux.py @@ -414,13 +414,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_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 diff --git a/cibuildwheel/platforms/macos.py b/cibuildwheel/platforms/macos.py index cc0a5a8ef..a273f062c 100644 --- a/cibuildwheel/platforms/macos.py +++ b/cibuildwheel/platforms/macos.py @@ -732,6 +732,7 @@ def build(options: Options, tmp_path: Path) -> None: shell_with_arch(test_command_prepared, cwd=test_cwd, env=virtualenv_env) # we're all done here; move it to output (overwrite existing) + output_wheel = None if compatible_wheel is None: output_wheel = build_options.output_dir.joinpath(repaired_wheel.name) moved_wheel = move_file(repaired_wheel, output_wheel) @@ -744,7 +745,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 diff --git a/cibuildwheel/platforms/pyodide.py b/cibuildwheel/platforms/pyodide.py index 1ae5146b1..c46248ee7 100644 --- a/cibuildwheel/platforms/pyodide.py +++ b/cibuildwheel/platforms/pyodide.py @@ -540,6 +540,7 @@ def build(options: Options, tmp_path: Path) -> None: shell(test_command_prepared, cwd=test_cwd, env=virtualenv_env) # 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(repaired_wheel.name) moved_wheel = move_file(repaired_wheel, output_wheel) @@ -548,7 +549,7 @@ def build(options: Options, tmp_path: Path) -> None: f"{repaired_wheel} was moved to {moved_wheel} instead of {output_wheel}" ) built_wheels.append(output_wheel) - 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 ''}" diff --git a/cibuildwheel/platforms/windows.py b/cibuildwheel/platforms/windows.py index 6913092cd..5a55b3a08 100644 --- a/cibuildwheel/platforms/windows.py +++ b/cibuildwheel/platforms/windows.py @@ -613,6 +613,7 @@ def build(options: Options, tmp_path: Path) -> None: shell(test_command_prepared, cwd=test_cwd, env=virtualenv_env) # we're all done here; move it to output (remove if already exists) + output_wheel = None if compatible_wheel is None: output_wheel = build_options.output_dir.joinpath(repaired_wheel.name) moved_wheel = move_file(repaired_wheel, output_wheel) @@ -627,7 +628,7 @@ def build(options: Options, tmp_path: Path) -> None: # don't want to abort a build because of that) shutil.rmtree(identifier_tmp_dir, ignore_errors=True) - 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 diff --git a/pyproject.toml b/pyproject.toml index b5665bc2e..55b254b0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "certifi", "dependency-groups>=1.2", "filelock", + "humanize", "packaging>=20.9", "platformdirs" ] diff --git a/unit_test/main_tests/conftest.py b/unit_test/main_tests/conftest.py index e01c8c027..ef5c08340 100644 --- a/unit_test/main_tests/conftest.py +++ b/unit_test/main_tests/conftest.py @@ -6,7 +6,8 @@ import pytest -from cibuildwheel import __main__, architecture +from cibuildwheel import architecture +from cibuildwheel.logger import Logger from cibuildwheel.platforms import linux, macos, pyodide, windows from cibuildwheel.util import file @@ -58,7 +59,7 @@ def disable_print_wheels(monkeypatch): def empty_cm(*args, **kwargs): yield - monkeypatch.setattr(__main__, "print_new_wheels", empty_cm) + monkeypatch.setattr(Logger, "print_summary", empty_cm) @pytest.fixture diff --git a/unit_test/option_prepare_test.py b/unit_test/option_prepare_test.py index 945cb3b0a..39d6058ab 100644 --- a/unit_test/option_prepare_test.py +++ b/unit_test/option_prepare_test.py @@ -45,7 +45,7 @@ def ignore_context_call(*args, **kwargs): "cibuildwheel.platforms.linux.build_in_container", mock.Mock(spec=platforms.linux.build_in_container), ) - monkeypatch.setattr("cibuildwheel.__main__.print_new_wheels", ignore_context_call) + monkeypatch.setattr("cibuildwheel.logger.Logger.print_summary", ignore_context_call) @pytest.mark.usefixtures("mock_build_container", "fake_package_dir") diff --git a/unit_test/wheel_print_test.py b/unit_test/wheel_print_test.py index c3e4933af..9fd8dfcc5 100644 --- a/unit_test/wheel_print_test.py +++ b/unit_test/wheel_print_test.py @@ -1,34 +1,39 @@ +from pathlib import Path + import pytest -from cibuildwheel.__main__ import print_new_wheels +from cibuildwheel.logger import BuildInfo, Logger +from cibuildwheel.options import CommandLineArguments, Options + +OPTIONS_DEFAULTS = Options("linux", CommandLineArguments.defaults(), {}, defaults=True) +FILE = Path(__file__) -def test_printout_wheels(tmp_path, capsys): - tmp_path.joinpath("example.0").touch() - with print_new_wheels("TEST_MSG: {n}", tmp_path): - tmp_path.joinpath("example.1").write_bytes(b"0" * 1023) - tmp_path.joinpath("example.2").write_bytes(b"0" * 1025) +def test_printout_wheels(capsys): + log = Logger() + log.fold_mode = "disabled" + log.colors_enabled = False + + with log.print_summary(options=OPTIONS_DEFAULTS): + log.summary = [ + BuildInfo(identifier="id1", filename=None, duration=3), + BuildInfo(identifier="id2", filename=FILE, duration=2), + ] captured = capsys.readouterr() assert captured.err == "" - assert "example.0" not in captured.out - assert "example.1 1 kB\n" in captured.out - assert "example.2 2 kB\n" in captured.out - assert "TEST_MSG:" in captured.out - assert "TEST_MSG: 2\n" in captured.out + assert "id1" in captured.out + assert "id2" in captured.out + assert "wheels produced in" in captured.out + assert "SHA256=" in captured.out -def test_no_printout_on_error(tmp_path, capsys): - tmp_path.joinpath("example.0").touch() - with pytest.raises(RuntimeError), print_new_wheels("TEST_MSG: {n}", tmp_path): # noqa: PT012 - tmp_path.joinpath("example.1").touch() +def test_no_printout_on_error(capsys): + log = Logger() + with pytest.raises(RuntimeError), log.print_summary(options=OPTIONS_DEFAULTS): raise RuntimeError() captured = capsys.readouterr() assert captured.err == "" - - assert "example.0" not in captured.out - assert "example.1" not in captured.out - assert "example.2" not in captured.out - assert "TEST_MSG:" not in captured.out + assert captured.out == ""