From 1db0f1057b6817b626d550b02a7536ea2705941f Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Thu, 22 Oct 2026 11:06:12 -0400 Subject: [PATCH] fix(ci): read pip's indented ERROR line in util/pip_install.py (#2041) - reason() strips each line's leading whitespace before matching the `ERROR: ` prefix, so a failed wheel build's indented ` ERROR: Failed building wheel for ...` names the annotation instead of the no-ERROR-line fallback - tests: a fixture from the pypy3.10 (KTLO) failure log, its exact annotation, and the parity case where the former shell's `^ERROR: ` now diverges on purpose --- tests/project/test_pip_install_1967.py | 39 +++++++++++++++++++++++++- util/pip_install.py | 21 ++++++++++---- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/tests/project/test_pip_install_1967.py b/tests/project/test_pip_install_1967.py index 349529fca1..b2f61639a8 100644 --- a/tests/project/test_pip_install_1967.py +++ b/tests/project/test_pip_install_1967.py @@ -7,7 +7,8 @@ * :class:`TestTheFormerShellAgrees` runs the shell the steps held before the script against the same stand-in and requires the same output, log and status; for the steps that had no annotation, the same except for the annotation a - failed install now writes. + failed install now writes; and for an indented ``ERROR:`` line, the same + except for the reason the shell missed (#2041). * :class:`TestTheStepsRunTheScript` pins each covered step to the script, with its guards and environment, and every other ``pip install`` in ``unit-tests.yml`` to a listed step that keeps its shell. @@ -111,6 +112,16 @@ CLEAN = ['out:Obtaining file:///checkout', 'err:a deprecation warning', 'out:Successfully installed pypcapkit-1.5.0'] FAILED = ['out:Collecting pytest>=8', 'err:ERROR: Ignored the following versions: 8.0.0 Requires-Python >=3.8', 'out:INFO: pip is looking at multiple versions', 'err:ERROR: No matching distribution found for pytest>=8'] +#: The tail of a failed wheel build, as pip printed it on ``Python pypy3.10 (KTLO)``: +#: its one ``ERROR:`` line is indented, so the former shell found none (#2041). +WHEEL_FAILED = ['out: note: This error originates from a subprocess, and is likely not a problem with pip.', + 'out: ERROR: Failed building wheel for cryptography', 'out:Successfully built pypcapkit', + 'out:Failed to build cryptography', 'out:', + 'err:[notice] A new release of pip is available: 26.2.1 -> 26.2.2', + 'err:[notice] To update, run: pip install --upgrade pip', + 'err:error: failed-wheel-build-for-install', 'err:', + 'err:× Failed to build installable wheels for some pyproject.toml based projects', + 'err:╰─> cryptography'] #: Every ``(install output, install status)`` an install step can meet. OUTCOMES = ( (CLEAN, 0), @@ -195,6 +206,20 @@ def test_a_failure_without_a_reason_is_still_named(self) -> None: self.assertTrue(result.stdout.endswith(f'::warning title={TITLE}::{reason}{SKIPPED}\n'), result.stdout) + def test_an_indented_error_line_is_the_reason(self) -> None: + """Pip indents a failed wheel build's ``ERROR:`` line; the reason drops the indent and the prefix (#2041).""" + result = self.script('-e', SPEC, env=self.env(install_output=WHEEL_FAILED, install_status=1)) + self.assertEqual(result.returncode, 1, result.stderr) + self.assertEqual(result.stdout, text(WHEEL_FAILED) + f'::warning title={TITLE}::Failed building wheel ' + f'for cryptography{SKIPPED}\n') + for output, reason in ((['out:\tERROR: a tab', 'out: INFO: later'], 'a tab'), + (['err:ERROR: first', 'out: ERROR: last'], 'last'), + (['out:ERROR: first', 'out: ERROR: '], NO_REASON)): + with self.subTest(output=output): + result = self.script('-e', SPEC, env=self.env(install_output=output, install_status=1)) + self.assertTrue(result.stdout.endswith(f'::warning title={TITLE}::{reason}{SKIPPED}\n'), + result.stdout) + def test_a_signal_is_a_failed_install(self) -> None: result = self.script('-e', SPEC, env=self.env(install_output=FAILED, install_status=-signal.SIGTERM)) self.assertEqual(result.returncode, 1, result.stderr) @@ -274,6 +299,18 @@ def test_every_annotated_outcome_matches(self) -> None: install_status=status) self.assertEqual(new, old) + def test_an_indented_error_line_departs_on_purpose(self) -> None: + """The former shell's ``^ERROR: `` missed an indented line and fell back; the script names it + (#2041). Everything else still matches, and so does an outcome where both find the same line.""" + for level in ('error', 'warning'): + with self.subTest(level=level): + (old, _), (new, _) = self.both(FORMER_ANNOTATED, level, install_output=WHEEL_FAILED, + install_status=1) + self.assertEqual((new[0], new[2], new[3]), (old[0], old[2], old[3])) + head = text(WHEEL_FAILED) + f'::{level} title={TITLE}::' + self.assertEqual(old[1], f'{head}{NO_REASON}{SKIPPED}\n') + self.assertEqual(new[1], f'{head}Failed building wheel for cryptography{SKIPPED}\n') + def test_a_failed_upgrade_matches_on_both_forms(self) -> None: for shell in (FORMER_ANNOTATED, FORMER_BARE): for status in (1, 3, -signal.SIGTERM): diff --git a/util/pip_install.py b/util/pip_install.py index 3a35311acf..5edb829581 100644 --- a/util/pip_install.py +++ b/util/pip_install.py @@ -18,10 +18,11 @@ output, which it copies, as it arrives, both to its own standard output and to :file:`$RUNNER_TEMP/pip-install.log`. On a failure it writes one ``ANNOTATION_LEVEL`` annotation, ``pcapkit did not install on LEG``, whose - message is pip's last ``ERROR:`` line without that prefix, or a fallback when - there is none, and exits 1. The title escapes ``,`` as ``%2C``, since the runner - splits an annotation's properties on ``,`` (#1633), and the message ``%`` as - ``%25``. + message is pip's last ``ERROR:`` line without that prefix or its indent, since + pip indents the one naming a wheel it failed to build (#2041), or a fallback + when there is none, and exits 1. The title escapes ``,`` as ``%2C``, since the + runner splits an annotation's properties on ``,`` (#1633), and the message + ``%`` as ``%25``. On a KTLO leg the level is ``warning`` and the step carries ``continue-on-error``, so every later step, which waits on the install's ``outcome``, is skipped. An @@ -73,8 +74,16 @@ def status_of(code: 'int') -> 'int': def reason(output: 'bytes') -> 'bytes': - """Pip's last ``ERROR:`` line in ``output``, less the prefix, or :data:`NO_REASON`.""" - found = [line[len(PREFIX):] for line in output.split(b'\n') if line.startswith(PREFIX)] + """Pip's last ``ERROR:`` line in ``output``, less its indent and the prefix, or :data:`NO_REASON`. + + A failed wheel build's line is indented, `` ERROR: Failed building wheel for + cryptography``, and names the package and the step; pip's closing + ``Failed to build cryptography`` and ``error: failed-wheel-build-for-install`` + say less, and carry no ``ERROR:`` prefix (#2041). + + """ + lines = (line.lstrip() for line in output.split(b'\n')) + found = [line[len(PREFIX):] for line in lines if line.startswith(PREFIX)] return found[-1] if found and found[-1] else NO_REASON