Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
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(options=options):
platform_module.build(options, tmp_path)
finally:
# avoid https://github.com/python/cpython/issues/86962 by performing
Expand Down
14 changes: 12 additions & 2 deletions cibuildwheel/ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from .util.helpers import strtobool

ANSI_CODE_REGEX = re.compile(r"(\033\[[0-9;]*m)")


class CIProvider(Enum):
# official support
Expand Down Expand Up @@ -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 = ""

Expand All @@ -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
Expand All @@ -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)
174 changes: 154 additions & 20 deletions cibuildwheel/logger.py
Original file line number Diff line number Diff line change
@@ -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}", "")
Expand Down Expand Up @@ -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
Expand All @@ -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"):
Expand All @@ -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()
Expand All @@ -120,19 +164,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,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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

<details>
<summary>
Build options
</summary>

```yaml
{options_summary}
```

</details>

""").format(options_summary=options_summary)
)
n_wheels = len([b for b in self.summary if b.filename])
wheel_rows = "\n".join(
"<tr>"
f"<td nowrap>{'<samp>' + b.filename.name + '</samp>' if b.filename else '*Build only*'}</td>"
f"<td nowrap>{b.size or 'N/A'}</td>"
f"<td nowrap><samp>{b.identifier}</samp></td>"
f"<td nowrap>{humanize.naturaldelta(b.duration)}</td>"
f"<td nowrap><samp>{b.sha256 or 'N/A'}</samp></td>"
"</tr>"
for b in self.summary
)
out.write(
textwrap.dedent("""\
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use inspect.cleandoc instead of textwrap.dedent then you don't have to worry about leading/trailing whitespace. Up to you which you prefer.

Suggested change
textwrap.dedent("""\
inspect.cleandoc("""

<table>
<thead>
<tr>
<th align="left">Wheel</th>
<th align="left">Size</th>
<th align="left">Build identifier</th>
<th align="left">Time</th>
<th align="left">SHA256</th>
</tr>
</thead>
<tbody>
{wheel_rows}
</tbody>
</table>
<div align="right"><sup>{n} wheel{s} created in {duration_str}</sup></div>
""").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)
Expand Down
Loading
Loading