11import codecs
2+ import contextlib
3+ import dataclasses
4+ import functools
5+ import hashlib
6+ import io
27import os
38import re
49import sys
10+ import textwrap
511import time
6- from typing import IO , AnyStr , Final , Literal
12+ from collections .abc import Generator
13+ from pathlib import Path
14+ from typing import IO , TYPE_CHECKING , AnyStr , Final , Literal
715
8- from .ci import CIProvider , detect_ci_provider
16+ import humanize
17+
18+ from .ci import CIProvider , detect_ci_provider , filter_ansi_codes
19+
20+ if TYPE_CHECKING :
21+ from .options import Options
922
1023FoldPattern = tuple [str , str ]
1124DEFAULT_FOLD_PATTERN : Final [FoldPattern ] = ("{name}" , "" )
@@ -69,6 +82,33 @@ def __init__(self, *, unicode: bool) -> None:
6982 self .error = "✕" if unicode else "failed"
7083
7184
85+ @dataclasses .dataclass (kw_only = True , frozen = True )
86+ class BuildInfo :
87+ identifier : str
88+ filename : Path | None
89+ duration : float
90+
91+ @functools .cached_property
92+ def size (self ) -> str | None :
93+ if self .filename is None :
94+ return None
95+ return humanize .naturalsize (self .filename .stat ().st_size )
96+
97+ @functools .cached_property
98+ def sha256 (self ) -> str | None :
99+ if self .filename is None :
100+ return None
101+ with self .filename .open ("rb" ) as f :
102+ digest = hashlib .file_digest (f , "sha256" )
103+ return digest .hexdigest ()
104+
105+ def __str__ (self ) -> str :
106+ duration = humanize .naturaldelta (self .duration )
107+ if self .filename :
108+ return f"{ self .identifier } : { self .filename .name } { self .size } in { duration } , SHA256={ self .sha256 } "
109+ return f"{ self .identifier } : { duration } (test only)"
110+
111+
72112class Logger :
73113 fold_mode : Literal ["azure" , "github" , "travis" , "disabled" ]
74114 colors_enabled : bool
@@ -77,6 +117,7 @@ class Logger:
77117 build_start_time : float | None = None
78118 step_start_time : float | None = None
79119 active_fold_group_name : str | None = None
120+ summary : list [BuildInfo ]
80121
81122 def __init__ (self ) -> None :
82123 if sys .platform == "win32" and hasattr (sys .stdout , "reconfigure" ):
@@ -88,25 +129,28 @@ def __init__(self) -> None:
88129
89130 ci_provider = detect_ci_provider ()
90131
91- if ci_provider == CIProvider .azure_pipelines :
92- self .fold_mode = "azure"
93- self .colors_enabled = True
132+ match ci_provider :
133+ case CIProvider .azure_pipelines :
134+ self .fold_mode = "azure"
135+ self .colors_enabled = True
94136
95- elif ci_provider == CIProvider .github_actions :
96- self .fold_mode = "github"
97- self .colors_enabled = True
137+ case CIProvider .github_actions :
138+ self .fold_mode = "github"
139+ self .colors_enabled = True
98140
99- elif ci_provider == CIProvider .travis_ci :
100- self .fold_mode = "travis"
101- self .colors_enabled = True
141+ case CIProvider .travis_ci :
142+ self .fold_mode = "travis"
143+ self .colors_enabled = True
102144
103- elif ci_provider == CIProvider .appveyor :
104- self .fold_mode = "disabled"
105- self .colors_enabled = True
145+ case CIProvider .appveyor :
146+ self .fold_mode = "disabled"
147+ self .colors_enabled = True
106148
107- else :
108- self .fold_mode = "disabled"
109- self .colors_enabled = file_supports_color (sys .stdout )
149+ case _:
150+ self .fold_mode = "disabled"
151+ self .colors_enabled = file_supports_color (sys .stdout )
152+
153+ self .summary = []
110154
111155 def build_start (self , identifier : str ) -> None :
112156 self .step_end ()
@@ -120,19 +164,22 @@ def build_start(self, identifier: str) -> None:
120164 self .build_start_time = time .time ()
121165 self .active_build_identifier = identifier
122166
123- def build_end (self ) -> None :
167+ def build_end (self , filename : Path | None ) -> None :
124168 assert self .build_start_time is not None
125169 assert self .active_build_identifier is not None
126170 self .step_end ()
127171
128172 c = self .colors
129173 s = self .symbols
130174 duration = time .time () - self .build_start_time
175+ duration_str = humanize .naturaldelta (duration , minimum_unit = "milliseconds" )
131176
132177 print ()
133- print (
134- f"{ c .green } { s .done } { c .end } { self .active_build_identifier } finished in { duration :.2f} s"
178+ print (f"{ c .green } { s .done } { c .end } { self .active_build_identifier } finished in { duration_str } " )
179+ self .summary .append (
180+ BuildInfo (identifier = self .active_build_identifier , filename = filename , duration = duration )
135181 )
182+
136183 self .build_start_time = None
137184 self .active_build_identifier = None
138185
@@ -147,6 +194,7 @@ def step_end(self, success: bool = True) -> None:
147194 c = self .colors
148195 s = self .symbols
149196 duration = time .time () - self .step_start_time
197+
150198 if success :
151199 print (f"{ c .green } { s .done } { c .end } { duration :.2f} s" .rjust (78 ))
152200 else :
@@ -183,6 +231,26 @@ def error(self, error: BaseException | str) -> None:
183231 c = self .colors
184232 print (f"cibuildwheel: { c .bright_red } error{ c .end } : { error } \n " , file = sys .stderr )
185233
234+ @contextlib .contextmanager
235+ def print_summary (self , * , options : "Options" ) -> Generator [None , None , None ]:
236+ start = time .time ()
237+ yield
238+ duration = time .time () - start
239+ if summary_path := os .environ .get ("GITHUB_STEP_SUMMARY" ):
240+ github_summary = self ._github_step_summary (duration = duration , options = options )
241+ Path (summary_path ).write_text (filter_ansi_codes (github_summary ), encoding = "utf-8" )
242+
243+ n = len (self .summary )
244+ s = "s" if n > 1 else ""
245+ duration_str = humanize .naturaldelta (duration )
246+ print ()
247+ self ._start_fold_group (f"{ n } wheel{ s } produced in { duration_str } " )
248+ for build_info in self .summary :
249+ print (" " , build_info )
250+ self ._end_fold_group ()
251+
252+ self .summary = []
253+
186254 @property
187255 def step_active (self ) -> bool :
188256 return self .step_start_time is not None
@@ -222,6 +290,72 @@ def _fold_group_identifier(name: str) -> str:
222290 # lowercase, shorten
223291 return identifier .lower ()[:20 ]
224292
293+ def _github_step_summary (self , duration : float , options : "Options" ) -> str :
294+ """
295+ Returns the GitHub step summary, in markdown format.
296+ """
297+ out = io .StringIO ()
298+ options_summary = options .summary (
299+ identifiers = [bi .identifier for bi in self .summary ], skip_unset = True
300+ )
301+ out .write (
302+ textwrap .dedent ("""\
303+ ### 🎡 cibuildwheel
304+
305+ <details>
306+ <summary>
307+ Build options
308+ </summary>
309+
310+ ```yaml
311+ {options_summary}
312+ ```
313+
314+ </details>
315+
316+ """ ).format (options_summary = options_summary )
317+ )
318+ n_wheels = len ([b for b in self .summary if b .filename ])
319+ wheel_rows = "\n " .join (
320+ "<tr>"
321+ f"<td nowrap>{ '<samp>' + b .filename .name + '</samp>' if b .filename else '*Build only*' } </td>"
322+ f"<td nowrap>{ b .size or 'N/A' } </td>"
323+ f"<td nowrap><samp>{ b .identifier } </samp></td>"
324+ f"<td nowrap>{ humanize .naturaldelta (b .duration )} </td>"
325+ f"<td nowrap><samp>{ b .sha256 or 'N/A' } </samp></td>"
326+ "</tr>"
327+ for b in self .summary
328+ )
329+ out .write (
330+ textwrap .dedent ("""\
331+ <table>
332+ <thead>
333+ <tr>
334+ <th align="left">Wheel</th>
335+ <th align="left">Size</th>
336+ <th align="left">Build identifier</th>
337+ <th align="left">Time</th>
338+ <th align="left">SHA256</th>
339+ </tr>
340+ </thead>
341+ <tbody>
342+ {wheel_rows}
343+ </tbody>
344+ </table>
345+ <div align="right"><sup>{n} wheel{s} created in {duration_str}</sup></div>
346+ """ ).format (
347+ wheel_rows = wheel_rows ,
348+ n = n_wheels ,
349+ duration_str = humanize .naturaldelta (duration ),
350+ s = "s" if n_wheels > 1 else "" ,
351+ )
352+ )
353+
354+ out .write ("\n " )
355+ out .write ("---" )
356+ out .write ("\n " )
357+ return out .getvalue ()
358+
225359 @property
226360 def colors (self ) -> Colors :
227361 return Colors (enabled = self .colors_enabled )
0 commit comments