Repository: pypa/cibuildwheel

Issue:
Tiny feat: Add printout of produced wheels at end of logs
One thing I have in common for all my uses of cibuildwheel is:

```yaml
    - uses: joerick/cibuildwheel@v1.8.0

    - name: Show files
      run: ls -lh wheelhouse
      shell: bash
```

It might be a nice to list the built wheel names at the end of the logs, so it's easy to tell what's been produced. (If actions/upload-artifact printed the name of the files it was uploading, this wouldn't be so important, but it doesn't)


Discussion:
Yeah, good idea!

Resolving patch:
diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py
--- a/cibuildwheel/__main__.py
+++ b/cibuildwheel/__main__.py
@@ -12,6 +12,7 @@
 import cibuildwheel
 import cibuildwheel.linux
 import cibuildwheel.macos
+import cibuildwheel.util
 import cibuildwheel.windows
 from cibuildwheel.architecture import Architecture, allowed_architectures_check
 from cibuildwheel.environment import EnvironmentParseError, parse_environment
@@ -281,14 +282,15 @@ def main() -> None:
     if not output_dir.exists():
         output_dir.mkdir(parents=True)
 
-    if platform == 'linux':
-        cibuildwheel.linux.build(build_options)
-    elif platform == 'windows':
-        cibuildwheel.windows.build(build_options)
-    elif platform == 'macos':
-        cibuildwheel.macos.build(build_options)
-    else:
-        assert_never(platform)
+    with cibuildwheel.util.print_new_wheels("{n} wheels produced in {m:.0f} minutes:", output_dir):
+        if platform == 'linux':
+            cibuildwheel.linux.build(build_options)
+        elif platform == 'windows':
+            cibuildwheel.windows.build(build_options)
+        elif platform == 'macos':
+            cibuildwheel.macos.build(build_options)
+        else:
+            assert_never(platform)
 
 
 def detect_obsolete_options() -> None:
diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py
--- a/cibuildwheel/util.py
+++ b/cibuildwheel/util.py
@@ -1,14 +1,16 @@
+import contextlib
 import fnmatch
 import itertools
 import os
 import re
 import ssl
 import textwrap
+import time
 import urllib.request
 from enum import Enum
 from pathlib import Path
 from time import sleep
-from typing import Dict, List, NamedTuple, Optional, Set
+from typing import Dict, Iterator, List, NamedTuple, Optional, Set
 
 import bracex
 import certifi
@@ -251,3 +253,23 @@ def unwrap(text: str) -> str:
     text = text.strip()
     # remove consecutive whitespace
     return re.sub(r'\s+', ' ', text)
+
+
+@contextlib.contextmanager
+def print_new_wheels(msg: str, output_dir: Path) -> Iterator[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 = final_contents - existing_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}" for f in new_contents), sep="\n")


Test patch:
diff --git a/unit_test/main_tests/conftest.py b/unit_test/main_tests/conftest.py
--- a/unit_test/main_tests/conftest.py
+++ b/unit_test/main_tests/conftest.py
@@ -1,3 +1,4 @@
+import contextlib
 import platform as platform_module
 import subprocess
 import sys
@@ -58,6 +59,15 @@ def mock_path_exists(path):
     return args
 
 
+@pytest.fixture(autouse=True)
+def disable_print_wheels(monkeypatch):
+    @contextlib.contextmanager
+    def empty_cm(*args, **kwargs):
+        yield
+
+    monkeypatch.setattr(util, 'print_new_wheels', empty_cm)
+
+
 @pytest.fixture
 def allow_empty(request, monkeypatch, fake_package_dir):
     monkeypatch.setattr(sys, 'argv', fake_package_dir + ['--allow-empty'])
diff --git a/unit_test/test_wheel_print.py b/unit_test/test_wheel_print.py
new file mode 100644
--- /dev/null
+++ b/unit_test/test_wheel_print.py
@@ -0,0 +1,35 @@
+import pytest
+
+from cibuildwheel.util import print_new_wheels
+
+
+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").touch()
+        tmp_path.joinpath("example.2").touch()
+
+    captured = capsys.readouterr()
+    assert captured.err == ""
+
+    assert "example.0" not in captured.out
+    assert "example.1\n" in captured.out
+    assert "example.2\n" in captured.out
+    assert "TEST_MSG:" in captured.out
+    assert "TEST_MSG: 2\n" in captured.out
+
+
+def test_no_printout_on_error(tmp_path, capsys):
+    tmp_path.joinpath("example.0").touch()
+    with pytest.raises(RuntimeError):
+        with print_new_wheels("TEST_MSG: {n}", tmp_path):
+            tmp_path.joinpath("example.1").touch()
+            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