diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e94261fb0..02a09c11b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -160,7 +160,7 @@ jobs: - uses: actions/checkout@v7 # One interpreter, not a matrix. Unit Tests and Python Compatibility sweep - # 3.11-3.15, plus the non-blocking KTLO legs 3.8-3.10 (#1633), because + # 3.11-3.15, plus the non-blocking KTLO legs 3.7-3.10 (#1633), because # they check that the package *runs* everywhere; these tools read the # source, and running them several times over would produce several # copies of the same findings for several times the runner cost. diff --git a/.github/workflows/python-compatibility.yml b/.github/workflows/python-compatibility.yml index 682175954..132409845 100644 --- a/.github/workflows/python-compatibility.yml +++ b/.github/workflows/python-compatibility.yml @@ -31,11 +31,11 @@ jobs: # # #1633: two tiers. The supported tier, 3.11-3.15, keeps the plain name, # which is what ruleset 23497679 requires one context per version of. The - # KTLO tier -- end-of-life versions that still run, 3.8-3.10 -- is suffixed + # KTLO tier -- end-of-life versions that still run, 3.7-3.10 -- is suffixed # ` (KTLO)`, so no required context can ever match one, and carries # `continue-on-error`, so a failed leg shows red without failing the run. - # 3.6 and 3.7 are left out: neither can install and import pcapkit (see the - # note on `requires-python` in pyproject.toml). + # 3.7 is red by design: it cannot import pcapkit (see "Verify package"). 3.6 + # is left out: setup-python builds it only for retired Ubuntu images. name: Compat Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO)' || '' }} runs-on: ${{ matrix.os || 'ubuntu-latest' }} continue-on-error: ${{ matrix.ktlo == true }} @@ -49,8 +49,12 @@ jobs: - "3.14" - "3.15" # setup-python publishes 3.8 and 3.9 builds up to Ubuntu 24.04 only, and - # `ubuntu-latest` moves to 26.04 from 2026-10-19, hence the pin. + # `ubuntu-latest` moves to 26.04 from 2026-10-19, hence the pin. 3.7's + # newest is for the deprecated 22.04; see the `test` job in unit-tests.yml. include: + - python-version: "3.7" + ktlo: true + os: ubuntu-22.04 - python-version: "3.8" ktlo: true os: ubuntu-24.04 @@ -71,7 +75,26 @@ jobs: python -m pip install -U pip setuptools wheel python -m pip install -e . + # One annotation saying why, so a red leg -- 3.7's, today, where the + # editable install succeeds and 41 modules do not compile -- is legible + # from the run summary without opening the log. Each title escapes `,` as + # `%2C`: the runner splits a command's properties on `,`, so the KTLO + # leg's title would otherwise stop at "(KTLO" (actions/runner + # src/Runner.Common/ActionCommand.cs:100, at 5f0b3ddcc). - name: Verify package + shell: bash + env: + LEG: Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO, non-blocking)' || '' }} run: | - python -m compileall -q pcapkit - python -c 'import pcapkit; print(pcapkit.__version__)' + log="$RUNNER_TEMP/compileall.log" + if ! python -m compileall -q pcapkit > "$log" 2>&1; then + cat "$log" + count=$(awk '/^\*\*\* Error compiling /{n++} END{print n+0}' "$log") + first=$(awk '/^ File "/{sub(/^ /, ""); print; exit}' "$log") + echo "::error title=pcapkit does not compile on ${LEG//,/%2C}::$count modules fail to compile, the first at ${first:-an unreported place}; see this step's log." + exit 1 + fi + if ! python -c 'import pcapkit; print(pcapkit.__version__)'; then + echo "::error title=pcapkit does not import on ${LEG//,/%2C}::See the traceback in this step's log." + exit 1 + fi diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 11229235d..91e192f72 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -80,6 +80,18 @@ jobs: # as a red step and an error annotation. 3.8 and 3.9 need # ubuntu-24.04: setup-python has no Ubuntu 26.04 build of either, and # `ubuntu-latest` moves to 26.04 from 2026-10-19. + # + # 3.7 needs ubuntu-22.04, the newest image setup-python builds it for + # (3.7.17). It cannot install today -- the `test` extra's `pytest>=8` + # needs 3.8, and the sources use syntax 3.7 cannot parse -- so the leg + # stops at its install step, with one annotation naming pip's reason + # (see that step). ubuntu-22.04 is deprecated: brownouts from + # 2027-03-23, retirement on 2027-04-17 (actions/runner-images#14254). + # A brownout fails the leg before any step runs, which no step-level + # `continue-on-error` can contain, so this leg must go before then. + - python-version: "3.7" + ktlo: true + os: ubuntu-22.04 - python-version: "3.8" ktlo: true os: ubuntu-24.04 @@ -93,6 +105,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 + id: python continue-on-error: ${{ matrix.ktlo == true }} with: python-version: ${{ matrix.python-version }} @@ -127,11 +140,29 @@ jobs: # HAS_PYPCAPFILE-gated methods between them), deliberately apart from # this job rather than added to it -- see either job's own comment for # why. + # + # #1633: a KTLO leg that cannot install -- 3.7 today -- fails fast and + # once: one annotation names pip's last ERROR line (its title's `,` as + # `%2C`, as the runner splits properties on `,`), and every later step + # needs `steps.install.outcome == 'success'`, so it is skipped rather + # than failing against a missing package. The install needs its own + # interpreter: if setup-python failed, the image's `python` would be + # tested under this leg's name. On a supported leg either fails the job. - name: Install package and test dependencies + id: install + if: ${{ steps.python.outcome == 'success' }} continue-on-error: ${{ matrix.ktlo == true }} + shell: bash + env: + LEG: Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO, non-blocking)' || '' }} run: | python -m pip install -U pip setuptools wheel - python -m pip install -e '.[test,DPKT,crypto,NGAP]' + if ! python -m pip install -e '.[test,DPKT,crypto,NGAP]' 2>&1 | tee "$RUNNER_TEMP/pip-install.log"; then + reason=$(sed -n 's/^ERROR: //p' "$RUNNER_TEMP/pip-install.log" | tail -n 1) + if [ -z "$reason" ]; then reason="pip failed without an ERROR line; see this step's log"; fi + echo "::error title=pcapkit did not install on ${LEG//,/%2C}::${reason//%/%25}. The rest of this leg is skipped." + exit 1 + fi # 7.10 is the first release with `[run] patch = ["subprocess"]`, which # .github/coverage.toml relies on to measure the xdist workers. @@ -143,6 +174,7 @@ jobs: # the log says what that was -- print it so the number `-n auto` picked # is a measured fact instead of an assumption about runner size. - name: Report available parallelism + if: ${{ steps.install.outcome == 'success' }} continue-on-error: ${{ matrix.ktlo == true }} run: | nproc @@ -150,6 +182,7 @@ jobs: - name: Run unit tests id: tests + if: ${{ steps.install.outcome == 'success' }} continue-on-error: ${{ matrix.ktlo == true }} # `--dist load` is pinned explicitly rather than left to default: a # future change to xdist's own default, or someone "optimising" to @@ -294,7 +327,7 @@ jobs: # `continue-on-error` because the file is only diagnostic: an artifact # service outage must not fail a leg that `Required checks passed` needs. - name: Upload test timings - if: ${{ !cancelled() }} + if: ${{ !cancelled() && steps.install.outcome == 'success' }} continue-on-error: true uses: actions/upload-artifact@v7 with: @@ -319,6 +352,9 @@ jobs: - "3.14" - "3.15" include: + - python-version: "3.7" + ktlo: true + os: ubuntu-22.04 - python-version: "3.8" ktlo: true os: ubuntu-24.04 @@ -332,6 +368,7 @@ jobs: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 + id: python continue-on-error: ${{ matrix.ktlo == true }} with: python-version: ${{ matrix.python-version }} @@ -359,15 +396,28 @@ jobs: # make installing it here a regression, now fixed by #743/#746. #751's # `pypcap-parity` job below covers those 6 methods instead, on the same # fixture-tier selection as this job but in a venv of its own. + # + # A KTLO leg that cannot install stops here, as in the `test` job above. - name: Install package, test and generator dependencies + id: install + if: ${{ steps.python.outcome == 'success' }} continue-on-error: ${{ matrix.ktlo == true }} + shell: bash + env: + LEG: Python ${{ matrix.python-version }}${{ matrix.ktlo && ' (KTLO, non-blocking)' || '' }} run: | python -m pip install -U pip setuptools wheel - python -m pip install -e '.[test,Scapy,DPKT,cli]' + if ! python -m pip install -e '.[test,Scapy,DPKT,cli]' 2>&1 | tee "$RUNNER_TEMP/pip-install.log"; then + reason=$(sed -n 's/^ERROR: //p' "$RUNNER_TEMP/pip-install.log" | tail -n 1) + if [ -z "$reason" ]; then reason="pip failed without an ERROR line; see this step's log"; fi + echo "::error title=pcapkit did not install on ${LEG//,/%2C}::${reason//%/%25}. The rest of this leg is skipped." + exit 1 + fi # `shell: bash` rather than the default, because it runs with -o pipefail: # without it the `tee` below would swallow the generator's exit status. - name: Regenerate sample captures + if: ${{ steps.install.outcome == 'success' }} continue-on-error: ${{ matrix.ktlo == true }} shell: bash run: | @@ -393,6 +443,7 @@ jobs: # See the `test` job above for why this step exists. - name: Report available parallelism + if: ${{ steps.install.outcome == 'success' }} continue-on-error: ${{ matrix.ktlo == true }} run: | nproc @@ -416,6 +467,7 @@ jobs: # say so. `--dist load` is pinned explicitly; see the `test` job above # for why. - name: Run full test suite + if: ${{ steps.install.outcome == 'success' }} continue-on-error: ${{ matrix.ktlo == true }} shell: bash run: | @@ -433,7 +485,7 @@ jobs: # See the `test` job's step of the same name. - name: Upload test timings - if: ${{ !cancelled() }} + if: ${{ !cancelled() && steps.install.outcome == 'success' }} continue-on-error: true uses: actions/upload-artifact@v7 with: @@ -496,9 +548,9 @@ jobs: # with step-level `continue-on-error: true` -- the treatment #845 gave the # then-prerelease 3.15 cell -- so a 3.10 failure shows as a failed step and an # error annotation while the job, and `required-checks`, which reads its - # result, stay green. 3.8 and 3.9 are not cells here: setup-python has no - # Ubuntu 26.04 build of either, and this job installs every interpreter on - # `ubuntu-latest`; PCAP_CT's extra is also `>= '3.10'`. + # result, stay green. 3.7-3.9 are not cells here: setup-python has no + # Ubuntu 26.04 build of any of them, and this job installs every interpreter + # on `ubuntu-latest`; PCAP_CT's extra is also `>= '3.10'`. # # Scoped deliberately out (#845): per-engine integration coverage (needs # regenerated captures, so it stays in aggregate in `integration` and @@ -805,7 +857,7 @@ jobs: 'read timed out|connection (reset|refused|aborted)|temporary failure in name resolution|max retries exceeded|could not fetch url|newconnectionerror|readtimeouterror|httpsconnectionpool|retrying \(retry\(|http error (5[0-9][0-9]|429)|status code 429|too many 429 error responses|no matching distribution found' \ && ! echo "$output" | grep -qiE \ 'failed building wheel|error: command .* failed with exit code|subprocess-exited-with-error'; then - echo "::error title=$ENGINE's install failure on Python $PY looks transient, not a compile failure::pip exited $status, but the output matches a network/index failure pattern rather than the C-API compile failure this cell records as expected. Re-run the job rather than trusting this as a genuine 'not-installable' result." + echo "::error title=$ENGINE's install failure on Python $PY looks transient%2C not a compile failure::pip exited $status, but the output matches a network/index failure pattern rather than the C-API compile failure this cell records as expected. Re-run the job rather than trusting this as a genuine 'not-installable' result." exit 1 fi echo "::notice title=$ENGINE is not installable on Python $PY (expected)::pip exited $status, matching this cell's recorded expectation ('not-installable'), and the output does not match a transient-failure pattern. No tests run for this cell -- this is a deliberate skip, not a pass." @@ -878,7 +930,7 @@ jobs: if ! bash "$RUNNER_TEMP/engine-cell.sh" 3.10; then echo "| 3.10 | $ENGINE | | FAILED (KTLO, non-blocking) |" >> "$GITHUB_STEP_SUMMARY" echo "::endgroup::" - echo "::error title=$ENGINE failed on Python 3.10 (KTLO, non-blocking)::3.10 is end-of-life and KTLO per #1633; this does not fail the job." + echo "::error title=$ENGINE failed on Python 3.10 (KTLO%2C non-blocking)::3.10 is end-of-life and KTLO per #1633; this does not fail the job." exit 1 fi echo "::endgroup::" @@ -1613,7 +1665,7 @@ jobs: # here: the cell script exits 0 for a cell that declined exactly as # expected, so they contribute to that job's `success` like any other cell. # - # Nor is the KTLO tier (#1633): engine-tests' 3.10 cell, and the 3.8-3.10 + # Nor is the KTLO tier (#1633): engine-tests' 3.10 cell, and the 3.7-3.10 # legs of `test` and `integration` and the 3.10 leg of `pypcap-parity`. # Each is non-blocking through step-level `continue-on-error`, whose effect # on the job's result *is* documented (a step's `conclusion` is its result @@ -1621,7 +1673,9 @@ jobs: # `success` while the failed step stays visible. Not a job-level # `continue-on-error`: GitHub does not document whether `needs..result` # sees such a job's failure as `failure` or `success`, so a KTLO-only - # regression could block every merge. + # regression could block every merge. The one failure no step can contain is + # a leg that never gets a runner: the 3.7 legs' deprecated ubuntu-22.04 image + # has brownouts from 2027-03-23 -- see the `test` job's matrix. # # The docs-only path. On a pull request whose diff touches nothing but # `docs/**` and `*.md` files outside the code directories (the `changes` diff --git a/README.md b/README.md index 893c59330..fbbd44687 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ Where that depth is not needed, the same interface also drives six third-party extraction engines. The whole project declares **Python 3.6** or later. CI supports 3.11 to 3.15, and -still tests the end-of-life 3.8 to 3.10 without letting a failure block anything. +still tests the end-of-life 3.7 to 3.10 without letting a failure block anything; +3.7 cannot import the package yet. ## Installation diff --git a/SECURITY.md b/SECURITY.md index d3544955c..9868b1d02 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,8 +14,8 @@ lines are not patched -- upgrading is the fix. Supported interpreters are the test matrix's blocking tier: CPython 3.11 through 3.15. `pyproject.toml` declares `requires-python = ">=3.6, <4"` through the -`bpc-*` source conversion, but 3.8 to 3.10 are best-effort (tested, never blocking) -and below 3.8 is intent rather than something exercised -- see the note above +`bpc-*` source conversion, but 3.7 to 3.10 are best-effort (tested, never blocking) +and below 3.8 is intent rather than something that works -- see the note above `requires-python` in `pyproject.toml`. A report that only reproduces outside the tested range is still welcome; it may be fixed by raising the floor rather than by patching. diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index bbdd5330e..64113121c 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -588,8 +588,8 @@ Test Cases **Largely done.** There is a systematic test suite under ``tests/``, and it runs in CI against Python 3.11 through 3.15, per -``.github/workflows/unit-tests.yml``, and against the end-of-life 3.8 through 3.10 -on legs whose failures block nothing. +``.github/workflows/unit-tests.yml``, and against the end-of-life 3.7 through 3.10 +on legs whose failures block nothing; 3.7's stop at the install. The module count is measurable rather than worth writing down: .. code-block:: shell diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index eb850f62f..4de9bb7f0 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -46,7 +46,7 @@ Editing ``__version__`` by hand without also moving :file:`CITATION.cff`'s (``:494-514``) asserts the two agree, and ``create-release.yml``'s ``unit-tests`` job (``:401-415``) calls ``unit-tests.yml`` with ``gate-only: true``, which runs the **full** suite rather than the tiered -subset an ordinary push runs (its ``gate`` job, ``unit-tests.yml:1481-1550``). +subset an ordinary push runs (its ``gate`` job, ``unit-tests.yml:1533-1602``). That gate runs whenever a release will -- a moved ``__version__`` is exactly what makes ``version_check``'s evidence read "not yet published" -- and every publishing job requires it to have succeeded (``:434,619,784,997``), so a @@ -56,7 +56,7 @@ Either run the script, or move both fields by hand in the same commit. This is the **only** edit a person makes to get a release started -- the tag, the Release, and every upload are the workflow's job from here. Two other checks run unconditionally on the same path, though neither is a step in *this* -process: the ``changelog`` job (``unit-tests.yml:1341-1362``) fails outright if +process: the ``changelog`` job (``unit-tests.yml:1393-1414``) fails outright if ``CHANGELOG.md`` has drifted from its source entry under :file:`docs/source/changelog/`, and the release-body step warns, without failing, if that entry's heading still reads "unreleased" diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index 94ec46aae..8300cee68 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -261,8 +261,8 @@ repository target the same reusable workflow, with the same input: ``gate-only: true`` selects **two** of ``unit-tests.yml``'s **ten** jobs, not one: ``gate`` (the full suite, one Python version) and ``changelog`` (checks ``CHANGELOG.md`` against its source entry). It skips the other -**eight** -- the five matrix jobs, ``test`` (eight Python legs: five -supported, three KTLO), ``integration`` (eight), ``engine-tests`` (one job +**eight** -- the five matrix jobs, ``test`` (nine Python legs: five +supported, four KTLO), ``integration`` (nine), ``engine-tests`` (one job for each of five engines, each looping over six Python versions), ``pypcap-parity`` (two) and ``unittest-ordering`` (eleven matrix cells), each of which has already run once for this commit @@ -274,7 +274,7 @@ and ``project-tests``, and ``required-checks`` (see `Required Status Checks`_ below), gated out by their own ``if:`` rather than by having already run. ``changelog`` carries no ``if:`` at all and runs on every path regardless -- deliberately, per its own -comment (``unit-tests.yml:1329-1335``): ``create-release.yml`` feeds +comment (``unit-tests.yml:1381-1387``): ``create-release.yml`` feeds ``CHANGELOG.md`` to the GitHub Release body, so the release path is exactly where a drifted file must not go unchecked. Confirmed on run `36210743295 `__ (a @@ -542,7 +542,7 @@ grepping every workflow file for its name: - Where * - ``Required checks passed`` - job ``required-checks`` - - ``unit-tests.yml:1640`` + - ``unit-tests.yml:1694`` * - ``Compat Python 3.11`` - job ``compatibility``, matrix leg ``3.11`` - ``python-compatibility.yml:39,46`` @@ -564,19 +564,19 @@ which added ``Compat Python 3.15`` and removed ``Compat Python 3.10``. Moving a between the tiers below means editing the ruleset in the same change. ``Required checks passed`` is defined by exactly one job -(``unit-tests.yml:1640``). ``Compat Python`` is defined by one, the +(``unit-tests.yml:1694``). ``Compat Python`` is defined by one, the ``compatibility`` job in ``python-compatibility.yml`` (``:39``), expanding to -the five required legs at ``:46-50`` and to three KTLO legs at ``:53-61`` whose +the five required legs at ``:46-50`` and to four KTLO legs at ``:55-65`` whose names end in ``(KTLO)``, so no required context matches one. A comment at -``unit-tests.yml:1553`` mentions the string without defining it. +``unit-tests.yml:1605`` mentions the string without defining it. ``Required checks passed`` is an aggregate, not a single check run. The ruleset requires it and the five ``Compat Python 3.11``-``3.15`` legs, nothing else, as -``unit-tests.yml``'s own comment (``unit-tests.yml:1552-1562``) records. It +``unit-tests.yml``'s own comment (``unit-tests.yml:1604-1614``) records. It ``needs:`` the gating jobs of ``unit-tests.yml`` -- ``test``, ``integration``, ``engine-tests`` and ``pypcap-parity``, plus ``changes`` and ``project-tests`` for the docs-only path -- and checks each result explicitly -(``unit-tests.yml:1639-1693``). Rulesets match check names literally, with no +(``unit-tests.yml:1693-1747``). Rulesets match check names literally, with no wildcard, so requiring the matrix cells one by one would mean editing the ruleset whenever a matrix changes; the aggregate keeps the required list fixed. It accepts the four legs as ``skipped`` only on a docs-only pull request, where @@ -585,12 +585,12 @@ instead. The ``Compat`` legs stay required separately because they come from ``python-compatibility.yml``, and a job cannot ``needs:`` a job in another workflow file. This job runs on Unit Tests' own ``push``/``pull_request`` triggers; the ``gate-only: true`` reusable calls documented above skip it via -``if: ${{ always() && inputs.gate-only != true }}`` (``unit-tests.yml:1642``), +``if: ${{ always() && inputs.gate-only != true }}`` (``unit-tests.yml:1696``), so it is never produced -- and never expected -- on those paths. Python versions come in two tiers (#1633). The **supported** tier, 3.11-3.15, blocks: a failed leg fails ``Required checks passed`` or its own required -``Compat`` context. The **KTLO** tier -- end-of-life 3.8-3.10, still built and +``Compat`` context. The **KTLO** tier -- end-of-life 3.7-3.10, still built and tested -- never blocks and is never a required context. Each KTLO leg sits in the same job as the supported ones, so the two mechanisms differ by whether something aggregates the job: @@ -604,10 +604,19 @@ aggregates the job: documented (``unit-tests.yml:74-82``); ``engine-tests`` runs its 3.10 cell as a step of its own with ``continue-on-error: true``. -3.6 and 3.7 are in no matrix: neither can install and import the package. Nor are -3.8 and 3.9 in ``engine-tests``, which installs every interpreter on -``ubuntu-latest``, where setup-python has no Ubuntu 26.04 build of either. Nor are -they in ``pypcap-parity``, which also runs on ``ubuntu-latest``; neither version +3.7 can neither install the ``test`` extra nor import the package, so each 3.7 leg +fails one step, with one error annotation saying why: ``test`` and ``integration`` +at the install step, the ``Compat`` leg at ``Verify package``. Only the ``Compat`` +leg shows red, through its job-level ``continue-on-error``; the ``test`` and +``integration`` legs conclude ``success``, their failed step and annotation inside. +They run on ``ubuntu-22.04``, which retires on 2027-04-17 after brownouts from +2027-03-23 (actions/runner-images#14254). A brownout fails a ``test`` or +``integration`` leg before any step runs, where no ``continue-on-error`` reaches, +and so fails ``Required checks passed``: they must go before then (#1660). 3.6 is +in no matrix: setup-python builds it only for retired Ubuntu images. Nor are +3.7-3.9 in ``engine-tests``, which installs every interpreter on +``ubuntu-latest``, where setup-python has no Ubuntu 26.04 build of any of them. Nor +are they in ``pypcap-parity``, which also runs on ``ubuntu-latest``; none of them was measured on that job. Deployment ``environment:`` diff --git a/docs/source/index.rst b/docs/source/index.rst index 5d4a4f85a..5729ada96 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -11,7 +11,8 @@ construction and analysis. .. important:: The whole project declares **Python 3.6** or later. CI supports 3.11 to 3.15, and - still tests the end-of-life 3.8 to 3.10 without letting a failure block anything. + still tests the end-of-life 3.7 to 3.10 without letting a failure block anything; + 3.7 cannot import the package yet. .. .. contents:: .. :depth: 2 @@ -258,9 +259,10 @@ Installation .. note:: - 3.8, 3.9 and 3.10 are end-of-life and best-effort: CI builds and tests them, - but a failure there blocks nothing. Individual *engines* also stop earlier than - the library does; see `Engine prerequisites`_. + 3.7 to 3.10 are end-of-life and best-effort: CI builds and tests them, but a + failure there blocks nothing, and on 3.7 the import fails as described above. + Individual *engines* also stop earlier than the library does; see + `Engine prerequisites`_. Run the following to install the current version from PyPI: diff --git a/pyproject.toml b/pyproject.toml index 70d87db65..7354cb560 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,10 +45,11 @@ license = { text="BSD 3-Clause License" } # rather than something that works. # # Independently, CI has two tiers (#1633). **3.11 through 3.15** are supported and -# blocking. **3.8 through 3.10** are KTLO: end-of-life, still built and tested, never -# blocking. 3.6 and 3.7 are not run at all: on 3.6 the build fails (no -# ``setuptools>=61``), on 3.7 the import fails as above, and the ``test`` extra's -# ``pytest>=8`` needs 3.8 on both. The ``PCAP_CT`` extra needs 3.10 regardless. +# blocking. **3.7 through 3.10** are KTLO: end-of-life, still built and tested, never +# blocking. Each 3.7 leg fails a step by design: the import fails as above, and the +# ``test`` extra's ``pytest>=8`` needs 3.8. 3.6 is not run at all: setup-python +# builds it only for retired Ubuntu images, and the build needs ``setuptools>=61`` +# besides. The ``PCAP_CT`` extra needs 3.10 regardless. requires-python = ">=3.6, <4" description = "PyPCAPKit: comprehensive network packet analysis library" keywords = [ "network", "pcap", "packet" ] @@ -66,7 +67,7 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", # The supported tier only (#1633). A version classifier is a claim of - # support, which the KTLO tier (3.8-3.10) is by definition not: it may stay + # support, which the KTLO tier (3.7-3.10) is by definition not: it may stay # red without blocking anything. ``requires-python`` above is what keeps # those versions installable. "Programming Language :: Python :: 3.11", diff --git a/tests/_dependency_gates.py b/tests/_dependency_gates.py index ecef1ad94..bbbd63dd0 100644 --- a/tests/_dependency_gates.py +++ b/tests/_dependency_gates.py @@ -786,8 +786,8 @@ def _strip_comment(line: 'str') -> 'str': def _toml_array(text: 'str', name: 'str') -> 'Optional[tuple[Requirement, ...]]': """The ``name = [ ... ]`` array of double-quoted strings in ``text``. - A targeted scan rather than :mod:`tomllib`, which is 3.11+ while the - ``test`` job's matrix floor is 3.8 (a KTLO leg, #1633) -- and a guard that skips on + A targeted scan rather than :mod:`tomllib`, which is 3.11+ while the oldest + ``test`` leg to run the suite is 3.8 (a KTLO leg, #1633) -- and a guard that skips on some legs is the shape of invisibility this whole module exists to remove. Comments are stripped first, quote-aware, because the arrays here are heavily annotated; the brackets inside ``"requests[socks]"`` are what makes diff --git a/tests/project/test_annotation_names.py b/tests/project/test_annotation_names.py index f56be076c..a4c1cf1cb 100644 --- a/tests/project/test_annotation_names.py +++ b/tests/project/test_annotation_names.py @@ -91,10 +91,10 @@ def _as_string(node: 'Optional[ast.expr]') -> 'Iterator[tuple[str, int]]': #: :class:`ast.TypeAlias` where the running interpreter has it, else ``()``. #: #: ``ast.TypeAlias`` and ``ast.TypeVar`` are :pep:`695` nodes, new in Python 3.12, -#: and CI's range starts at 3.8 (KTLO, #1633), the supported tier at 3.11 -- so -#: naming either one directly raises :exc:`AttributeError` on 3.8-3.11, and does so -#: in *every* test in this module rather -#: than one, because :func:`bound_names` walks every node of every file. Resolving +#: and the oldest CI leg to run the suite is 3.8 (KTLO, #1633), the oldest supported +#: one 3.11 -- so naming either one directly raises :exc:`AttributeError` on 3.8-3.11, +#: and does so in *every* test in this module rather than one, because +#: :func:`bound_names` walks every node of every file. Resolving #: them through :func:`getattr` to an empty tuple instead leaves both branches #: fully live on 3.12+ and merely unreachable below it, since :func:`isinstance` #: against ``()`` is always :data:`False`. diff --git a/tests/project/test_ci_timing.py b/tests/project/test_ci_timing.py index fa5298da4..ee9ee339a 100644 --- a/tests/project/test_ci_timing.py +++ b/tests/project/test_ci_timing.py @@ -690,8 +690,10 @@ def test_every_unit_and_integration_leg_uploads_its_timings(self) -> 'None': for block in blocks: with self.subTest(step=block.split('\n', 3)[:3]): # A failed upload of a diagnostic file must not fail a required leg. + # It runs after failed tests too, but not after a failed install + # (#1633), which leaves no file to upload. self.assertIn('continue-on-error: true', block) - self.assertIn('if: ${{ !cancelled() }}', block) + self.assertIn("if: ${{ !cancelled() && steps.install.outcome == 'success' }}", block) self.assertIn('path: ${{ runner.temp }}/junit.xml', block) # tests/_dependency_gates.py reads exactly one pytest step per job. self.assertNotIn('python -m pytest', block) diff --git a/tests/project/test_pyshark_encap_map.py b/tests/project/test_pyshark_encap_map.py index e8c295ff3..7e2876a0d 100644 --- a/tests/project/test_pyshark_encap_map.py +++ b/tests/project/test_pyshark_encap_map.py @@ -274,8 +274,8 @@ def make_tmp_dir(case: unittest.TestCase) -> pathlib.Path: :meth:`unittest.TestCase.enterContext` would do this in one line, but it is Python 3.11+ and this repository supports back to - ``requires-python = ">=3.6, <4"`` (:file:`pyproject.toml`) -- CI's own - oldest tested leg is ``Python 3.8 (KTLO)``, where ``enterContext`` does not + ``requires-python = ">=3.6, <4"`` (:file:`pyproject.toml`) -- the oldest + CI leg to run the suite is ``Python 3.8 (KTLO)``, where ``enterContext`` does not exist at all (``AttributeError``). ``TemporaryDirectory.cleanup`` plus :meth:`~unittest.TestCase.addCleanup` is the same "clean up after the test regardless of outcome" guarantee, built from APIs both present since diff --git a/tests/project/test_python_matrix_tiers_1633.py b/tests/project/test_python_matrix_tiers_1633.py index 712c42dc8..4b36d0bfc 100644 --- a/tests/project/test_python_matrix_tiers_1633.py +++ b/tests/project/test_python_matrix_tiers_1633.py @@ -7,8 +7,8 @@ * **Supported** -- 3.11 to 3.15, in every matrix (unit tests, compatibility, conda, PyPI), blocking as before. * **KTLO** -- end-of-life versions that still build and test (3.10 wherever it - ran, plus 3.8 and 3.9 in the test matrices), never blocking: not CI, not a - release, not a required context. + ran, plus 3.7, 3.8 and 3.9 in the test matrices), never blocking: not CI, not + a release, not a required context. * ``requires-python`` stays ``>=3.6, <4``. A KTLO leg can block in more ways than one, so each is pinned on its own: a job @@ -21,6 +21,14 @@ execute the workflow's own shell, stubbing only ``curl``. The last class reads the prose that states the tiers back against the matrices. +3.7 joined the tier afterwards, at the owner's request, and cannot install or +import pcapkit: the ``test`` extra's ``pytest>=8`` needs 3.8, and the sources use +3.8 syntax. So each of its legs fails a step by design -- only Compat's shows red, +its ``continue-on-error`` being job-level -- and :class:`TestPython37IsKTLO` pins +that they fail fast and say why -- the install and verify steps are executed with +``python`` stubbed, and each annotation is parsed as the runner parses it. 3.6 +stays out: setup-python builds it only for retired images. + """ from __future__ import annotations @@ -42,7 +50,7 @@ yaml = None # type: ignore[assignment] if TYPE_CHECKING: - from typing import Any + from typing import Any, Optional ROOT = pathlib.Path(__file__).resolve().parents[2] WORKFLOWS = ROOT / '.github' / 'workflows' @@ -68,15 +76,25 @@ ('unit-tests.yml', 'pypcap-parity'), ('create-release.yml', 'pypi')) #: Where each KTLO leg must be, as (workflow, job, KTLO versions). KTLO_LEGS = ( - ('unit-tests.yml', 'test', ('3.8', '3.9', '3.10')), - ('unit-tests.yml', 'integration', ('3.8', '3.9', '3.10')), + ('unit-tests.yml', 'test', ('3.7', '3.8', '3.9', '3.10')), + ('unit-tests.yml', 'integration', ('3.7', '3.8', '3.9', '3.10')), ('unit-tests.yml', 'pypcap-parity', ('3.10',)), - ('python-compatibility.yml', 'compatibility', ('3.8', '3.9', '3.10')), + ('python-compatibility.yml', 'compatibility', ('3.7', '3.8', '3.9', '3.10')), ('create-release.yml', 'pypi', ('3.10', 'pypy3.10')), ) +#: The newest Ubuntu each pinned KTLO version has a setup-python build for; the +#: rest run on ``ubuntu-latest``. ubuntu-22.04 is deprecated and retires 2027-04-17. +PINNED_OS = {'3.7': 'ubuntu-22.04', '3.8': 'ubuntu-24.04', '3.9': 'ubuntu-24.04'} +#: The jobs 3.7 joins (#1633), each as (workflow, job, install-or-verify step). +LEGACY_37 = ( + ('unit-tests.yml', 'test', 'Install package and test dependencies'), + ('unit-tests.yml', 'integration', 'Install package, test and generator dependencies'), + ('python-compatibility.yml', 'compatibility', 'Verify package'), +) -#: Claims the tier split made false, each worded as it was somewhere before #1633. -#: A denylist: it catches these phrasings coming back and examines no others. +#: Claims the tier split made false, each worded as it was somewhere before #1633, +#: or before 3.7 joined the KTLO tier. A denylist: it catches these phrasings +#: coming back and examines no others. RETIRED_CLAIMS = re.compile('|'.join(( r'3\.15 as an allowed-to-fail leg', r'3\.15 runs allowed-to-fail', @@ -85,7 +103,20 @@ r'CI covers \**3\.10', r'runs on 3\.10 through 3\.1[45]', r'repository supports 3\.10', - r'matrix floor is 3\.10', + r'matrix floor is 3\.(?:10|8)', + r'3\.6 and 3\.7 are (?:in no matrix|left out|not run)', + r'KTLO tier \(3\.8-3\.10\)', + r'3\.8 through 3\.10\** are KTLO', + r'end-of-life (?:versions that still run, )?3\.8(?:-| to | through )3\.10', + r'the 3\.8-3\.10 legs', + r'KTLO legs 3\.8-3\.10', + r'3\.8 to 3\.10 are best-effort', + r'3\.8, 3\.9 and 3\.10 are end-of-life', + r"CI's range starts at 3\.8", + r'oldest tested leg is ``Python 3\.8', + # Only Compat's 3.7 leg shows red; step-level ``continue-on-error`` leaves + # the ``test`` and ``integration`` legs concluding ``success``. + r"3\.7's legs are red by design", ))) #: Where :data:`RETIRED_CLAIMS` is looked for, as (directory, globs), plus root files. #: The changelog is history and is left out. @@ -201,14 +232,14 @@ def test_each_ktlo_leg_is_there(self) -> None: found = sorted(leg['python-version'] for leg in legs(job(name, job_id)) if is_ktlo(leg)) self.assertEqual(found, sorted(versions)) - def test_38_and_39_run_where_setup_python_still_builds_them(self) -> None: - """No Ubuntu 26.04 build of either, and ``ubuntu-latest`` moves to 26.04.""" + def test_37_to_39_run_where_setup_python_still_builds_them(self) -> None: + """No Ubuntu 26.04 build of any, and ``ubuntu-latest`` moves to 26.04.""" for name, job_id, _ in KTLO_LEGS: spec = job(name, job_id) for leg in legs(spec): - if leg['python-version'] in ('3.8', '3.9'): + if leg['python-version'] in PINNED_OS: with self.subTest(workflow=name, job=job_id, python=leg['python-version']): - self.assertEqual(leg.get('os'), 'ubuntu-24.04') + self.assertEqual(leg.get('os'), PINNED_OS[leg['python-version']]) self.assertEqual(spec['runs-on'], "${{ matrix.os || 'ubuntu-latest' }}") def test_no_ktlo_leg_can_fail_an_aggregated_job(self) -> None: @@ -346,6 +377,269 @@ def test_no_conda_leg_is_ktlo_and_315_leaves_base_python_alone(self) -> None: "${{ matrix.python-version != '3.15' && matrix.python-version || '' }}") +#: ``python`` for an install step: ``pip install -U`` passes, ``pip install -e`` prints +#: ``$PIP_OUTPUT`` and exits ``$PIP_STATUS``. +PIP_STUB = '''\ +case "$*" in + "-m pip install -U pip setuptools wheel") exit 0 ;; + "-m pip install -e "*) printf '%s\\n' "$PIP_OUTPUT"; exit "$PIP_STATUS" ;; +esac +echo "unexpected python $*" >&2 +exit 99 +''' +#: ``python`` for ``Verify package``: compileall and the import, each scripted by env. +VERIFY_STUB = '''\ +case "$1" in + -m) printf '%s\\n' "$COMPILE_OUTPUT"; exit "$COMPILE_STATUS" ;; + -c) [ "$IMPORT_STATUS" != 0 ] || echo 1.5.0; exit "$IMPORT_STATUS" ;; +esac +exit 99 +''' +#: The head of ``python -m compileall -q pcapkit``'s output on CPython 3.7.16. +COMPILE_37 = '''\ +*** Error compiling 'pcapkit/const/ftp/command.py'... + File "pcapkit/const/ftp/command.py", line 286 + ABOR: 'Command' = 'ABOR', FEATCode.base, 'Abort', CommandType.S, ConformanceRequirement.M + ^ +SyntaxError: invalid syntax + +*** Error compiling 'pcapkit/const/ftp/return_code.py'... + File "pcapkit/const/ftp/return_code.py", line 116 + CODE_110: 'ReturnCode' = 110, 'Restart marker replay.' + ^ +SyntaxError: invalid syntax +''' +#: pip 24.0's last lines installing ``.[test,...]`` on CPython 3.7.16. +PIP_37 = ('INFO: pip is looking at multiple versions of pypcapkit[crypto,dpkt,ngap,test] to ' + 'determine which version is compatible with other requirements.\n' + 'ERROR: Ignored the following versions that require a different python version: ' + '8.0.0 Requires-Python >=3.8\n' + 'ERROR: Could not find a version that satisfies the requirement pytest>=8; ' + 'extra == "test" (from pypcapkit[crypto,dpkt,ngap,test]) (from versions: 7.4.4)\n' + 'ERROR: No matching distribution found for pytest>=8; extra == "test"') +LEG_37 = 'Python 3.7 (KTLO, non-blocking)' +#: :data:`LEG_37` as a ``title=`` property must carry it: ``,`` escaped (see :func:`runner_parse`). +TITLE_37 = 'Python 3.7 (KTLO%2C non-blocking)' + + +def workflow_commands(output: 'str') -> 'list[str]': + """The workflow-command lines of ``output``.""" + return [line for line in output.splitlines() if line.startswith('::')] + + +#: ``(escaped, character)`` pairs, in the order actions/runner un-escapes them. +PROPERTY_ESCAPES = (('%0D', '\r'), ('%0A', '\n'), ('%3A', ':'), ('%2C', ','), ('%25', '%')) +DATA_ESCAPES = (('%0D', '\r'), ('%0A', '\n'), ('%25', '%')) + + +def runner_parse(line: 'str') -> 'Optional[tuple[str, dict[str, str], str]]': + """``line`` as the runner reads it: ``(command, properties, message)``, or :data:`None`. + + A port of ``ActionCommand.TryParseV2`` and its un-escaping, from + ``src/Runner.Common/ActionCommand.cs`` in actions/runner at 5f0b3ddcc. The + properties end at the first ``::`` after the leading one (``:69``), and are + split on every ``,`` (``:100``, ``propertiesStr.Split(new[] { ',' }, ...)``) + *before* ``%2C`` becomes a comma (``:30``, ``:106``). A piece holding no + ``=`` is dropped without a word, so ``title=on 3.7 (KTLO, non-blocking)`` + renders as "on 3.7 (KTLO". + + """ + message = line.lstrip() + if not message.startswith('::'): + return None + end = message.find('::', 2) + if end < 0: + return None + command, _, rest = message[2:end].partition(' ') + properties = {} # type: dict[str, str] + for piece in filter(None, rest.strip().split(',')): + pair = [part for part in piece.split('=', 1) if part] + if len(pair) == 2: + value = pair[1] + for escaped, char in PROPERTY_ESCAPES: + value = value.replace(escaped, char) + properties[pair[0].lower()] = value # an OrdinalIgnoreCase dictionary + data = message[end + 2:] + for escaped, char in DATA_ESCAPES: + data = data.replace(escaped, char) + return command, properties, data + + +def titles(output: 'str') -> 'list[str]': + """Each workflow command's ``title`` in ``output``, as the run summary shows it.""" + parsed = (runner_parse(line) for line in workflow_commands(output)) + return [command[1].get('title', '') for command in parsed if command is not None] + + +class TestPython37IsKTLO(unittest.TestCase): + """3.7 runs, non-blocking, and stops at the first failure with one annotation.""" + + def setUp(self) -> None: + if shutil.which('bash') is None: # pragma: no cover + self.skipTest('bash is needed to run the workflow shell') + tmp = tempfile.TemporaryDirectory(prefix='pcapkit-1633-37-') # pylint: disable=consider-using-with + self.addCleanup(tmp.cleanup) + self.tmp = pathlib.Path(tmp.name) + + def run_step(self, name: 'str', job_id: 'str', step_name: 'str', stub: 'str', + env: 'dict[str, str]') -> 'subprocess.CompletedProcess[str]': + """Step ``step_name`` under ``shell: bash`` (``-eo pipefail``), ``python`` stubbed.""" + step = next(step for step in job(name, job_id)['steps'] if step.get('name') == step_name) + self.assertEqual(step.get('shell'), 'bash', 'pipefail is what keeps `tee` from hiding a failure') + self.assertEqual(step['env']['LEG'], "Python ${{ matrix.python-version }}" + "${{ matrix.ktlo && ' (KTLO, non-blocking)' || '' }}") + bin_dir = self.tmp / 'bin' + bin_dir.mkdir(exist_ok=True) + python = bin_dir / 'python' + python.write_text('#!/bin/bash\n' + stub, encoding='utf-8') + python.chmod(0o755) + return subprocess.run(['bash', '--noprofile', '--norc', '-eo', 'pipefail', '-c', step['run']], + env=dict(os.environ, PATH=f'{bin_dir}{os.pathsep}{os.environ["PATH"]}', + RUNNER_TEMP=str(self.tmp), LEG=LEG_37, **env), + capture_output=True, text=True, check=False, timeout=60) + + def test_37_is_a_ktlo_leg_of_exactly_the_three_test_matrices(self) -> None: + """On ubuntu-22.04; not engine-tests or pypcap-parity (``ubuntu-latest`` only), nor a release leg.""" + for name, job_id, _ in LEGACY_37: + spec = job(name, job_id) + with self.subTest(workflow=name, job=job_id): + found = [leg for leg in legs(spec) if leg['python-version'] == '3.7'] + self.assertEqual(found, [{'python-version': '3.7', 'ktlo': True, 'os': 'ubuntu-22.04'}]) + self.assertTrue(spec['name'].endswith("${{ matrix.ktlo && ' (KTLO)' || '' }}")) + joined = {(name, job_id) for name, job_id, _ in MATRICES + if '3.7' in [leg['python-version'] for leg in legs(job(name, job_id))]} + engines = [str(step['with']['python-version']) for step in job('unit-tests.yml', 'engine-tests')['steps'] + if str(step.get('uses', '')).startswith('actions/setup-python@')] + if '3.7' in ' '.join(engines).split(): + joined.add(('unit-tests.yml', 'engine-tests')) + self.assertEqual(joined, {(name, job_id) for name, job_id, _ in LEGACY_37}) + + def test_a_leg_that_cannot_install_stops_there(self) -> None: + """The install needs setup-python's interpreter, and every later step needs the install. + + ``outcome``, not ``conclusion``: ``continue-on-error`` turns a failed step's + conclusion into ``success``, so only its outcome still says it failed. + + """ + for name, job_id, install in LEGACY_37[:2]: + steps = job(name, job_id)['steps'] + setup = next(step for step in steps if str(step.get('uses', '')).startswith('actions/setup-python@')) + index = next(i for i, step in enumerate(steps) if step.get('name') == install) + with self.subTest(workflow=name, job=job_id): + self.assertEqual(setup.get('id'), 'python') + self.assertEqual(steps[index].get('id'), 'install') + self.assertEqual(steps[index].get('if'), "${{ steps.python.outcome == 'success' }}") + for step in steps[index + 1:]: + condition = str(step.get('if', '')) + if 'matrix.coverage' in condition: + continue # the 3.14 leg only, which a failed install fails anyway + with self.subTest(workflow=name, job=job_id, step=step.get('name') or step.get('uses')): + self.assertIn("steps.install.outcome == 'success'", condition) + + def test_a_failed_install_fails_once_naming_pips_reason(self) -> None: + for name, job_id, install in LEGACY_37[:2]: + with self.subTest(job=job_id): + result = self.run_step(name, job_id, install, PIP_STUB, {'PIP_OUTPUT': PIP_37, 'PIP_STATUS': '1'}) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(workflow_commands(result.stdout + result.stderr), [ + f'::error title=pcapkit did not install on {TITLE_37}::No matching distribution found ' + 'for pytest>=8; extra == "test". The rest of this leg is skipped.']) + self.assertEqual(titles(result.stdout), [f'pcapkit did not install on {LEG_37}']) + self.assertEqual((self.tmp / 'pip-install.log').read_text(encoding='utf-8'), PIP_37 + '\n') + + def test_an_install_failure_without_an_error_line_is_still_named(self) -> None: + for output, reason in (('Killed', "pip failed without an ERROR line; see this step's log"), + ('ERROR: 100% of the index timed out', '100%25 of the index timed out')): + with self.subTest(output=output): + result = self.run_step('unit-tests.yml', 'test', LEGACY_37[0][2], PIP_STUB, + {'PIP_OUTPUT': output, 'PIP_STATUS': '1'}) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(workflow_commands(result.stdout), [ + f'::error title=pcapkit did not install on {TITLE_37}::{reason}. The rest of this leg is skipped.']) + + def test_a_clean_install_passes_without_an_annotation(self) -> None: + for name, job_id, install in LEGACY_37[:2]: + with self.subTest(job=job_id): + result = self.run_step(name, job_id, install, PIP_STUB, + {'PIP_OUTPUT': 'Successfully installed pypcapkit-1.5.0', 'PIP_STATUS': '0'}) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(workflow_commands(result.stdout), []) + + def test_the_compatibility_leg_names_its_first_compile_error(self) -> None: + cases = ( + ({'COMPILE_OUTPUT': COMPILE_37, 'COMPILE_STATUS': '1', 'IMPORT_STATUS': '0'}, + [f'::error title=pcapkit does not compile on {TITLE_37}::2 modules fail to compile, the first at ' + 'File "pcapkit/const/ftp/command.py", line 286; see this step\'s log.']), + ({'COMPILE_OUTPUT': '', 'COMPILE_STATUS': '0', 'IMPORT_STATUS': '1'}, + [f"::error title=pcapkit does not import on {TITLE_37}::See the traceback in this step's log."]), + ({'COMPILE_OUTPUT': '', 'COMPILE_STATUS': '0', 'IMPORT_STATUS': '0'}, []), + ) # type: tuple[tuple[dict[str, str], list[str]], ...] + for env, expected in cases: + with self.subTest(compile=env['COMPILE_STATUS'], imported=env['IMPORT_STATUS']): + result = self.run_step('python-compatibility.yml', 'compatibility', 'Verify package', + VERIFY_STUB, env) + self.assertEqual(bool(result.returncode), bool(expected), result.stderr) + self.assertEqual(workflow_commands(result.stdout), expected) + for title in titles(result.stdout): + self.assertTrue(title.endswith(f' on {LEG_37}'), title) + if env['COMPILE_STATUS'] != '0': + self.assertIn('SyntaxError: invalid syntax', result.stdout) + + +#: The properties an annotation takes; anything else in one is a value cut at a ``,``. +ANNOTATION_PROPERTIES = frozenset({'title', 'file', 'line', 'endline', 'col', 'endcolumn'}) +#: An annotation command and its properties, which end at the next ``::``. +ANNOTATION = re.compile(r'::(?:error|warning|notice) (.*?)::') +#: A ``${...}`` expansion: the shell replaces it, so the runner never sees its text. +BRACED = re.compile(r'\$\{([^}]*)\}') +#: The one braced form that escapes what it expands: ``${NAME//,/%2C}``. +ESCAPED = re.compile(r'\w+//,/%2C') +#: A plain ``$NAME`` or ``${NAME}``. +PLAIN = re.compile(r'\$(?:\{(\w+)\}|(\w+))') + + +class TestNoAnnotationTitleIsCutAtAComma(unittest.TestCase): + """Every ``title=`` a workflow emits reaches the run summary whole. + + The runner splits an annotation's properties on ``,`` before un-escaping + ``%2C`` (:func:`runner_parse`), so ``title=failed on 3.10 (KTLO, non-blocking)`` + shows as "failed on 3.10 (KTLO". A comma in the source text is one way; an + ``env:`` value holding one, such as ``LEG``'s ``(KTLO, non-blocking)``, + expanded plainly into a title is the other. ``${LEG//,/%2C}`` is the fix. + + """ + + def test_no_title_holds_an_unescaped_comma(self) -> None: + found = [] + for path in sorted(WORKFLOWS.glob('*.yml')): + spec = workflow(path.name) + for job_id, body in spec.get('jobs', {}).items(): + for step in body.get('steps', []): + if 'run' not in step: + continue + env = {**(spec.get('env') or {}), **(body.get('env') or {}), **(step.get('env') or {})} + label = f"{path.name} {job_id} {step.get('name') or step.get('id')!r}" + for match in ANNOTATION.finditer(step['run']): + properties = match.group(1) + found.extend(f'{label}: ${{{body}}} can expand a comma' for body in BRACED.findall(properties) + if ',' in body and not ESCAPED.fullmatch(body)) + found.extend(f'{label}: ${name} expands {env[name]!r}' + for name in (a or b for a, b in PLAIN.findall(properties)) + if ',' in str(env.get(name, ''))) + found.extend(f'{label}: {piece!r} is not a property' + for piece in BRACED.sub('_', properties).strip().split(',') + if piece.split('=', 1)[0].strip().lower() not in ANNOTATION_PROPERTIES) + self.assertEqual(found, []) + + def test_the_parser_cuts_where_the_runner_does(self) -> None: + """:func:`runner_parse` against the two forms, so the test above reads what GitHub would.""" + cut = runner_parse('::error title=pcapkit failed on Python 3.10 (KTLO, non-blocking)::message') + whole = runner_parse('::error file=a.py,line=3,title=on Python 3.10 (KTLO%2C non-blocking)::100%25 done') + self.assertEqual(cut, ('error', {'title': 'pcapkit failed on Python 3.10 (KTLO'}, 'message')) + self.assertEqual(whole, ('error', {'file': 'a.py', 'line': '3', + 'title': 'on Python 3.10 (KTLO, non-blocking)'}, '100% done')) + + def prose(path: 'pathlib.Path') -> 'str': """``path``'s text with each line break, and a leading ``#``/``#:`` after it, as one space.""" return re.sub(r'[ \t]*\n[ \t]*(?:#:?[ \t]*)?', ' ', path.read_text(encoding='utf-8', errors='replace'))