From 013a7cc1fe5a7ef0c19ce016962cd92915ba6d79 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 13 Jun 2025 17:20:38 -0400 Subject: [PATCH 01/12] chore: add summary to Action --- .github/workflows/test.yml | 2 +- action.yml | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index edc55f027..b99ee74d1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/action.yml b/action.yml index 3e19ab982..77b3f1f11 100644 --- a/action.yml +++ b/action.yml @@ -89,3 +89,24 @@ runs: ${{ inputs.only != '' && format('--only "{0}"', inputs.only) || ''}} shell: pwsh if: runner.os == 'Windows' + + - run: | + # Prepare summary + "${{ steps.python.outputs.python-path }}" -u << "EOF" + import os + from pathlib import Path + + output_dir = Path("${{ inputs.output-dir }}" or "wheelhouse") + + with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("w", encoding="utf-8") as f: + print("## šŸŽ”: files in output directory", file=f) + print(file=f) + for item in sorted(output_dir.iterdir()): + if item.is_file(): + size_mb = item.stat().st_size / 1024**2 + print(f"* `{item}` - {size_mb:.2f} MB", file=f) + else: + print(f"* `{item.name}/`", file=f) + print(file=f) + EOF + shell: bash From 35a6c7a030cf55cfd7e62bd6ab6a8e4c04db9420 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Fri, 13 Jun 2025 18:41:55 -0400 Subject: [PATCH 02/12] refactor: new summary table --- action.yml | 21 ---------- cibuildwheel/__main__.py | 13 ++---- cibuildwheel/logger.py | 67 ++++++++++++++++++++++--------- cibuildwheel/platforms/ios.py | 3 +- cibuildwheel/platforms/linux.py | 5 ++- cibuildwheel/platforms/macos.py | 3 +- cibuildwheel/platforms/windows.py | 3 +- 7 files changed, 62 insertions(+), 53 deletions(-) diff --git a/action.yml b/action.yml index 77b3f1f11..3e19ab982 100644 --- a/action.yml +++ b/action.yml @@ -89,24 +89,3 @@ runs: ${{ inputs.only != '' && format('--only "{0}"', inputs.only) || ''}} shell: pwsh if: runner.os == 'Windows' - - - run: | - # Prepare summary - "${{ steps.python.outputs.python-path }}" -u << "EOF" - import os - from pathlib import Path - - output_dir = Path("${{ inputs.output-dir }}" or "wheelhouse") - - with Path(os.environ["GITHUB_STEP_SUMMARY"]).open("w", encoding="utf-8") as f: - print("## šŸŽ”: files in output directory", file=f) - print(file=f) - for item in sorted(output_dir.iterdir()): - if item.is_file(): - size_mb = item.stat().st_size / 1024**2 - print(f"* `{item}` - {size_mb:.2f} MB", file=f) - else: - print(f"* `{item.name}/`", file=f) - print(file=f) - EOF - shell: bash diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index 934d80c4e..af2f2bae1 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -308,18 +308,10 @@ def print_new_wheels(msg: str, output_dir: Path) -> Generator[None, None, None]: 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", - ) + print(msg.format(n=n, s=s, m=m)) def build_in_directory(args: CommandLineArguments) -> None: @@ -382,8 +374,9 @@ 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 print_new_wheels("\n{n} wheels produced in {m:.0f} minutes", output_dir): platform_module.build(options, tmp_path) + log.print_summary() finally: # avoid https://github.com/python/cpython/issues/86962 by performing # cleanup manually diff --git a/cibuildwheel/logger.py b/cibuildwheel/logger.py index cee94d666..b004332c6 100644 --- a/cibuildwheel/logger.py +++ b/cibuildwheel/logger.py @@ -3,7 +3,8 @@ import re import sys import time -from typing import IO, AnyStr, Final +from pathlib import Path +from typing import IO, AnyStr, Final, Literal from .ci import CIProvider, detect_ci_provider @@ -70,13 +71,15 @@ def __init__(self, *, unicode: bool) -> None: 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[tuple[str, Path | None, float]] + summary_mode: Literal["github", "generic"] def __init__(self) -> None: if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"): @@ -88,25 +91,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() @@ -120,7 +131,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() @@ -133,6 +144,8 @@ def build_end(self) -> None: print( f"{c.green}{s.done} {c.end}{self.active_build_identifier} finished in {duration:.2f}s" ) + self.summary.append((self.active_build_identifier, filename, duration)) + self.build_start_time = None self.active_build_identifier = None @@ -183,6 +196,24 @@ def error(self, error: BaseException | str) -> None: c = self.colors print(f"cibuildwheel: {c.bright_red}error{c.end}: {error}\n", file=sys.stderr) + def print_summary(self) -> None: + summary = "## šŸŽ”: Wheels\n\n| Identifier | Wheel | Size | Time |\n|===|===|===|===|\n" + for ident, filename, duration in self.summary: + if filename: + size_mb = filename.stat().st_size / 1024**2 + summary += f"| {ident} | {filename.name} | {size_mb:.2f} MB | {duration} |" + else: + summary += f"| {ident} | test only | --- | {duration} |" + + match self.summary_mode: + case "github": + Path(os.environ["GITHUB_STEP_SUMMARY"]).write_text(summary, encoding="utf-8") + print(summary) + case _: + print(summary) + + self.summary = [] + @property def step_active(self) -> bool: return self.step_start_time is not None diff --git a/cibuildwheel/platforms/ios.py b/cibuildwheel/platforms/ios.py index 1a3d2d274..ac9ce44b0 100644 --- a/cibuildwheel/platforms/ios.py +++ b/cibuildwheel/platforms/ios.py @@ -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) @@ -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 diff --git a/cibuildwheel/platforms/linux.py b/cibuildwheel/platforms/linux.py index 82a695e69..83c6d527b 100644 --- a/cibuildwheel/platforms/linux.py +++ b/cibuildwheel/platforms/linux.py @@ -418,14 +418,17 @@ def build_in_container( container.call(["rm", "-rf", testing_temp_dir]) # move repaired wheels to output + # TODO: can this still output multiple wheels? I though it was just multiple tags + 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 ) + output_wheel = options.globals.output_dir / repaired_wheels[0].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 99ca915b8..a9a4311e4 100644 --- a/cibuildwheel/platforms/macos.py +++ b/cibuildwheel/platforms/macos.py @@ -726,6 +726,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) @@ -738,7 +739,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/windows.py b/cibuildwheel/platforms/windows.py index c57a53de0..aa811d131 100644 --- a/cibuildwheel/platforms/windows.py +++ b/cibuildwheel/platforms/windows.py @@ -607,6 +607,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) @@ -621,7 +622,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 From 36fc0d22b8490cd50ea8b02223d02bf8f05bc963 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sat, 14 Jun 2025 00:17:17 -0400 Subject: [PATCH 03/12] fix: fixup tests and formatting Signed-off-by: Henry Schreiner --- cibuildwheel/logger.py | 24 +++++++++++++++--------- unit_test/wheel_print_test.py | 6 ------ 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/cibuildwheel/logger.py b/cibuildwheel/logger.py index b004332c6..74f1ea3fe 100644 --- a/cibuildwheel/logger.py +++ b/cibuildwheel/logger.py @@ -1,4 +1,6 @@ import codecs +import functools +import io import os import re import sys @@ -197,20 +199,24 @@ def error(self, error: BaseException | str) -> None: print(f"cibuildwheel: {c.bright_red}error{c.end}: {error}\n", file=sys.stderr) def print_summary(self) -> None: - summary = "## šŸŽ”: Wheels\n\n| Identifier | Wheel | Size | Time |\n|===|===|===|===|\n" + string_io = io.StringIO() + ioprint = functools.partial(print, file=string_io) + ioprint("## šŸŽ”: Wheels\n") + ioprint("| Identifier | Wheel | Size | Time |") + ioprint("|===|===|===|===|") for ident, filename, duration in self.summary: if filename: size_mb = filename.stat().st_size / 1024**2 - summary += f"| {ident} | {filename.name} | {size_mb:.2f} MB | {duration} |" + ioprint(f"| {ident} | {filename.name} | {size_mb:.2f} MB | {duration} |") else: - summary += f"| {ident} | test only | --- | {duration} |" + ioprint(f"| {ident} | test only | --- | {duration} |") - match self.summary_mode: - case "github": - Path(os.environ["GITHUB_STEP_SUMMARY"]).write_text(summary, encoding="utf-8") - print(summary) - case _: - print(summary) + if self.summary_mode == "github": + Path(os.environ["GITHUB_STEP_SUMMARY"]).write_text( + string_io.getvalue(), encoding="utf-8" + ) + + print(string_io.getvalue()) self.summary = [] diff --git a/unit_test/wheel_print_test.py b/unit_test/wheel_print_test.py index c3e4933af..47b61ad72 100644 --- a/unit_test/wheel_print_test.py +++ b/unit_test/wheel_print_test.py @@ -12,9 +12,6 @@ def test_printout_wheels(tmp_path, capsys): 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 @@ -28,7 +25,4 @@ def test_no_printout_on_error(tmp_path, capsys): 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 From 94f1cb0fd5293a15ddad52754d8476a907365a99 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sat, 14 Jun 2025 00:39:30 -0400 Subject: [PATCH 04/12] fix: pyodide missing some logging Signed-off-by: Henry Schreiner --- cibuildwheel/logger.py | 8 ++++---- cibuildwheel/platforms/pyodide.py | 9 +++++++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/cibuildwheel/logger.py b/cibuildwheel/logger.py index 74f1ea3fe..df26173e8 100644 --- a/cibuildwheel/logger.py +++ b/cibuildwheel/logger.py @@ -202,14 +202,14 @@ def print_summary(self) -> None: string_io = io.StringIO() ioprint = functools.partial(print, file=string_io) ioprint("## šŸŽ”: Wheels\n") - ioprint("| Identifier | Wheel | Size | Time |") - ioprint("|===|===|===|===|") + ioprint("| Identifier | Size | Time | Wheel |") + ioprint("| ---------- | ---- | ---- | ----- |") for ident, filename, duration in self.summary: if filename: size_mb = filename.stat().st_size / 1024**2 - ioprint(f"| {ident} | {filename.name} | {size_mb:.2f} MB | {duration} |") + ioprint(f"| `{ident}` | {size_mb:.2f} MB | {duration:.0f} s | `{filename.name}` |") else: - ioprint(f"| {ident} | test only | --- | {duration} |") + ioprint(f"| `{ident}` | --- | {duration:.0f} s | test only |") if self.summary_mode == "github": Path(os.environ["GITHUB_STEP_SUMMARY"]).write_text( diff --git a/cibuildwheel/platforms/pyodide.py b/cibuildwheel/platforms/pyodide.py index 4e545260f..c46248ee7 100644 --- a/cibuildwheel/platforms/pyodide.py +++ b/cibuildwheel/platforms/pyodide.py @@ -3,6 +3,7 @@ import json import os import shutil +import subprocess import sys import tomllib import typing @@ -445,6 +446,7 @@ def build(options: Options, tmp_path: Path) -> None: dest_dir=repaired_wheel_dir, ) shell(repair_command_prepared, env=env) + log.step_end() else: shutil.move(str(built_wheel), repaired_wheel_dir) @@ -538,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) @@ -546,6 +549,8 @@ 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(output_wheel) - finally: - pass + except subprocess.CalledProcessError as error: + msg = f"Command {error.cmd} failed with code {error.returncode}. {error.stdout or ''}" + raise errors.FatalError(msg) from error From d3990bd9816be10de2730c7e5cb84ec5de7f3e27 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 23 Jun 2025 23:11:35 -0400 Subject: [PATCH 05/12] fix: nicer printout, nicer in-place summary Signed-off-by: Henry Schreiner --- .pre-commit-config.yaml | 1 + cibuildwheel/__main__.py | 10 +++--- cibuildwheel/logger.py | 66 ++++++++++++++++++++++++++++------------ pyproject.toml | 1 + 4 files changed, 54 insertions(+), 24 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 94bf4a707..d46b26dc6 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 af2f2bae1..0f7dd66de 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -15,6 +15,8 @@ from tempfile import mkdtemp from typing import Any, Literal, TextIO +import humanize + import cibuildwheel import cibuildwheel.util from cibuildwheel import errors @@ -309,9 +311,9 @@ def print_new_wheels(msg: str, output_dir: Path) -> Generator[None, None, None]: return n = len(new_contents) - s = time.time() - start_time - m = s / 60 - print(msg.format(n=n, s=s, m=m)) + s = "s" if n > 1 else "" + t = humanize.naturaldelta(time.time() - start_time) + print(msg.format(n=n, s=s, t=t)) def build_in_directory(args: CommandLineArguments) -> None: @@ -374,7 +376,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 print_new_wheels("\n{n} wheel{s} produced in {t}", output_dir): platform_module.build(options, tmp_path) log.print_summary() finally: diff --git a/cibuildwheel/logger.py b/cibuildwheel/logger.py index df26173e8..693ba03b6 100644 --- a/cibuildwheel/logger.py +++ b/cibuildwheel/logger.py @@ -1,5 +1,5 @@ import codecs -import functools +import dataclasses import io import os import re @@ -8,6 +8,8 @@ from pathlib import Path from typing import IO, AnyStr, Final, Literal +import humanize + from .ci import CIProvider, detect_ci_provider FoldPattern = tuple[str, str] @@ -72,6 +74,31 @@ 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: Literal["azure", "github", "travis", "disabled"] colors_enabled: bool @@ -80,7 +107,7 @@ class Logger: build_start_time: float | None = None step_start_time: float | None = None active_fold_group_name: str | None = None - summary: list[tuple[str, Path | None, float]] + summary: list[BuildInfo] summary_mode: Literal["github", "generic"] def __init__(self) -> None: @@ -141,12 +168,13 @@ def build_end(self, filename: Path | None) -> None: c = self.colors s = self.symbols duration = time.time() - self.build_start_time + duration_str = humanize.naturaldelta(duration) 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.summary.append((self.active_build_identifier, filename, duration)) self.build_start_time = None self.active_build_identifier = None @@ -162,10 +190,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 @@ -199,24 +228,21 @@ def error(self, error: BaseException | str) -> None: print(f"cibuildwheel: {c.bright_red}error{c.end}: {error}\n", file=sys.stderr) def print_summary(self) -> None: - string_io = io.StringIO() - ioprint = functools.partial(print, file=string_io) - ioprint("## šŸŽ”: Wheels\n") - ioprint("| Identifier | Size | Time | Wheel |") - ioprint("| ---------- | ---- | ---- | ----- |") - for ident, filename, duration in self.summary: - if filename: - size_mb = filename.stat().st_size / 1024**2 - ioprint(f"| `{ident}` | {size_mb:.2f} MB | {duration:.0f} s | `{filename.name}` |") - else: - ioprint(f"| `{ident}` | --- | {duration:.0f} s | test only |") - 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" ) - print(string_io.getvalue()) + print("\nšŸŽ” Wheels:\n") + for build_info in self.summary: + print(" *", build_info) self.summary = [] diff --git a/pyproject.toml b/pyproject.toml index 0b1ef884d..8ddd4e9b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "certifi", "dependency-groups>=1.2", "filelock", + "humanize", "packaging>=20.9", "platformdirs" ] From 18927e47e7f37281e37d12f9d8694d90985f4527 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 24 Jun 2025 12:09:21 -0400 Subject: [PATCH 06/12] fix: use summary for everything Signed-off-by: Henry Schreiner --- cibuildwheel/__main__.py | 36 ++------------------------------ cibuildwheel/logger.py | 22 +++++++++++++------ unit_test/main_tests/conftest.py | 5 +++-- unit_test/option_prepare_test.py | 2 +- unit_test/wheel_print_test.py | 33 ++++++++++++++++------------- 5 files changed, 41 insertions(+), 57 deletions(-) diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index 0f7dd66de..131d1559a 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -7,16 +7,13 @@ 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 -import humanize - import cibuildwheel import cibuildwheel.util from cibuildwheel import errors @@ -288,34 +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 - - n = len(new_contents) - s = "s" if n > 1 else "" - t = humanize.naturaldelta(time.time() - start_time) - print(msg.format(n=n, s=s, t=t)) - - def build_in_directory(args: CommandLineArguments) -> None: platform: PlatformName = _compute_platform(args) if platform == "pyodide" and sys.platform == "win32": @@ -376,9 +345,8 @@ def build_in_directory(args: CommandLineArguments) -> None: tmp_path = Path(mkdtemp(prefix="cibw-run-")).resolve(strict=True) try: - with print_new_wheels("\n{n} wheel{s} produced in {t}", output_dir): + with log.print_summary(): platform_module.build(options, tmp_path) - log.print_summary() finally: # avoid https://github.com/python/cpython/issues/86962 by performing # cleanup manually diff --git a/cibuildwheel/logger.py b/cibuildwheel/logger.py index 693ba03b6..5fccc7503 100644 --- a/cibuildwheel/logger.py +++ b/cibuildwheel/logger.py @@ -1,10 +1,12 @@ import codecs +import contextlib import dataclasses import io import os import re import sys import time +from collections.abc import Generator from pathlib import Path from typing import IO, AnyStr, Final, Literal @@ -168,7 +170,7 @@ def build_end(self, filename: Path | None) -> None: c = self.colors s = self.symbols duration = time.time() - self.build_start_time - duration_str = humanize.naturaldelta(duration) + duration_str = humanize.naturaldelta(duration, minimum_unit="milliseconds") print() print(f"{c.green}{s.done} {c.end}{self.active_build_identifier} finished in {duration_str}") @@ -227,22 +229,30 @@ def error(self, error: BaseException | str) -> None: c = self.colors print(f"cibuildwheel: {c.bright_red}error{c.end}: {error}\n", file=sys.stderr) - def print_summary(self) -> None: + @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("## šŸŽ” 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") + string_io.write("\n") Path(os.environ["GITHUB_STEP_SUMMARY"]).write_text( string_io.getvalue(), encoding="utf-8" ) - print("\nšŸŽ” Wheels:\n") + 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) + print(" ", build_info) + self._end_fold_group() self.summary = [] 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 d2a1a0826..935eb17f2 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 47b61ad72..461047776 100644 --- a/unit_test/wheel_print_test.py +++ b/unit_test/wheel_print_test.py @@ -1,28 +1,33 @@ import pytest -from cibuildwheel.__main__ import print_new_wheels +from cibuildwheel.logger import BuildInfo, Logger -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 + log.summary_mode = "generic" + + with log.print_summary(): + log.summary = [ + BuildInfo(identifier="id1", filename=None, duration=3), + BuildInfo(identifier="id2", filename=None, duration=2), + ] captured = capsys.readouterr() assert captured.err == "" - 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 -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(): raise RuntimeError() captured = capsys.readouterr() assert captured.err == "" - - assert "TEST_MSG:" not in captured.out + assert captured.out == "" From dc25a28f521080a5accae550f1d272294f0a9bba Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Tue, 24 Jun 2025 12:28:39 -0400 Subject: [PATCH 07/12] fix: support only one output wheel from repair Signed-off-by: Henry Schreiner --- cibuildwheel/errors.py | 18 +++++++++++++++ cibuildwheel/platforms/linux.py | 39 ++++++++++++++------------------- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/cibuildwheel/errors.py b/cibuildwheel/errors.py index 28939719f..9f0a71f15 100644 --- a/cibuildwheel/errors.py +++ b/cibuildwheel/errors.py @@ -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 diff --git a/cibuildwheel/platforms/linux.py b/cibuildwheel/platforms/linux.py index 83c6d527b..ebe51e1f6 100644 --- a/cibuildwheel/platforms/linux.py +++ b/cibuildwheel/platforms/linux.py @@ -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...") @@ -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...") @@ -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, ) @@ -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" @@ -417,16 +413,13 @@ def build_in_container( # clean up test environment container.call(["rm", "-rf", testing_temp_dir]) - # move repaired wheels to output - # TODO: can this still output multiple wheels? I though it was just multiple tags + # 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 - ) - output_wheel = options.globals.output_dir / repaired_wheels[0].name + 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(output_wheel) From 1cb91f44fb9044907d4627bb4a20282f815ff9bd Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Fri, 11 Jul 2025 15:13:46 +0100 Subject: [PATCH 08/12] Add new Github summary format --- cibuildwheel/__main__.py | 2 +- cibuildwheel/logger.py | 106 ++++++++++++++++++++++++---------- cibuildwheel/options.py | 27 +++++++-- unit_test/wheel_print_test.py | 8 ++- 4 files changed, 101 insertions(+), 42 deletions(-) diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index 131d1559a..7585ca830 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -345,7 +345,7 @@ def build_in_directory(args: CommandLineArguments) -> None: tmp_path = Path(mkdtemp(prefix="cibw-run-")).resolve(strict=True) try: - with log.print_summary(): + 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/logger.py b/cibuildwheel/logger.py index 5fccc7503..58fd078ce 100644 --- a/cibuildwheel/logger.py +++ b/cibuildwheel/logger.py @@ -5,15 +5,19 @@ import os import re import sys +import textwrap import time from collections.abc import Generator from pathlib import Path -from typing import IO, AnyStr, Final, Literal +from typing import IO, TYPE_CHECKING, AnyStr, Final, Literal import humanize from .ci import CIProvider, detect_ci_provider +if TYPE_CHECKING: + from .options import Options + FoldPattern = tuple[str, str] DEFAULT_FOLD_PATTERN: Final[FoldPattern] = ("{name}", "") FOLD_PATTERNS: Final[dict[str, FoldPattern]] = { @@ -82,17 +86,6 @@ class BuildInfo: 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: @@ -110,7 +103,6 @@ class Logger: 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"): @@ -126,27 +118,22 @@ def __init__(self) -> None: case CIProvider.azure_pipelines: self.fold_mode = "azure" self.colors_enabled = True - self.summary_mode = "generic" case CIProvider.github_actions: self.fold_mode = "github" self.colors_enabled = True - self.summary_mode = "github" case CIProvider.travis_ci: self.fold_mode = "travis" self.colors_enabled = True - self.summary_mode = "generic" case CIProvider.appveyor: self.fold_mode = "disabled" self.colors_enabled = True - self.summary_mode = "generic" case _: self.fold_mode = "disabled" self.colors_enabled = file_supports_color(sys.stdout) - self.summary_mode = "generic" self.summary = [] @@ -230,26 +217,19 @@ def error(self, error: BaseException | str) -> None: print(f"cibuildwheel: {c.bright_red}error{c.end}: {error}\n", file=sys.stderr) @contextlib.contextmanager - def print_summary(self) -> Generator[None, None, None]: + def print_summary(self, *, options: "Options") -> 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" - ) + 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(github_summary, 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}") + duration_str = humanize.naturaldelta(duration) + self._start_fold_group(f"{n_str} wheel{s} produced in {duration_str}") for build_info in self.summary: print(" ", build_info) self._end_fold_group() @@ -295,6 +275,68 @@ 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) + ) + + out.write( + textwrap.dedent("""\ + + + + + + + + + + + {wheel_rows} + +
WheelSizeBuild identifierTime
+
{n} wheels created in {duration_str}
+ """).format( + wheel_rows="\n".join( + "" + f"{'' + b.filename.name + '' if b.filename else '*Build only*'}" + f"{humanize.naturalsize(b.filename.stat().st_size) if b.filename else 'N/A'}" + f"{b.identifier}" + f"{humanize.naturaldelta(b.duration)}" + "" + for b in self.summary + ), + n=len([b for b in self.summary if b.filename]), + duration_str=humanize.naturaldelta(duration), + ) + ) + + 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 7c98f1e21..14d4c1d44 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -901,14 +901,18 @@ def defaults(self) -> Self: defaults=True, ) - def summary(self, identifiers: Iterable[str]) -> str: - lines = [] + def summary(self, identifiers: Iterable[str], skip_unset: bool = False) -> str: + lines: list[str] = [] 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) @@ -928,9 +932,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) @@ -940,7 +950,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. @@ -954,6 +965,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/unit_test/wheel_print_test.py b/unit_test/wheel_print_test.py index 461047776..71b57bd07 100644 --- a/unit_test/wheel_print_test.py +++ b/unit_test/wheel_print_test.py @@ -1,15 +1,17 @@ import pytest from cibuildwheel.logger import BuildInfo, Logger +from cibuildwheel.options import CommandLineArguments, Options + +OPTIONS_DEFAULTS = Options("linux", CommandLineArguments.defaults(), {}, defaults=True) def test_printout_wheels(capsys): log = Logger() log.fold_mode = "disabled" log.colors_enabled = False - log.summary_mode = "generic" - with log.print_summary(): + with log.print_summary(options=OPTIONS_DEFAULTS): log.summary = [ BuildInfo(identifier="id1", filename=None, duration=3), BuildInfo(identifier="id2", filename=None, duration=2), @@ -25,7 +27,7 @@ def test_printout_wheels(capsys): def test_no_printout_on_error(capsys): log = Logger() - with pytest.raises(RuntimeError), log.print_summary(): + with pytest.raises(RuntimeError), log.print_summary(options=OPTIONS_DEFAULTS): raise RuntimeError() captured = capsys.readouterr() From 8e6857633d749ad850c55839189479734ac37461 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Fri, 11 Jul 2025 15:15:24 +0100 Subject: [PATCH 09/12] Remove a couple of humanize uses --- cibuildwheel/logger.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cibuildwheel/logger.py b/cibuildwheel/logger.py index 58fd078ce..a6b065c0a 100644 --- a/cibuildwheel/logger.py +++ b/cibuildwheel/logger.py @@ -179,11 +179,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_str}".rjust(78)) + print(f"{c.green}{s.done} {c.end}{duration:.2f}s".rjust(78)) else: - print(f"{c.red}{s.error} {c.end}{duration_str}".rjust(78)) + print(f"{c.red}{s.error} {c.end}{duration:.2f}s".rjust(78)) self.step_start_time = None @@ -227,9 +227,9 @@ def print_summary(self, *, options: "Options") -> Generator[None, None, None]: n = len(self.summary) s = "s" if n > 1 else "" - n_str = humanize.apnumber(n).title() duration_str = humanize.naturaldelta(duration) - self._start_fold_group(f"{n_str} wheel{s} produced in {duration_str}") + 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() From 6a81d70f19001dcb3ad6183802bf5a395febcbee Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sun, 13 Jul 2025 22:59:21 -0700 Subject: [PATCH 10/12] fix: filter ANSI codes in summary Signed-off-by: Henry Schreiner --- cibuildwheel/ci.py | 14 ++++++++++++-- cibuildwheel/logger.py | 4 ++-- 2 files changed, 14 insertions(+), 4 deletions(-) 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 a6b065c0a..2358dfdc2 100644 --- a/cibuildwheel/logger.py +++ b/cibuildwheel/logger.py @@ -13,7 +13,7 @@ import humanize -from .ci import CIProvider, detect_ci_provider +from .ci import CIProvider, detect_ci_provider, filter_ansi_codes if TYPE_CHECKING: from .options import Options @@ -223,7 +223,7 @@ def print_summary(self, *, options: "Options") -> Generator[None, None, None]: 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(github_summary, encoding="utf-8") + Path(summary_path).write_text(filter_ansi_codes(github_summary), encoding="utf-8") n = len(self.summary) s = "s" if n > 1 else "" From 764f8b627953b8443b7bcb51be41fbadd47f4012 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Sun, 13 Jul 2025 23:08:54 -0700 Subject: [PATCH 11/12] fix: add sha256 Signed-off-by: Henry Schreiner --- cibuildwheel/logger.py | 23 ++++++++++++++++++++--- unit_test/wheel_print_test.py | 6 +++++- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/cibuildwheel/logger.py b/cibuildwheel/logger.py index 2358dfdc2..eea8c9894 100644 --- a/cibuildwheel/logger.py +++ b/cibuildwheel/logger.py @@ -1,6 +1,8 @@ import codecs import contextlib import dataclasses +import functools +import hashlib import io import os import re @@ -86,11 +88,24 @@ class BuildInfo: 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: - size = humanize.naturalsize(self.filename.stat().st_size) - return f"{self.identifier}: {self.filename.name} {size} in {duration}" + return f"{self.identifier}: {self.filename.name} {self.size} in {duration}, SHA256={self.sha256}" return f"{self.identifier}: {duration} (test only)" @@ -310,6 +325,7 @@ def _github_step_summary(self, duration: float, options: "Options") -> str: Size Build identifier Time + SHA256 @@ -321,9 +337,10 @@ def _github_step_summary(self, duration: float, options: "Options") -> str: wheel_rows="\n".join( "" f"{'' + b.filename.name + '' if b.filename else '*Build only*'}" - f"{humanize.naturalsize(b.filename.stat().st_size) if b.filename else 'N/A'}" + 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 ), diff --git a/unit_test/wheel_print_test.py b/unit_test/wheel_print_test.py index 71b57bd07..9fd8dfcc5 100644 --- a/unit_test/wheel_print_test.py +++ b/unit_test/wheel_print_test.py @@ -1,9 +1,12 @@ +from pathlib import Path + import pytest 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(capsys): @@ -14,7 +17,7 @@ def test_printout_wheels(capsys): with log.print_summary(options=OPTIONS_DEFAULTS): log.summary = [ BuildInfo(identifier="id1", filename=None, duration=3), - BuildInfo(identifier="id2", filename=None, duration=2), + BuildInfo(identifier="id2", filename=FILE, duration=2), ] captured = capsys.readouterr() @@ -23,6 +26,7 @@ def test_printout_wheels(capsys): 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(capsys): From 741429ad7b85e358c955b88bc4bd4904239a2f45 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Mon, 14 Jul 2025 10:42:44 -0700 Subject: [PATCH 12/12] fix: nicer wheel/wheels depending on how many are present Signed-off-by: Henry Schreiner --- cibuildwheel/logger.py | 28 +++++++++++++++------------- cibuildwheel/options.py | 2 +- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/cibuildwheel/logger.py b/cibuildwheel/logger.py index eea8c9894..6ed3b761c 100644 --- a/cibuildwheel/logger.py +++ b/cibuildwheel/logger.py @@ -315,7 +315,17 @@ def _github_step_summary(self, duration: float, options: "Options") -> str: """).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("""\ @@ -332,20 +342,12 @@ def _github_step_summary(self, duration: float, options: "Options") -> str: {wheel_rows}
-
{n} wheels created in {duration_str}
+
{n} wheel{s} created in {duration_str}
""").format( - 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 - ), - n=len([b for b in self.summary if b.filename]), + wheel_rows=wheel_rows, + n=n_wheels, duration_str=humanize.naturaldelta(duration), + s="s" if n_wheels > 1 else "", ) ) diff --git a/cibuildwheel/options.py b/cibuildwheel/options.py index ed6375bae..6656df008 100644 --- a/cibuildwheel/options.py +++ b/cibuildwheel/options.py @@ -908,7 +908,7 @@ def defaults(self) -> Self: ) def summary(self, identifiers: Iterable[str], skip_unset: bool = False) -> str: - lines: list[str] = [] + lines = [] global_option_names = sorted(f.name for f in dataclasses.fields(self.globals)) for option_name in global_option_names: