From ac32f428191fe8f5519e7dc3b69c421c2cdd4703 Mon Sep 17 00:00:00 2001 From: Jarry Shaw Date: Sun, 25 Oct 2026 01:06:40 -0400 Subject: [PATCH] docs(docs): fifth whole-repo pass over the non-API docs, with issue/PR roles (#1103) - Cite issues and pull requests by :issue:/:pr: in rendered text: the hand-written GitHub links in workflows.rst, releasing.rst and pep.rst, the bare #NNN in workflows.rst, and the #NNN in pcapkit docstrings and #: comments. #2050, linked as an issue, is a pull request. - Add tests/project/test_docs_issue_pr_roles_1103_unit.py, which fails on a hand-written link or bare #NNN in .rst pages, docstrings and #: comments; its allowlist holds only the references other tests pin. - Record the rule on the documentation conventions page. - Name the external action functions releasing.rst and workflows.rst cited by line, where no test pins the line. - Fix stale claims: the shared changelog pull request has merged, the Windows leg runs leg_summary.py and pip_install.py, Lint expects pylint's import-error rather than tracking a count, pypcapfile fails from 3.12 up rather than working only on 3.10 and 3.11, cryptography in the all extra is no pure-Python wheel; drop "today", "this change" and "current". Docs build: no warnings, and the same 116 under -n as origin/main. --- .../conventions/documentation.rst | 11 + .../contributing/conventions/process.rst | 12 +- docs/source/contributing/pep.rst | 12 +- docs/source/contributing/releasing.rst | 85 ++--- docs/source/contributing/workflows.rst | 214 +++++++------ docs/source/engines.rst | 2 +- docs/source/ext.rst | 2 +- docs/source/index.rst | 2 +- pcapkit/dumpkit/common.py | 4 +- pcapkit/foundation/engines/dpkt.py | 2 +- pcapkit/foundation/engines/pcap_ct.py | 2 +- pcapkit/foundation/engines/pcapng.py | 2 +- pcapkit/foundation/engines/pypcap.py | 2 +- pcapkit/foundation/engines/pypcapfile.py | 2 +- pcapkit/foundation/engines/pyshark.py | 2 +- pcapkit/foundation/engines/scapy.py | 18 +- pcapkit/protocols/schema/misc/pcapng.py | 4 +- pcapkit/toolkit/pyshark.py | 2 +- .../test_docs_issue_pr_roles_1103_unit.py | 296 ++++++++++++++++++ 19 files changed, 493 insertions(+), 183 deletions(-) create mode 100644 tests/project/test_docs_issue_pr_roles_1103_unit.py diff --git a/docs/source/contributing/conventions/documentation.rst b/docs/source/contributing/conventions/documentation.rst index 2bf7277f99..b73f87df85 100644 --- a/docs/source/contributing/conventions/documentation.rst +++ b/docs/source/contributing/conventions/documentation.rst @@ -314,6 +314,17 @@ Format and Mechanics :file:`MANIFEST.in` does not reach Markdown, so the READMEs under :file:`examples/` ship through ``recursive-include examples *.md``, and any other Markdown file ships only if setuptools or an ``include`` line adds it. +* **Cite an issue or a pull request by role wherever Sphinx renders the text.** In an + ``.rst`` page, a docstring or a ``#:`` doc-comment an issue is ``:issue:`NNN``` and + a pull request ``:pr:`NNN```, by what the number is rather than what a URL says: a + bare ``#NNN`` there renders as plain text, and a hand-written link to the tracker + hides which of the two it names. A plain ``#`` comment, a workflow's YAML comment, + :file:`tests/` and any other file nothing renders keep ``#NNN``, which GitHub links + itself. A link into another repository or to a Discussion keeps its URL, and + ``GH-NNN`` is a deliberate issue form. Ruled on :issue:`1103`; + :file:`tests/project/test_docs_issue_pr_roles_1103_unit.py` fails on a bare number + or a hand-written link in rendered text, and + :file:`tests/project/test_issue_refs_in_comments.py` on a role in a plain comment. * **Prose names the real defining module, not a re-export.** One strand of the consistency sweep on :doc:`the roadmap `. The sentinels are the worked example: every one is defined in :mod:`pcapkit.corekit.sentinels`, and diff --git a/docs/source/contributing/conventions/process.rst b/docs/source/contributing/conventions/process.rst index 276307e2ed..d5e2d3f292 100644 --- a/docs/source/contributing/conventions/process.rst +++ b/docs/source/contributing/conventions/process.rst @@ -65,7 +65,7 @@ back to the default engine rather than raising when a requested engine is absent toolchain. ``dev`` is defined by what the toolchain must be able to *see*, and the workflows that need full resolution install ``.[all,dev]``. Do not add ``pypcap``/``pcap-ct`` to it to silence a lint finding; :file:`.github/workflows/lint.yml` - carries a tracked ``import-error`` count that is deliberate rather than accidental. + expects pylint's ``import-error`` for them, deliberately rather than by accident. .. note:: @@ -88,9 +88,9 @@ detail of what actually changed in that version bump. Ruled on Two different things get confused here, so they are named apart: -* **A pull request's commits.** The shared changelog for the 1.5.0 cycle is a +* **A pull request's commits.** The shared changelog for the 1.5.0 cycle was a long-lived pull request of its own, carrying roughly one commit per code pull - request, deliberately unsquashed so that what is and is not accounted for stays + request, and merged unsquashed so that what is and is not accounted for stays readable in its log. That is a property of *the pull request*, and it is not what the ruling is about. * **A changelog file's entries.** :file:`docs/source/changelog/1.5.0.rst` groups its @@ -142,9 +142,9 @@ belongs to none. The map is one-to-one with the package list below -- every pack section wants that module's changes; the same prose appearing twice reads as two separate changes. Raised originally on :issue:`918`. - The restructure itself belongs to the shared changelog's own pull request, which - owns the file and merges last; doing it earlier would conflict with every open - change that touches an entry. + The restructure itself was done on the shared changelog's own pull request, which + owned the file and merged last; doing it earlier would have conflicted with every + open change that touched an entry. Issue and Pull Request Labels ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/source/contributing/pep.rst b/docs/source/contributing/pep.rst index 0f7faf6427..346f2e2f9f 100644 --- a/docs/source/contributing/pep.rst +++ b/docs/source/contributing/pep.rst @@ -360,7 +360,7 @@ PCAPNG Support **Done.** The builtin default engine parses PCAP-NG files; :class:`~pcapkit.protocols.misc.pcapng.PCAPNG` implements the format, with its block and option enumerations under :doc:`/pcapkit/const/pcapng`. This closes -`#35 `__. +:issue:`35`. Maybe Even Faster? ------------------ @@ -506,8 +506,8 @@ both of the following are worth knowing before reaching for them: ``import pcapfile`` still succeeds on 3.12+, the modules the engine actually needs raise :exc:`ModuleNotFoundError`. Upstream ``master`` (0.12.1, unreleased) has fixed this, and that is what the engine was verified against. - The extra therefore installs a version that only works on Python 3.10 and - 3.11 until 0.12.1 is published. + The extra therefore installs a version that does not work on Python 3.12 or + newer until 0.12.1 is published. * **pypcap** -- the 1.3.0 sdist (the only distribution; there has been no wheel since Python 2.7) ships a ``pcap.c`` pre-generated by Cython 0.29.32 and compiles it verbatim -- its :file:`setup.py` never invokes Cython. That @@ -1116,7 +1116,7 @@ library gets swept deliberately, in strands: * **Packet formats against the specifications.** The most valuable strand, and the one with the clearest evidence behind it: reading the RFC field-by-field against the schema is what produced - `#472 `__ — HIP + :issue:`472` — HIP parameters sizing their list entries at one octet where :rfc:`5770` §5.4 and :rfc:`7402` §5.1.2 specify sixteen bits — and the same method then *cleared* the further @@ -1129,13 +1129,13 @@ library gets swept deliberately, in strands: This strand has the sharpest evidence of them all, because duplication here has already *caused* defects rather than merely offended tidiness: - - `#487 `__ existed because + - :issue:`487` existed because :meth:`IPv6_Route.make() ` computed ``Hdr Ext Len`` in **separate** branches with different wrong units, and the read side computed the inverse elsewhere with yet another. The fix was not new arithmetic but one helper per direction, each documenting the :rfc:`8200#section-4.4` unit and saying what its counterpart is. - - `#483 `__ was the scapy + - :issue:`483` was the scapy toolkit adapter passing a fragment offset unscaled where every sibling adapter scaled it. Every adapter restates the same field mapping, so a convention that holds in all but one is invisible until a capture is diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index e3842a5fce..005950b405 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -34,10 +34,10 @@ code. ``util/vendor_code_changed.py`` compares each changed file with ``HEAD``; a crawl whose changes are all to comments or docstrings is committed as "Updated vendor documentation without a version bump" instead, so it reaches ``main`` without starting a release -(`#1674 `__). A crawl that +(:issue:`1674`). A crawl that finds nothing commits nothing. The bump commit carries every module the crawl wrote under ``pcapkit/const/``, new and deleted ones included -(`#1685 `__), and is what +(:issue:`1685`), and is what starts the automated path described below. The same script is also the manual entry point: run @@ -115,7 +115,7 @@ Update that did not conclude ``success`` or skipped ``vendor-update`` (below). Past it, a ``v*`` ref always runs the gate; on ``main`` it runs only when the version is not tagged at another commit and some target's evidence is not yet ``'true'`` -(`#1052 `__), so the +(:issue:`1052`), so the common Vendor Update run that bumped nothing costs a minute, not a full-suite run. @@ -124,12 +124,12 @@ cancels it (the ``concurrency`` block), since two Vendor Update runs completing close together raise two runs on the same tip. A group holds one pending run, which a newer one cancels, so a run whose Vendor Update did not conclude ``success`` is keyed on its own ``github.run_id`` instead -(`#1705 `__). It releases +(:issue:`1705`). It releases nothing, so it neither waits behind a release nor displaces a run waiting there; only a newer run that may release, reading ``main``'s newer tip, can. A Vendor Update that concluded ``success`` with ``vendor-update`` skipped -- its Unit Tests verdict was for another commit -- releases nothing either since -`#1748 `__: the +:issue:`1748`: the ``vendor_update`` job reads that run's jobs, and ``version_check`` skips. The group cannot see that, so such a run still joins the ref's group; if it displaces a pending run, that release waits for the @@ -139,7 +139,7 @@ One Commit per Release ---------------------- Every job that checks out, tags, builds or publishes pins ``github.sha`` -(`#1634 `__), so a run +(:issue:`1634`), so a run releases the commit it was created for however long it waits on approval. Before that, run 38008819343 was created for ``b6b95bc8e``, approved three days later, and tagged ``main``'s tip ``2c0008c3c`` as both ``v1.5.0b9`` and @@ -160,7 +160,7 @@ the default branch, and ``tag`` pulled ``main`` before tagging. publishes a second tree under that version. ``version_check`` resolves the ``v*`` tag the same way first, in "Check which commit the version's tag names", and off a ``v*`` ref a tag naming another commit skips the gate and every publisher - (`#1748 `__): + (:issue:`1748`): ``Release status`` posts a ``::notice`` if that release is complete, or a ``::warning`` naming both commits and what is missing, and the run stays green. A ``v*`` push of such a version still fails with the @@ -169,9 +169,10 @@ the default branch, and ``tag`` pulled ``main`` before tagging. build, by Conda Update's ``publish`` dispatch (see `Recovery`_). * ``pypi`` and ``conda`` attach their files with ``gh release upload``, in their "Upload package to GitHub Release" steps, not ``action-gh-release``, - which calls Update a release before every upload (3.0.2's ``src/github.ts:631-683``). A file + which calls Update a release before every upload (3.0.2's ``release()`` in + ``src/github.ts``, on a release that exists). A file the release already has is left alone - (`#1640 `__): the action + (:issue:`1640`): the action deleted and re-uploaded it, and a rebuild against live PyPI is not byte-identical to the published copy. Only an asset in state ``uploaded`` counts: `Upload a release asset @@ -184,11 +185,11 @@ the default branch, and ``tag`` pulled ``main`` before tagging. * ``gh release upload`` also fails, deliberately, where the action did not. With no release for the tag, the upload fails with ``gh``'s "release not found", where the action made one, with no notes and - ``target_commitish`` unset (``src/github.ts:617-628,1026``), which Create a + ``target_commitish`` unset (``release()`` and ``createRelease()`` there), which Create a release defaults to "the repository's default branch"; the release is ``github``'s to make, at the run's commit. A file pattern matching nothing exits 1 with ``::error title=Nothing to upload``, where the action, - ``fail_on_unmatched_files`` unset, only warned (``src/run.ts:15-20``) and + ``fail_on_unmatched_files`` unset, only warned (``run()`` in ``src/run.ts``) and published the release without that file. Pinning the target may have a cost, and GitHub documents the rule two ways. @@ -232,35 +233,38 @@ re-checks the target (unmeasured). ``cron-conda.yml``'s ``conda-dist`` does: it attaches packages with ``ncipollo/release-action`` v1.21.0, pinned to its commit, and ``allowUpdates: true``, which updates the release before uploading -(``src/Action.ts:53-66,107-120``). It therefore +(``Action.createOrUpdateRelease()`` and ``Action.updateRelease()`` in +``src/Action.ts``). It therefore names the default branch -- ``github.ref_name`` whichever trigger raised it -- as ``commit``, which that update sends as ``target_commitish`` -(``src/Releases.ts:150-174``), so the rule checks ``main`` itself; the tag +(``GithubReleases.update()`` in ``src/Releases.ts``), so the rule checks ``main`` itself; the tag stays put, as Update a release calls the target "Unused if the Git tag already exists". It also uploads only to a published release, read first with ``gh release view``. With none, the action would create one, and its tag, at -``commit`` (``src/Action.ts:72-92``). Conda Update bumped 1.5.0b9's build +``commit`` (``Action.checkForMissingReleaseError()`` and +``Action.updateDraftOrCreateRelease()``). Conda Update bumped 1.5.0b9's build three times while that run waited on approval; once ``conda-dist`` runs on -every bump (`#1632 `__), +every bump (:issue:`1632`), that would tag the pending version at ``main``'s tip and fail Create Release's tag check. The action would also retarget a draft at ``commit`` -(``src/Action.ts:85-92,107-120``), which ``omitDraftDuringUpdate`` keeps a +(``Action.updateDraftOrCreateRelease()`` and ``Action.updateRelease()``), which ``omitDraftDuringUpdate`` keeps a draft, but Create Release's ``workflow_run`` run finds the draft and resets -its target to the run's sha (``action-gh-release`` 3.0.2's -``src/github.ts:636-646,832-866``), so skipping a draft is a precaution +its target to the run's sha (``action-gh-release`` 3.0.2's ``release()`` and +``findTagFromReleases()``), so skipping a draft is a precaution against retargeting a release Create Release owns. Either way the build goes to anaconda.org only, with a ``::notice``. The update also sends the release's name, notes and ``prerelease`` and ``draft`` flags, which unset inputs make the tag, ``""`` and ``false`` -(``src/Inputs.ts:70-92,128-135,210-240``), so the step sets the -``omit*DuringUpdate`` inputs that leave them out (`#1657 -`__). +(``CoreInputs``' ``updatedReleaseName``, ``updatedReleaseBody``, ``updatedDraft`` +and ``updatedPrerelease`` getters, and the ``name`` and ``body`` they fall back +on, in ``src/Inputs.ts``), so the step sets the +``omit*DuringUpdate`` inputs that leave them out (:issue:`1657`). The Release App --------------- -The owner's choice on `#1634 -`__: ``github`` creates +The owner's choice on :issue:`1634`: +``github`` creates the release with a GitHub App installation token, which Create a release accepts for a target that changes a workflow file when it has the "Workflows" repository permission (write). "Mint the release App's token" runs @@ -281,8 +285,8 @@ One-time setup: permissions at read and write, and install it on this repository only. Add **Pull requests** at read and write too, and accept it on the installation: the Python Versions workflow's ``pr`` job opens its pull request with a token - of this App, with contents and pull requests write only (`#1660 - `__, :doc:`workflows`). + of this App, with contents and pull requests write only (:issue:`1660`, + :doc:`workflows`). 2. Add repository variables ``RELEASE_APP_CLIENT_ID``, the App's Client ID, and ``RELEASE_APP_BOT_ID``, its bot's user id (``gh api "/users/[bot]" --jq .id``, as the action's README reads it). @@ -309,14 +313,15 @@ according to the token spent: with ``GITHUB_TOKEN`` it lists the setup above; with the App's, it points at the App's permissions and installation. For a final release, first delete any draft ``v`` the refused run left: ``action-gh-release`` creates a non-prerelease as a draft and publishes it -with Update a release in the same step (``src/github.ts:1025``), and the next -run would reuse that draft and its target (``:631-647`` there). Then re-run +with Update a release in the same step (``createRelease()`` in +``src/github.ts``), and the next run would reuse that draft and its target +(``:631-647`` there). Then re-run the job; that a re-run reads variables and secrets set since is unmeasured. Per-Job Evidence, Not a Shared Proxy ------------------------------------ -Until `#888 `__, every job +Until :issue:`888`, every job past ``version_check`` shared one guard: .. code-block:: yaml @@ -340,13 +345,13 @@ checks *is* the one it is about to create: exists. * ``pypi`` compares PyPI's file count for the version against the expected one -- a wheel per supported leg plus the sdist kept only on the 3.15 leg; the - :ref:`KTLO ` legs' wheels (`#1633 `__) are not counted. This job is + :ref:`KTLO ` legs' wheels (:issue:`1633`) are not counted. This job is already idempotent (``skip-existing: true`` on every upload step), so the check exists only to save the cost of the build matrix, not for correctness. * ``conda`` compares Anaconda's aggregate count against the expected one (the ``linux-64``, ``osx-64``, ``osx-arm64`` and ``win-64`` files for each Python version of its matrix, one platform per ``os`` leg, - `#1922 `__, :issue:`2060`) as the same + :issue:`1922`, :issue:`2060`) as the same kind of job-level cost saver, and *also* checks, per matrix leg, whether that leg's own platform/Python distribution is already there -- this second check is the actual correctness fix, because @@ -355,7 +360,7 @@ checks *is* the one it is about to create: rather than being absorbed. The ``macos-26-intel`` leg is the exception: the action's CLI download returns 404 there, so its "Upload conda packages to Anaconda (Intel)" step runs ``anaconda upload --skip-existing`` instead - (`#2044 `__), which skips + (:issue:`2044`), which skips such a file. The ``windows-latest`` leg's "Upload conda packages to Anaconda (Windows)" makes the same call (:issue:`2060`). Conda Update's Intel and Windows legs run the same steps, so they keep a file anaconda.org already has, @@ -369,12 +374,12 @@ The PyPI and Anaconda counts are of the tiers the version was released with. Until its ``v*`` tag exists, that is this run's :file:`.github/python-versions.toml`; once it does, ``version_check`` reads the file at the tag's commit with that commit's ``util/python_versions.py``. A -supported version added since (`#1660 -`__) was never built for the +supported version added since (:issue:`1660`) +was never built for the release, and counting it would read a finished release as incomplete, run the gate and ``pypi``, and fail `One Commit per Release`_'s tag check on every run until the next release. A tag older than the file, as ``v1.5.0b12`` is, counts -the tiers the file was added with, which the change for `#1660 `__ copied unchanged from the +the tiers the file was added with, which the change for :issue:`1660` copied unchanged from the workflows of the time. The Anaconda count's platforms follow the same rule, from the ``SUBDIRS`` line of :file:`util/release_published.py` at the tag's commit or, for a tag older @@ -423,7 +428,7 @@ equality pair skips ``conda`` outright instead. cancelled ``version_check`` leaves them nothing to decide on. ``unit-tests`` is held to the same bar for a different reason: it is the release test gate, and it skips when nothing is publishable -(`#1052 `__), so accepting +(:issue:`1052`), so accepting ``skipped`` there would publish untested. ``github`` names it in ``needs:`` instead and relies on the implicit ``success()``. The gate's own ``if:`` is the union of the publishers' conditions, with ``!= 'true'`` so unknown evidence runs it; should the two @@ -465,7 +470,7 @@ that it can run regardless of which of them skipped. Only ``github-release`` carries a required reviewer; ``conda-tag``, ``pypi`` and ``anaconda`` kept their environments but had their reviewers -removed on `#887 `__. +removed on :issue:`887`. What still makes one approval mean *the whole release* is the ``needs:`` graph: ``tag`` and ``pypi`` both depend on ``github``, and ``conda`` depends on ``tag`` and ``github`` -- so nothing downstream of ``github`` can @@ -533,8 +538,8 @@ when ``main``'s tip already says ``0`` (the usual case, since :file:`util/bump_version.py` writes ``0``) or carries another ``__version__``, nor when origin already has a ``conda-+N`` tag other than ``+0``, since rewinding the counter would then reuse a tagged build number; the first -and last warn. `#1634 `__'s own shape -- three Conda Update bumps that `#1632 -`__ left untagged -- gets +and last warn. :issue:`1634`'s own shape -- three Conda Update bumps that :issue:`1632` +left untagged -- gets the reset: ``main``'s 3 becomes 0. A rejected push is retried from a fresh fetch, three times at most. Approving ``github-release`` therefore also approves a commit landing on the default branch, not only the artefacts that @@ -556,7 +561,7 @@ existence. ``pypi`` is safe to re-run (``skip-existing: true``), and so is ``conda``, because each matrix leg checks Anaconda for its own platform/Python distribution before uploading and skips only that leg's upload if it is already there. Neither replaces a file the GitHub Release -already has (`#1640 `__). +already has (:issue:`1640`). A re-run repeats the tagged commit's own workflow, so it cannot upload Anaconda files that commit's ``conda`` legs cannot build: 1.5.0b14's @@ -592,7 +597,7 @@ With that reconciliation, ``release_status`` reports exactly one of: * at least one target skipped despite its own evidence saying it is still incomplete, and nothing else failed to explain that (``::error``, and the job itself fails) -- the shape - `#888 `__ was actually + :issue:`888` was actually about. That last case should be unreachable given the diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index a6e4be8cf0..7487e7d213 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -271,7 +271,7 @@ manual run, where no completed Unit Tests run exists yet to read a verdict from. arrows), each reads the verdict Unit Tests already reached instead of re-running the gate a second time, and acts on it only once the head's ``verdict`` job, described under the edge list below, says it is a verdict on the commit it -builds (`#1967 `__). Create +builds (:issue:`1967`). Create Release has no ``schedule``, and its gate job's ``if:`` reads ``version_check``'s evidence rather than the event, so its gate can run whichever way the run was raised, once ``version_check`` itself has passed: on a ``v*`` tag push, and on a @@ -311,7 +311,7 @@ Those checks say nothing about *which* commit was verified. A ``workflow_run`` checks out ``github.sha``, ``main``'s tip when the event is raised, not ``github.event.workflow_run.head_sha``, the commit the completed run tested, and in a merge wave the two differ: Conda Update 38325136710 bumped 2e941bbb7 under -the verdict for 9083163f5 (`#1643 `__). +the verdict for 9083163f5 (:issue:`1643`). So Vendor Update, Conda Update and GitHub Pages each run Cron Head's ``verdict`` job first, in their ``head`` job, and act only when its ``verified`` output says the tip is the verified commit, or that commit plus bot commits @@ -319,24 +319,24 @@ to ``conda/build`` and ``conda/requirements.txt`` alone; otherwise they skip, an the tip's own Unit Tests run raises them again. Vendor Update's run still concludes ``success`` then, since ``verdict`` ran, so Create Release reads its ``vendor-update`` job to tell it from a real one -(`#1748 `__). The rule and its evidence are in +(:issue:`1748`). The rule and its evidence are in ``util/verdict_covers_checkout.py``. Conda Update also writes after the checkout, so it applies the same rule a second time: its push rebases onto ``main``'s tip only when the commits that brings in pass it, and otherwise pushes nothing, and Conda Tag tags the commit that push landed rather than the -tip (`#1632 `__). +tip (:issue:`1632`). Vendor Update's own commit raises it again: the push gets a Unit Tests run, whose completion starts another crawl. The commit bumps the version only when the crawl changed code. If every change is to comments or docstrings, the commit is "Updated vendor documentation without a version bump", and the Create Release run that follows sees the same version, so it has nothing new to publish -(`#1674 `__). A crawl raised by +(:issue:`1674`). A crawl raised by such a commit's own Unit Tests run does not commit comment changes on top of it, so documentation commits cannot chain. The rule is in ``util/vendor_code_changed.py``. A bump commit carries every module the crawl wrote, a new one included, so the crawl it raises does not find one untracked -and bump again (`#1685 `__). +and bump again (:issue:`1685`). Reusable-Workflow Calls (``uses:``) ----------------------------------- @@ -356,7 +356,7 @@ starting an independent run after the caller finishes. The calls target output is its ``verdict`` job's, and the acting job reads it beside ``needs.head.result``. -Cron Head (`#1967 `__) is +Cron Head (:issue:`1967`) is one copy of the test gate and the ``verdict`` job, which Conda Update, Vendor Update and GitHub Pages used to carry each. It holds ``contents: read`` and no more, which is what each caller grants its ``head`` job and what @@ -369,7 +369,7 @@ treats a ``head`` result other than ``failure`` as passing. Their schedules are eight hours apart on Saturdays: GitHub Pages at 02:00, Vendor Update at 10:00 and Conda Update at 18:00 UTC -(`#1967 `__). The owner's +(:issue:`1967`). The owner's ruling kept a schedule on each and allowed them aligned or spread, and nothing orders them: @@ -379,7 +379,7 @@ orders them: triggered it, through its ``vendor_update`` job, so the time of the others does not matter to it. * At one time they cost more. Vendor Update and Conda Update both ran at 10:00 - until this change, and each scheduled pair read back from 2026-09-26 on + until :issue:`1967` spread them, and each scheduled pair read back from 2026-09-26 on checked out the same commit -- on 2026-10-17, Conda Update 39023770904 and Vendor Update 39023891165 both gated 44ed96fc3, their ``Gate (full suite, …)`` jobs taking twelve and ten minutes. Their writing jobs share the ``repository-maintenance`` group, so ``conda-update`` @@ -389,7 +389,7 @@ orders them: Spread, each ``head`` gates alone, and Conda Update runs once Vendor Update's Create Release has had the day to publish, so its ``Check the GitHub release exists`` step finds the release rather than skipping the GitHub upload -(`#1634 `__). +(:issue:`1634`). To debug Vendor Update, Conda Update or GitHub Pages without waiting for Saturday, start a manual run on a branch, as in ``gh workflow run cron-conda.yml @@ -453,7 +453,7 @@ existed then -- ``Python ${{ matrix.python-version }}``, ``Integration Python …``, ``Engines Python …`` and ``PyPCAP/PyPCAPFile parity Python …`` -- all reporting ``skipped``. -The Skip Cascade (`#888 `__) +The Skip Cascade (:issue:`888`) ------------------------------------------------------------------------------- The relationships above compose into a failure mode worth seeing on its @@ -477,7 +477,7 @@ anything other than ``success`` -- including ``skipped``, which is exactly what Vendor Update's *own* jobs report when *their* ``workflow_run``-path guards decline (see ``cron-vendor.yml``'s ``vendor-update`` job). Since -`#1748 `__ it is also false +:issue:`1748` it is also false for a Vendor Update that concluded ``success`` with ``vendor-update`` skipped -- its ``verdict`` job found the Unit Tests verdict was for another commit -- which the ``vendor_update`` job reads from that run's jobs first (observed on run @@ -540,12 +540,12 @@ because the upstream Vendor Update run did not itself conclude ``success``. Such a run is also kept out of the release's concurrency group (its ``concurrency`` block, -`#1705 `__). It is keyed on +:issue:`1705`). It is keyed on its own ``github.run_id`` rather than on the ref, so it cannot cancel a run waiting there with release work. Keyed on the ref, run 38499295798 joined at 02:06:32Z on 2026-10-14, and the pending 38499150441, whose Vendor Update had succeeded, was cancelled a second later. A run whose Vendor Update concluded -``success`` with ``vendor-update`` skipped (`#1748 `__) is still keyed on the ref: +``success`` with ``vendor-update`` skipped (:issue:`1748`) is still keyed on the ref: the group reads only the event, which does not say which jobs ran. It can still displace a run pending there, and releases nothing in its place, so that release waits for the next Vendor Update on ``main``. @@ -558,14 +558,14 @@ that runs with a secret on an event a fork can cause: it writes the project boar *Status* field with ``PROJECT_TOKEN``, which a fork's ``pull_request`` run would not receive. It therefore never checks out the repository and has no ``uses:`` step. It fetches :file:`util/project_status.py`, and :file:`util/_actions.py`, which it imports -(`#2047 `__), through the API at +(:issue:`2047`), through the API at ``github.sha`` -- the base branch's tip under that event -- and takes only the item number from the payload. ``tests/project/test_project_status.py`` asserts this over the file. Without the secret it skips with a ``::notice::``. The Status mapping it applies is documented in :doc:`conventions/process`. -Coverage (`#1063 `__) +Coverage (:issue:`1063`) ------------------------------------------------------------------------- Coverage is measured inside an existing leg rather than by a workflow of its @@ -581,10 +581,10 @@ and re-importing pcapkit, one module's peak RSS was 4.17 GiB under the default killed the runner. ``sigterm = true`` and a ``pytest_sessionfinish`` hook in :file:`tests/conftest.py` that saves each xdist worker's data early keep a worker that dies during shutdown from losing its file -(`#1423 `__). The leg's +(:issue:`1423`). The leg's ``Report coverage`` step then runs :file:`util/coverage_report.py`, which fails, posting no total, unless the controller and every xdist worker saved a data file -(`#1387 `__), and otherwise +(:issue:`1387`), and otherwise writes the total and a per-package table to the job summary. The leg uploads the HTML report as the ``coverage-html`` artifact (30 days), with the bare numbers as the ``coverage-summary`` artifact and the per-process debug log as @@ -603,19 +603,19 @@ found by a hidden marker, on each push. The first line says when the tests failed. Neither workflow's coverage step is a required check. A push run has no pull request, so its report goes to Discussions instead (see `CI Reports`_). -CI Timing Review (`#1538 `__) +CI Timing Review (:issue:`1538`) --------------------------------------------------------------------------------- **CI Timing** (:file:`ci-timing.yml`) runs :file:`util/ci_timing.py` every Monday, or on dispatch with a ``days`` window and an optional ``concurrency_hours`` runner-pool measurement. Through the API it lists every completed run in the window of Unit Tests, -Python Compatibility, Lint, CodeQL, GitHub Pages and Experimental Tests (`#1732 `__, so +Python Compatibility, Lint, CodeQL, GitHub Pages and Experimental Tests (:issue:`1732`, so its two-hour legs stay measured), a day per query because a filtered listing stops at 1,000 runs (Pages completed 1,154 in the week to 2026-10-09). It then reads the jobs of an evenly spaced sample -- 150 push and 150 pull-request runs of Unit Tests, 60 runs of each other workflow -- and the summary says how many runs each sample holds and which span they cover. For the week to 2026-10-19 that was 677 calls -(`#1930 `__: 49 listing pages, +(:issue:`1930`: 49 listing pages, one per workflow per day and one more per further 100 runs; 601 job reads; 15 for the JUnit artifacts; 12 workflow-file reads) of ``GITHUB_TOKEN``'s 1,000 an hour, on a read-only token (``actions: read``, ``contents: read``). It took 16.6 minutes, @@ -714,24 +714,24 @@ fire while the pool stays saturated, and the JUnit timings are what show whether :doc:`conventions/process`. ``H1``, ``H2`` and ``H4`` on a non-blocking job are warnings, not HARD, so they do -not fail the run (`#1732 `__): on every job of :file:`experimental.yml`, which no +not fail the run (:issue:`1732`): on every job of :file:`experimental.yml`, which no required context names, on every :ref:`KTLO ` leg, a job whose name ends in ``(KTLO)`` -(`#1633 `__), and on a job whose name ends in ``(experimental)`` in any workflow. That +(:issue:`1633`), and on a job whose name ends in ``(experimental)`` in any workflow. That last covers the experimental legs that ran in :file:`unit-tests.yml` under ``test``'s 45-minute cap before the split. The finding keeps its ``H`` id and says it is non-blocking. A KTLO leg past its job's ``timeout-minutes`` is still cancelled, and its check run -reads red, though since `#1738 `__ nothing gating waits on it, so that warning is worth +reads red, though since :issue:`1738` nothing gating waits on it, so that warning is worth acting on before the cap. -Work Kept Off the Pull-Request Queue (`#1538 `__) +Work Kept Off the Pull-Request Queue (:issue:`1538`) ----------------------------------------------------------------------------------------------------- Cuts in :file:`unit-tests.yml` keep work that cannot gate a merge from competing with pull-request jobs for the 20-runner pool. None touches a required check or adds a permission to the reusable workflow. -* ``unittest-ordering`` runs on ``main`` pushes only, per `#1052 - `__ item H, and has +* ``unittest-ordering`` runs on ``main`` pushes only, per :issue:`1052` + item H, and has ``needs: [test]``, so its cells join the queue once ``test`` finishes rather than when the push run is created, ahead of that run's own ``Python`` legs and of pull-request jobs created later in a merge wave. Its ``if:`` opens with @@ -744,7 +744,7 @@ permission to the reusable workflow. on every leg. Dropped with it: that file in a venv holding only ``test`` and ``DPKT``. Another cut, running the then-prerelease 3.15 engine cell on ``main`` pushes only, -ended with `#1633 `__: 3.15 is +ended with :issue:`1633`: 3.15 is in the supported tier, so its cells block on every run. Required Status Checks @@ -762,7 +762,7 @@ grepping every workflow file for its name: - Emitted by - Where * - ``Lint`` - - job ``lint``, required since `#1967 `__ + - job ``lint``, required since :issue:`1967` - ``lint.yml`` * - ``Required checks passed`` - job ``required-checks`` @@ -783,7 +783,7 @@ grepping every workflow file for its name: - job ``compatibility``, matrix leg ``3.15`` - ``python-compatibility.yml``, ``.github/python-versions.toml:23`` -That is the list after `#1633 `__, +That is the list after :issue:`1633`, which added ``Compat Python 3.15`` and removed ``Compat Python 3.10``. Moving a version between the tiers below means editing the ruleset in the same change. @@ -817,14 +817,14 @@ produced -- and never expected -- on those paths. .. _ktlo-tier: Python versions are tiered -- supported and KTLO -(`#1633 `__), and experimental -(`#1660 `__, below) -- in +(:issue:`1633`), and experimental +(:issue:`1660`, below) -- in :file:`.github/python-versions.toml` (see `Python Versions`_). The **supported** tier, 3.11-3.15, blocks: a failed leg fails ``Required checks passed`` or its own required ``Compat`` context. The **KTLO** (*keep the lights on*) tier -- end-of-life 3.8-3.10, still built and tested -- never blocks, is never a required context, and never shows a commit or a -pull request red (`#1672 `__). In ``unit-tests.yml`` the KTLO legs are jobs of their own -since `#1738 `__: ``test-ktlo``, +pull request red (:issue:`1672`). In ``unit-tests.yml`` the KTLO legs are jobs of their own +since :issue:`1738`: ``test-ktlo``, ``integration-ktlo`` and ``pypcap-parity-ktlo``, which neither ``required-checks`` nor ``unittest-ordering`` ``needs:``, so not even a failure no step can contain, such as a lost runner or the job cap, reaches a gate. Each runs its gating twin's steps, @@ -859,13 +859,13 @@ with supported legs. Every job holding KTLO legs works the same way: Errors remain on a failed KTLO step, though none changes a conclusion: the runner's ``Process completed with exit code N.`` for a ``run:`` step, and an -action's own. And `#1634 `__'s tag check still blocks the ``pypi`` KTLO legs: it fails +action's own. And :issue:`1634`'s tag check still blocks the ``pypi`` KTLO legs: it fails every leg alike, and masked, the leg would go on to publish. PyPy is tiered the same way. ``test`` and ``integration`` run its supported versions after CPython's, and ``test-ktlo`` and ``integration-ktlo`` its KTLO ones, -each leg running the ``ALL`` test tier (`#1958 -`__, see `Test Tiers`_). A +each leg running the ``ALL`` test tier (:issue:`1958`, +see `Test Tiers`_). A supported PyPy leg blocks through ``Required checks passed``, which needs its job, so it adds no context to the ruleset. No ``Compat``, ``engine-tests`` or ``pypcap-parity`` leg runs PyPy. On PyPy 8.0, which setup-python's ``pypy3.11`` @@ -873,14 +873,13 @@ is, PyPI has no Linux wheel of |cryptography|_, so the install step builds it with the image's Rust and ``libssl-dev``, and none of ``lxml``, so the ``test`` extra carries ``lxml`` on CPython only. On ``pypy3.10`` the ``crypto`` and ``all`` extras cap |cryptography|_ below 46, as 45.0.7 is the newest release with a Linux -``pp310`` wheel and the newest release's sdist does not build there (`#2040 -`__). +``pp310`` wheel and the newest release's sdist does not build there (:issue:`2040`). On PyPy an xdist worker's RSS grows with every test it runs, rather than in a few heavy modules, so a PyPy leg's ``Run unit tests`` hands its pytest command to :file:`util/pytest_shards.py`, as GraalPy's leg does (see below), which runs it once per directory its ``SHARDS`` names, ``tests/protocols`` and then ``tests/corekit``, and once -more over the rest, ignoring each (`#2005 `__). +more over the rest, ignoring each (:issue:`2005`). Each run starts its workers afresh. Measured on PyPy 8.0.1 at ``-n 4``, one run over the whole ``ALL`` tier took each worker to 5.4-5.9 GB and the workers together past 20 GB, beyond the runner's 16 GB; split, the largest run peaked at 2.6 GB a @@ -890,8 +889,8 @@ the kills into ``MemoryError``\ s. Each run but the last writes a JUnit file of own beside ``junit.xml``, which ``Upload test timings`` takes too, and the step fails if any run did. -Nothing below ``requires-python``'s 3.8 is in any matrix (`#2051 -`__): pip refuses such an +Nothing below ``requires-python``'s 3.8 is in any matrix (:issue:`2051`): +pip refuses such an interpreter at install, so a leg there could only prove the refusal. 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 any of them. Nor @@ -903,8 +902,8 @@ setup-python reads them (``3.14t``, ``graalpy-25.4``). Each runs a leg of ``experimental``, the matrix job of :file:`.github/workflows/experimental.yml` ("Experimental Tests"), named ``Python (experimental)``, and nothing else: no ``integration``, ``Compat``, wheel or conda leg, so never a required -context. They were legs of ``test`` until `#1732 -`__, which moved them to a +context. They were legs of ``test`` until :issue:`1732`, +which moved them to a workflow of their own, by the owner's ruling that nothing waits on them. **That workflow reports separately and holds up nothing**: a job cannot ``needs:`` one in another workflow, nothing calls it, and no ``workflow_run`` names it but CI @@ -915,7 +914,7 @@ Tests run completes without it, and with it ``Required checks passed``, ``cron-conda.yml``). It runs on Unit Tests' ``push`` and ``pull_request`` events, skips a docs-only pull request by the same ``changes`` script, and lets a newer commit supersede a run, a pull request's or, unlike Unit Tests, a push to ``main``'s -(`#1903 `__): since nothing +(:issue:`1903`): since nothing waits on the legs, ``main``'s newest commit is the one worth testing, whereas Unit Tests keeps a run per push for ``unittest-ordering``, which runs only there. @@ -970,11 +969,11 @@ run from failing when a job fails" (`Workflow syntax though the leg's check run still reads ``failure``. A job past its ``timeout-minutes`` is cancelled, and that page does not say whether the job-level guard covers a cancellation. So setup-python, the install, the watcher's setup-python -and ``requests`` (`#1938 `__, +and ``requests`` (:issue:`1938`, below) and the tests have caps of their own, of 5, 10, 1, 1 and 120 minutes, under the job's 140, which leaves 3 for the rest: a step at its cap -fails rather than cancels, and the guard holds (actions/runner -``src/Runner.Worker/StepsRunner.cs:322-353``). The runner's ``has timed out`` error +fails rather than cancels, and the guard holds (actions/runner's +``StepsRunner.RunStepAsync``). The runner's ``has timed out`` error stays, changing no conclusion, as ``Process completed with exit code N.`` does. Everything but the install and the tests took under 20 seconds on every uncovered leg of runs 38569321543-38569522442. ``test``'s caps are main's: 45 minutes for the @@ -989,13 +988,13 @@ them: its leg reached 26% of the unit tier in 25 minutes (job 115915976024), whe ``List the LATEST tier`` asks ``latest_tier_paths()`` on the runner's own ``python3``, before setup-python, so GraalPy does not parse the tree, and ``Run unit tests`` passes each module it printed as an ``--ignore``. The -round-trip sweeps of `#1732 `__ +round-trip sweeps of :issue:`1732` took 8% of the unit tier's time in a serial 3.14 run (86 of 1,031 s), and 10-11% on the 3.14, 3.15 and 3.14t legs of run 38581834697, so the rest should take GraalPy 88-113 minutes (estimate). GraalPy's leg still loses its runner 18-30 minutes into its tests, with ``The runner -has received a shutdown signal`` (`#1740 `__), +has received a shutdown signal`` (:issue:`1740`), so each experimental leg samples its runner while the tests run. ``Start the resource monitor`` writes ``free -m``, ``df -h`` of ``/`` and the workspace's volume, ``/proc/loadavg`` and the five processes with the most RSS every 30 seconds, each @@ -1031,8 +1030,8 @@ warnings: is spent exits, xdist fails the test it ran as crashed and starts another, and ``--max-worker-restart=30`` allows 30 restarts in all, where three 16-minute heaps need about 21 in 110 minutes; xdist's default is 4 a worker, 12 at ``-n 3``. -* GraalPy's tier runs in shards (`#1963 - `__), so no worker outlives its +* GraalPy's tier runs in shards (:issue:`1963`), + so no worker outlives its heap. In job 118912680486, the first full run of the curated ``ALL`` tier, nine workers went down at 2.5-3.0 GB RSS, 9-17 minutes into their lives, each failing a test that passes alone. So on GraalPy's leg ``Run unit tests`` hands its pytest @@ -1052,7 +1051,7 @@ warnings: * ``timeout --signal=INT --kill-after=3m 110m`` ends pytest under the step's 120-minute cap, so it still writes the junit XML, and exits 124; ``prlimit --fsize=2147483648`` caps any file at 2 GiB. The ``timeout`` is GNU's, ``gnutimeout`` where it exists - (#2020): on Ubuntu 26.04 ``timeout`` is uutils', whose ``--kill-after`` sends SIGKILL + (:issue:`2020`): on Ubuntu 26.04 ``timeout`` is uutils', whose ``--kill-after`` sends SIGKILL with the SIGINT, and to its child alone. * Where the scope starts ``true`` under a throwaway unit, ``pcapkit-probe-.scope``, with every flag the tests get (``sudo -n --preserve-env``, ``MemoryMax=12G``, @@ -1086,8 +1085,8 @@ warning: that is expected, blocks nothing, and leaves the caps as they are. **The Windows leg.** :file:`experimental.yml`'s ``windows`` job runs the ``ALL`` tier on ``windows-latest`` under ``latest``, the newest CPython the conda builds target, as -``Python on Windows (experimental)`` (`#2060 -`__), so the first published +``Python on Windows (experimental)`` (:issue:`2060`), +so the first published ``win-64`` conda package has had a test run. It needs ``current``, ``changes`` and ``python-versions`` and runs on ``experimental``'s condition, and it is non-blocking by the same mechanics: every step continues on error, setup-python, the install and ``Run @@ -1111,14 +1110,14 @@ never holds ``latest`` back for the blocking jobs. Test Tiers ---------- -Every test module runs in one of two *test tiers* (`#1958 -`__): ``ALL``, which runs on every +Every test module runs in one of two *test tiers* (:issue:`1958`): +``ALL``, which runs on every leg, and ``LATEST``, which runs on ``latest`` alone. They are orthogonal to the unit and fixture-dependent tiers, which decide what a module may read and so which job collects it; a test tier decides on which of that job's legs it runs. ``module_tier()`` in :file:`tests/_tiers.py` decides, in one place, for every -``tests/**/test_*.py``. ``ALL`` is a curated, opt-in list (#1958, #1963): short enough +``tests/**/test_*.py``. ``ALL`` is a curated, opt-in list (:issue:`1958`, :issue:`1963`): short enough to save time on every leg, the same on every leg so QA stays consistent, and enough on its own to validate a change on each interpreter, while ``latest`` runs the whole suite as the gate. @@ -1155,8 +1154,8 @@ or versions: ``pypcap`` and ``pypcapfile``, which install below 3.12 only, and Scapy, which the ``integration`` and ``pypcap-parity`` legs install on every interpreter and a ``test`` leg off ``latest`` skips; -* the hand-picked round trips, whatever their names, and the #1202 edge-case - tables, whose cases are each written out by hand, as the owner ruled on #1958; +* the hand-picked round trips, whatever their names, and the :issue:`1202` edge-case + tables, whose cases are each written out by hand, as the owner ruled on :issue:`1958`; and * the smoke slice: a representative module per protocol family and per package, so each leg still drives the main parse and build paths. @@ -1164,7 +1163,7 @@ or versions: Everything else is ``LATEST``: the per-protocol field-by-field tests, whose behaviour is interpreter-independent pure Python, each real sweep -- a round trip over every class or registry entry a table enumerates, every offset of a packet or -every capture, as the owner ruled on #1732 and #1958 -- and the docs, project and +every capture, as the owner ruled on :issue:`1732` and :issue:`1958` -- and the docs, project and structure tests. The vendor extra is installed on ``latest`` alone and the crawlers are pure Python, so the vendor modules are ``LATEST``, each saying so, but for the file-restore and environment-variable tests, which stay on every leg. The tier guard itself is ``ALL``, since :file:`tests/conftest.py` @@ -1208,7 +1207,7 @@ rather than lengthening it under coverage, and it is also the job that runs the readers on a docs-only diff. Latest CPython runs the whole suite, as the sanity check and the gate (the owner's -ruling on #1958), so it also installs what the whole suite gates on. In ``test`` and +ruling on :issue:`1958`), so it also installs what the whole suite gates on. In ``test`` and ``integration`` the step ``Install the whole suite's dependencies on latest`` runs on the ``latest`` leg alone, after the install every leg runs, and adds the engines pip can install there (``Scapy``, ``PyShark``), html5lib through ``vendor``, Sphinx and @@ -1258,11 +1257,11 @@ those pins drive read the tier file on the runner's own ``python3``, and :file:`tests/project/test_python_versions_1660.py` checks those skips in a child interpreter that cannot import ``tomllib``. -File Size Caps (`#1969 `__) +File Size Caps (:issue:`1969`) ------------------------------------------------------------------------------- -A test that writes without bound fills the runner's disk rather than failing: `#1372 -`__'s ``out.json`` grew to +A test that writes without bound fills the runner's disk rather than failing: :issue:`1372`'s +``out.json`` grew to several gigabytes on every run. So every pytest and unittest command in :file:`.github/workflows/unit-tests.yml` runs as ``python util/cap_file_size.py python -m ...``: ``test``'s ``Run unit tests``; ``integration``'s, @@ -1285,7 +1284,7 @@ killed by the signal, whose default action the script restores before it execs. ``unit-tests.yml`` runs pytest, unittest or :file:`util/run_unittest_leg.py` without the script, and runs the script against a small limit. -Workflow Scripts (`#1967 `__) +Workflow Scripts (:issue:`1967`) --------------------------------------------------------------------------------- Shell that several steps repeat, or that has real logic -- branches, or a bug @@ -1307,8 +1306,8 @@ to a script. Each script's docstring says what it does; each has tests under - ``KTLO summary`` in ``unit-tests.yml``'s ``test``, ``integration`` and ``pypcap-parity`` (and so their KTLO twins), ``python-compatibility.yml``'s ``compatibility`` and ``create-release.yml``'s ``pypi``, as ``leg_summary.py - ktlo``; ``Experimental summary`` in ``experimental.yml``'s ``experimental``, - as ``leg_summary.py experimental``. + ktlo``; ``Experimental summary`` in ``experimental.yml``'s ``experimental`` + and ``windows``, as ``leg_summary.py experimental``. - No. * - :file:`util/classify_changes.py` - ``Classify the diff`` in the ``changes`` job of ``unit-tests.yml`` and of @@ -1331,7 +1330,8 @@ to a script. Each script's docstring says what it does; each has tests under * - :file:`util/pip_install.py` - The install step of ``unit-tests.yml``'s ``test``, ``integration`` and ``pypcap-parity`` (and so their KTLO twins), ``experimental.yml``'s - ``experimental`` and ``python-compatibility.yml``'s ``compatibility``, at the + ``experimental`` and ``windows`` and ``python-compatibility.yml``'s + ``compatibility``, at the job's ``ANNOTATION_LEVEL``, and of ``unittest-ordering``, ``project-tests`` and ``gate``, at ``error``. - No: install with ``pip install -e '.[test,DPKT,crypto,NGAP]'``. @@ -1354,8 +1354,8 @@ to a script. Each script's docstring says what it does; each has tests under ``benchmark.yml``'s ``benchmark``, as ``install`` alone. The ``actions/cache`` steps, the seed steps (``Seed apt's archive with this engine's cached .deb files`` and ``Seed apt's archive with the cached .deb - files``) and the AppArmor step (`#2019 - `__) stay inline. + files``) and the AppArmor step (:issue:`2019`) + stay inline. - No. * - :file:`util/_actions.py` - No step: it is imported by the scripts that share its annotations, step @@ -1371,50 +1371,48 @@ to a script. Each script's docstring says what it does; each has tests under - ``Check the release tags name this run's commit`` in ``create-release.yml``'s ``github``, ``pypi`` and ``conda``, as ``release_tags.py check``, and ``Check which commit the version's tag names`` in its ``version_check``, as - ``release_tags.py owner`` (`#1634 - `__, `#1748 - `__). + ``release_tags.py owner`` (:issue:`1634`, + :issue:`1748`). - No. * - :file:`util/release_upload.py` - ``Upload package to GitHub Release`` in ``create-release.yml``'s ``pypi`` and - ``conda`` (`#1640 `__). + ``conda`` (:issue:`1640`). - No. * - :file:`util/release_status.py` - ``Report why this run did or did not release anything`` in ``create-release.yml``'s ``release_status``, on the image's ``python3`` - after a checkout of :file:`util/` alone (`#888 - `__). + after a checkout of :file:`util/` alone (:issue:`888`). - No. * - :file:`util/release_tiers.py` - ``Read the released Python versions`` in ``create-release.yml``'s - ``version_check``, on setup-python's ``python3`` (`#1660 - `__). + ``version_check``, on setup-python's ``python3`` (:issue:`1660`). - No. * - :file:`util/release_published.py` - ``Check PyPI publish completeness`` and ``Check Anaconda publish completeness`` in ``create-release.yml``'s ``version_check``, as ``release_published.py pypi`` and ``anaconda``, on setup-python's - ``python3`` (`#888 `__). + ``python3`` (:issue:`888`). - No. * - :file:`util/release_conda.py` - ``Check whether this leg is already on Anaconda`` in ``create-release.yml``'s ``conda``, as ``release_conda.py leg``; ``Build conda packages`` and ``Upload conda packages to Anaconda (Intel)`` in that job and in ``cron-conda.yml``'s - ``conda-dist``, as ``release_conda.py build`` and ``upload`` (`#1822 - `__, `#2044 - `__), and ``Upload conda + ``conda-dist``, as ``release_conda.py build`` and ``upload`` (:issue:`1822`, + :issue:`2044`), + and ``Upload conda packages to Anaconda (Windows)`` in both as ``upload`` (:issue:`2060`). - No. * - :file:`util/release_reset_build.py` - ``Reset Build Number`` in ``create-release.yml``'s ``tag``, on the image's - ``python3`` (`#1634 `__). + ``python3`` (:issue:`1634`). ``Create Tag`` before it stays inline. - No. The summaries run on the image's ``/usr/bin/python3`` rather than the leg's interpreter, which may have failed to set up or be too broken to run anything, so they need only the standard library, as ``classify_changes.py`` does on the -runner's ``python3``. :file:`util/pytest_shards.py` runs on ``/usr/bin/python3`` +runner's ``python3``. The Windows leg, which has no ``/usr/bin/python3``, runs its +summary on ``python``, setup-python's or the image's. :file:`util/pytest_shards.py` runs on ``/usr/bin/python3`` too, standard library only, so on GraalPy's leg the leg's interpreter runs pytest alone; on PyPy's legs the leg's interpreter runs the script as well, under the file-size cap every test command of ``unit-tests.yml`` takes. @@ -1426,8 +1424,8 @@ every version the matrices hold, the experimental tier included. :file:`util/apt under ``sudo`` the commands that shell ran. Its ``plan`` and ``install`` both read the job's ``APT_PACKAGES``, so the cache key always describes the set installed. Its bounds are still ``timeout`` run as root inside ``sudo``, given no option, so -uutils' ``timeout`` behaves as GNU's (`#2020 -`__), and the bound reaches +uutils' ``timeout`` behaves as GNU's (:issue:`2020`), +and the bound reaches apt's own children, which a timeout in the script, running as the runner's user, could not signal. Both ``changes`` jobs check out on every event, since the script is in the tree. The release scripts run as ``python3``, from a checkout of the whole tree, and are @@ -1444,8 +1442,8 @@ status, output and calls. since the scripts on ``/usr/bin/python3`` and on every leg's interpreter import it. The scripts a person runs as well, or alone, sit beside the CI-only ones in -:file:`util/` rather than in a directory of their own (`#2050 -`__). Every :file:`util/` +:file:`util/` rather than in a directory of their own (:pr:`2050`). +Every :file:`util/` script not listed here is CI-only. .. list-table:: @@ -1490,7 +1488,7 @@ script not listed here is CI-only. .. _CI Reports: -CI Reports (`#2030 `__) +CI Reports (:issue:`2030`) --------------------------------------------------------------------------- A report with no pull request to comment on goes to GitHub Discussions. One @@ -1597,8 +1595,8 @@ where a job failed without failing it, or where an annotation counts: * On a push run, by the owner's ruling, an annotation counts only on a non-blocking leg, whose steps are forced to succeed: a job named ``(KTLO)`` or - ``(experimental)``, as a KTLO leg whose tests fail warns (`#1672 - `__), or an annotation + ``(experimental)``, as a KTLO leg whose tests fail warns (:issue:`1672`), + or an annotation titled ``(KTLO, non-blocking)``, as ``engine-tests``' KTLO cells write. A passing blocking job's annotations neither count nor appear, unless the run failed. * On any other run, which posts every time, every annotation on a passing job @@ -1607,7 +1605,7 @@ where a job failed without failing it, or where an annotation counts: neither do the annotations a workflow's ``expected`` list in the script holds as written on every run by design. Each entry is a job, a level and a title prefix, an empty prefix matching only an empty title, so a reworded message - still matches. Every list is empty today, and + still matches. Every list is empty, and :file:`tests/project/test_unit_tests_push_reports_2030.py` pins them. A run whose legs all skipped tested nothing, as when ``current`` found its commit @@ -1629,8 +1627,8 @@ days) for the report to download; both run on ``always()`` and are and one cut to fit links the run. ``Run unit tests`` in ``experimental.yml`` sets no ``JOB_SUMMARY_COPY`` either: the line :file:`util/superseded.py` writes there for a stopped run reaches the copy through the watcher's ``--summary-copy`` -option, which the tests it runs never see (`#2078 -`__). The post step runs only +option, which the tests it runs never see (:issue:`2078`). +The post step runs only where the report step's ``post`` output is ``true``, and sets ``GITHUB_WORKFLOW`` to the relayed run's workflow, which :file:`util/discussion_report.py` labels the run with. @@ -1653,7 +1651,7 @@ unchanged. .. _CodeQL Configuration: -CodeQL Configuration (`#2130 `__) +CodeQL Configuration (:issue:`2130`) ------------------------------------------------------------------------------------- ``Initialize CodeQL`` in ``codeql-analysis.yml`` reads @@ -1681,7 +1679,7 @@ config and the input. .. _Action Pins: -Action Pins (`#1968 `__) +Action Pins (:issue:`1968`) ---------------------------------------------------------------------------- A tag or a branch is a reference its owner can move, so every ``uses:`` of an @@ -1703,7 +1701,7 @@ and when :file:`.github/dependabot.yml` drops the ``github-actions`` ecosystem. ``pinned()`` and ``versions()`` in :file:`tests/_workflows.py` read a pin, for a test that names a particular action. -Workflow Lint (`#1967 `__) +Workflow Lint (:issue:`1967`) ------------------------------------------------------------------------------ Lint's ``lint`` job runs actionlint over every workflow in its ``actionlint`` step. @@ -1713,11 +1711,11 @@ it. Dependabot does not read this pin, so a bump changes both. The workflows use 1.7.10 on, and the pin is 1.7.12 or newer. actionlint runs the image's shellcheck over each ``run:`` block. Unlike Lint's -other steps, this one blocks: a finding fails the job, as the owner ruled on #1967 -once #2023, #2037 and #2038 had fixed every finding it reported. It runs last and +other steps, this one blocks: a finding fails the job, as the owner ruled on :issue:`1967` +once :pr:`2023`, :pr:`2037` and :pr:`2038` had fixed every finding it reported. It runs last and on ``!cancelled()``, so a finding skips none of the advisory steps and a failed Python setup does not skip it. ``Lint`` is a required check of ruleset 23497679 -(`#1967 `__), so a red Lint +(:issue:`1967`), so a red Lint holds a merge. The image's shellcheck is not pinned, so a runner image that adds a check can turn Lint red on unchanged code; the fix is to the script it flags. The step's run summary counts the findings by actionlint rule and @@ -1736,11 +1734,11 @@ pins an older release or no checksum, unpacks before it verifies, passes an an advisory step or a failed setup would skip it. It fails too when no workflow sets ``concurrency.queue`` any more. -Dependency Updates (`#1968 `__) +Dependency Updates (:issue:`1968`) ----------------------------------------------------------------------------------- Dependabot opens every dependency bump, weekly, from :file:`.github/dependabot.yml` -(the owner's ruling on #1968): +(the owner's ruling on :issue:`1968`): * ``pip`` over ``/``, ``/docs`` and ``/examples/benchmark``. The docs build pins Sphinx and sphinx-autodoc-typehints exactly in :file:`docs/requirements.txt`, and @@ -1765,7 +1763,7 @@ PyUp runs on its defaults. The PyUp App's installation, and the Python Versions --------------- -Since `#1660 `__, +Since :issue:`1660`, :file:`.github/python-versions.toml` declares every Python version CI runs: one line per version, with its tier -- ``supported``, ``ktlo``, ``experimental``, or ``pre-release``, which no version carries -- and, where ``ubuntu-latest`` cannot @@ -1809,8 +1807,8 @@ commits it did not make: when the branch holds some and proposes other tiers, ``detect`` fails before asking for the approval. Once that pull request is merged or closed, the next run with something to propose recreates the branch from ``main``, a closed proposal's tiers included. It reports a pre-release -instead of writing it. Nor does it retire a job (`#1747 -`__): when the tiers leave +instead of writing it. Nor does it retire a job (:issue:`1747`): +when the tiers leave a matrix job with no leg, as 3.11 reaching end of life leaves ``pypcap-parity``, it writes them as computed, warns, and puts the job first in the body as a manual step: retire or remove it, and the ``needs:`` that list it, @@ -1838,7 +1836,7 @@ approval, in ``create-release.yml`` and ``cron-python-versions.yml``: ``PYPCAPKIT`` token and ``PYPCAPKIT_ACTIONS_DEPLOY_KEY`` are secrets of ``conda-tag``. GitHub gives an environment's secrets only to a job that names it, so the scheduled jobs reading them name it too -(`#1968 `__): +(:issue:`1968`): * Conda Update's ``conda-update`` and ``conda-tag`` -- ``environment: conda-tag`` * Conda Update's ``conda-dist`` -- ``environment: anaconda`` @@ -1871,7 +1869,7 @@ for ``publish-pages``, which alone holds ``contents: write`` and never runs on a pull request. Its ``Check gh-pages holds no newer build`` step skips the deploy when ``gh-pages`` already holds the build of a commit this run's commit is behind, so a build that finishes late cannot replace newer documentation -(`#1968 `__). Of these environments, only +(:issue:`1968`). Of these environments, only ``github-release`` carries a required reviewer; :doc:`releasing` covers why one approval on that single environment is enough to gate the whole release graph, and is the page to read for the reasoning rather than diff --git a/docs/source/engines.rst b/docs/source/engines.rst index 9396b94a5b..8954346c9a 100644 --- a/docs/source/engines.rst +++ b/docs/source/engines.rst @@ -72,7 +72,7 @@ third-party package. ``benchmark-out`` artifact. :file:`examples/benchmark/run.sh` produces the same tables on any machine with Docker, and :file:`examples/benchmark/README.md` explains what each one measures. `Test Environment`_ records where the - current figures came from. + figures on this page came from. Engine Support by Python Version -------------------------------- diff --git a/docs/source/ext.rst b/docs/source/ext.rst index f3430d8571..f87e939f4a 100644 --- a/docs/source/ext.rst +++ b/docs/source/ext.rst @@ -861,7 +861,7 @@ A new reassembly or flow tracing class subclasses # ``buf`` formatted as a ``Buffer`` instance, to a ``Datagram`` # instance, which is then submitted to the storage, i.e., # ``self._dtgram``. - def submit(self, buf: 'Buffer', * , bufid: 'BufferID') -> 'list[Datagram]': + def submit(self, buf: 'Buffer', *, bufid: 'BufferID') -> 'list[Datagram]': datagram = ... # submit algorithm implementation, e.g., to convert # the reassembled datagram to a list of ``Datagram`` # instances, which is then returned for further diff --git a/docs/source/index.rst b/docs/source/index.rst index dc7d6a32de..58ac3207bc 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -213,7 +213,7 @@ plug-in functions, you may want to install the optional ones: The ``all`` extra deliberately excludes both ``pypcap`` and ``pcap-ct``, for different reasons. Everything - else in ``all`` is a pure-Python wheel, whereas ``pypcap`` compiles a C + else in ``all`` installs from a published wheel, whereas ``pypcap`` compiles a C extension; pulling it into ``all`` would demand a working compiler and the `libpcap`_ development files from everyone installing ``pypcapkit[all]``. ``pcap-ct`` needs no compiler, but it and its ``libpcap`` dependency are diff --git a/pcapkit/dumpkit/common.py b/pcapkit/dumpkit/common.py index a318e700bb..4e14b44093 100644 --- a/pcapkit/dumpkit/common.py +++ b/pcapkit/dumpkit/common.py @@ -692,7 +692,7 @@ def _append_string(self, value: 'str', file: 'TextIO') -> 'None': other character as ``'\\u{0:04x}'.format(ord(char))``, which for a code point above ``U+FFFF`` yields five or six hex digits that a reader takes as one escape and literal text - (GitHub issue #1261). This writes such a character as a + (GitHub issue :issue:`1261`). This writes such a character as a UTF-16 surrogate pair instead, as :func:`json.dumps` does, and every other character exactly as the upstream writer does. @@ -763,7 +763,7 @@ def _append_fallback(self, value: 'Any', file: 'TextIO') -> 'None': A class, module or routine is written as its name rather than expanded, since its attributes reach the whole type graph - (GitHub issue #1372). Every other object is expanded in full + (GitHub issue :issue:`1372`). Every other object is expanded in full each time it is met, except one that encloses itself: there it is written as a ```` marker, so a reference cycle terminates. diff --git a/pcapkit/foundation/engines/dpkt.py b/pcapkit/foundation/engines/dpkt.py index c33a4d645d..2f5fcfe71a 100644 --- a/pcapkit/foundation/engines/dpkt.py +++ b/pcapkit/foundation/engines/dpkt.py @@ -78,7 +78,7 @@ class _DPKTLoopbackModule(Protocol): def Loopback(self) -> 'Type[DPKTLoopback]': ... class _DPKTPackage(Protocol): - """|dpkt|_, as :class:`DPKT` reads it through :attr:`DPKT._expkg` (#1878). + """|dpkt|_, as :class:`DPKT` reads it through :attr:`DPKT._expkg` (:issue:`1878`). Each attribute is a submodule, described by what the engine reads of it. mypy matches a module with a protocol at the top level only, so the diff --git a/pcapkit/foundation/engines/pcap_ct.py b/pcapkit/foundation/engines/pcap_ct.py index de19474bad..dbbc2782fa 100644 --- a/pcapkit/foundation/engines/pcap_ct.py +++ b/pcapkit/foundation/engines/pcap_ct.py @@ -29,7 +29,7 @@ from pcapkit.foundation.extraction import Extractor class _PCAPCTPackage(Protocol): - """|pcap|_, as :class:`PCAP_CT` reads it through :attr:`PCAP_CT._expkg` (#1878).""" + """|pcap|_, as :class:`PCAP_CT` reads it through :attr:`PCAP_CT._expkg` (:issue:`1878`).""" @property def pcap(self) -> 'Type[Handle]': ... diff --git a/pcapkit/foundation/engines/pcapng.py b/pcapkit/foundation/engines/pcapng.py index 6102f17e7e..1c0cfe05fe 100644 --- a/pcapkit/foundation/engines/pcapng.py +++ b/pcapkit/foundation/engines/pcapng.py @@ -343,7 +343,7 @@ def _block_name(self, kind: 'str') -> 'str': :attr:`Context.interfaces `) restart with every Section Header Block, so numbering a block by their length gives the first IDB of every section the same name. The output dumpers key by name, so a repeated name overwrites - the earlier block (#1256). The number is therefore counted across the whole file instead; + the earlier block (:issue:`1256`). The number is therefore counted across the whole file instead; for a single-section file the two counts agree. Args: diff --git a/pcapkit/foundation/engines/pypcap.py b/pcapkit/foundation/engines/pypcap.py index b4b9482ccb..4f2d79c3b7 100644 --- a/pcapkit/foundation/engines/pypcap.py +++ b/pcapkit/foundation/engines/pypcap.py @@ -35,7 +35,7 @@ from pcapkit.foundation.extraction import Extractor class _PyPCAPPackage(Protocol): - """|pcap|_, as :class:`PyPCAP` reads it through :attr:`PyPCAP._expkg` (#1878).""" + """|pcap|_, as :class:`PyPCAP` reads it through :attr:`PyPCAP._expkg` (:issue:`1878`).""" @property def pcap(self) -> 'Type[Handle]': ... diff --git a/pcapkit/foundation/engines/pypcapfile.py b/pcapkit/foundation/engines/pypcapfile.py index f0ef4bd3d0..370de917b1 100644 --- a/pcapkit/foundation/engines/pypcapfile.py +++ b/pcapkit/foundation/engines/pypcapfile.py @@ -52,7 +52,7 @@ class _PCAPFileStructsModule(Protocol): def pcap_packet(self) -> 'Type[PCAPFilePacket]': ... class _PCAPFilePackage(Protocol): - """|pcapfile|_, as :class:`PyPCAPFile` reads it through :attr:`PyPCAPFile._expkg` (#1878). + """|pcapfile|_, as :class:`PyPCAPFile` reads it through :attr:`PyPCAPFile._expkg` (:issue:`1878`). Each attribute is a submodule, described by what the engine reads of it. mypy matches a module with a protocol at the top level only, so the diff --git a/pcapkit/foundation/engines/pyshark.py b/pcapkit/foundation/engines/pyshark.py index a58d28242f..c2e27f91f8 100644 --- a/pcapkit/foundation/engines/pyshark.py +++ b/pcapkit/foundation/engines/pyshark.py @@ -29,7 +29,7 @@ from pcapkit.foundation.extraction import Extractor class _PySharkPackage(Protocol): - """|pyshark|_, as :class:`PyShark` reads it through :attr:`PyShark._expkg` (#1878).""" + """|pyshark|_, as :class:`PyShark` reads it through :attr:`PyShark._expkg` (:issue:`1878`).""" @property def FileCapture(self) -> 'Type[PySharkFileCapture]': ... diff --git a/pcapkit/foundation/engines/scapy.py b/pcapkit/foundation/engines/scapy.py index c39dea98f2..59f1492896 100644 --- a/pcapkit/foundation/engines/scapy.py +++ b/pcapkit/foundation/engines/scapy.py @@ -58,7 +58,7 @@ from pcapkit.foundation.extraction import Extractor class _ScapyPackage(Protocol): - """``scapy.all``, as :class:`Scapy` reads it through :attr:`Scapy._expkg` (#1878). + """``scapy.all``, as :class:`Scapy` reads it through :attr:`Scapy._expkg` (:issue:`1878`). Typed by scapy itself, which ships its own type information. @@ -76,16 +76,16 @@ def sniff(self, *args: 'Any', **kwargs: 'Any') -> 'PacketList': ... #: The most octets a record can hold, as its captured length is 32 bits in a #: PCAP record header and in every PCAP-NG packet block. As the ``size`` a -#: packet is read with, it cuts nothing (#1717). +#: packet is read with, it cuts nothing (:issue:`1717`). _CAPLEN_MAX = 0xFFFF_FFFF #: The most octets of a PCAP record read in one call, so that a forged captured -#: length costs only the octets the file holds, as in Scapy 2.8 (#1717). +#: length costs only the octets the file holds, as in Scapy 2.8 (:issue:`1717`). _READ_CHUNK = 0x40000 def _read_tsoffset(options: 'bytes', endian: 'str') -> 'int': - """Read the ``if_tsoffset`` of a PCAP-NG Interface Description Block (#1549). + """Read the ``if_tsoffset`` of a PCAP-NG Interface Description Block (:issue:`1549`). Read here rather than by `Scapy`_'s ``_read_options``, whose result differs by version: keyed by option code in 2.7 and 2.8, and in 2.5 by name, with @@ -117,7 +117,7 @@ def _reader_type() -> 'Type[PcapReader]': comes out with the same digits whether the capture counted microseconds or nanoseconds. They drop the link type too, once it has picked the packet's first layer, and a layer does not name its link type: a raw IPv4 packet's is - :class:`~scapy.layers.inet.IP` (#1517). The PCAP frame record and the flow + :class:`~scapy.layers.inet.IP` (:issue:`1517`). The PCAP frame record and the flow tracing input of a packet need both, so these subclasses attach them with :func:`pcapkit.toolkit.scapy.attach_resolution` and :func:`pcapkit.toolkit.scapy.attach_linktype`: from the global header of a @@ -128,15 +128,15 @@ def _reader_type() -> 'Type[PcapReader]': PCAP-NG specification defines a section: an interface ID counts the Interface Description Blocks of its own section only. `Scapy`_'s reader keeps appending to one table across Section Header Blocks, so a later section's interface 0 - resolved to the first section's, with its link type and resolution (#1522). + resolved to the first section's, with its link type and resolution (:issue:`1522`). And it dates packets that `Scapy`_ misdates. A Simple Packet Block has no timestamp, and `Scapy`_ leaves its packet with the time it was - built, so it is set to 0 (#1503). `Scapy`_ ignores an interface's + built, so it is set to 0 (:issue:`1503`). `Scapy`_ ignores an interface's ``if_tsoffset``, the seconds to add to each of its timestamps, so the - subclass reads it and adds it (#1549). + subclass reads it and adds it (:issue:`1549`). - Both subclasses read each record whole (#1717). ``sniff`` reads a packet + Both subclasses read each record whole (:issue:`1717`). ``sniff`` reads a packet with ``recv()``, whose ``size`` defaults to `Scapy`_'s ``MTU``, 65,535 octets, and every reader cuts the record to ``size``, so ``recv`` defaults to :data:`_CAPLEN_MAX` instead, as does a PCAP-NG block, which `Scapy`_ 2.6 diff --git a/pcapkit/protocols/schema/misc/pcapng.py b/pcapkit/protocols/schema/misc/pcapng.py index 9a55e68584..920a6b853d 100644 --- a/pcapkit/protocols/schema/misc/pcapng.py +++ b/pcapkit/protocols/schema/misc/pcapng.py @@ -389,7 +389,7 @@ def bounded_option(length: 'Callable[[dict[str, Any]], int]') -> 'Callable[[dict An option's length is a 16-bit wire field, so one four-octet option header can declare 65,535 octets of payload. Read as declared, every shortfall would be zero-padded at the field layer, and the zeros rebuilt as if they - had been captured (#594, #1325). So a payload that runs past the area is + had been captured (:issue:`594`, :issue:`1325`). So a payload that runs past the area is refused here instead. Inside a block, :class:`OptionAreaField` then keeps the whole option area as the octets captured, so the block still parses and rebuilds byte for byte. @@ -426,7 +426,7 @@ class OptionAreaField(OptionField): or :func:`bounded_option`. A block has no catch point above its fields, so raising would abort the whole extraction. This field instead returns the area as a single :obj:`bytes` item, which packs back verbatim, and reports - no padding after it (#1325). + no padding after it (:issue:`1325`). """ diff --git a/pcapkit/toolkit/pyshark.py b/pcapkit/toolkit/pyshark.py index 623b994b07..4e609afb59 100644 --- a/pcapkit/toolkit/pyshark.py +++ b/pcapkit/toolkit/pyshark.py @@ -319,7 +319,7 @@ #: PyShark's text for a boolean field -> its value. tshark 4.x spells one ``'True'``/``'False'`` #: (measured for ``tcp.flags_syn``, ``tcp.flags_fin`` and ``tcp.flags_reset`` with PyShark 0.6 -#: on tshark 4.6.9); ``'1'``/``'0'`` is the spelling this module first parsed (#1514). +#: on tshark 4.6.9); ``'1'``/``'0'`` is the spelling this module first parsed (:issue:`1514`). _BOOLEAN_FIELD = {'1': True, '0': False, 'True': True, 'False': False} # type: dict[str, bool] diff --git a/tests/project/test_docs_issue_pr_roles_1103_unit.py b/tests/project/test_docs_issue_pr_roles_1103_unit.py new file mode 100644 index 0000000000..87764a8a1e --- /dev/null +++ b/tests/project/test_docs_issue_pr_roles_1103_unit.py @@ -0,0 +1,296 @@ +# -*- coding: utf-8 -*- +"""Rendered text cites this repository's issues and pull requests by role (#1103). + +Sphinx renders ``.rst`` pages, docstrings and ``#:`` attribute doc-comments, and +there an issue is cited as ``:issue:`NNN``` and a pull request as ``:pr:`NNN```. +The roles render the same ``#NNN`` caption a hand-written link would, from one +template in :file:`docs/source/conf.py`, and they say which of the two the number +is. A bare ``#NNN`` in rendered text is not a link at all. Plain ``#`` comments, +tests and other unrendered files keep ``#NNN``, which +:mod:`tests.project.test_issue_refs_in_comments` pins from the other side. + +* :class:`ScannerTests` -- the scanners skip literals, code blocks, comments, + link targets and URL fragments, and flag the rest. +* :class:`RenderedTextTests` -- no ``.rst`` page under :file:`docs/source`, no + :file:`pcapkit/` docstring and no ``#:`` doc-comment holds a hand-written link + to an issue or pull request of this repository, or a bare ``#NNN``. + +Links to other repositories, to Discussions and to a comment on an issue keep +their explicit URLs, which a role cannot express; ``GH-NNN`` is a deliberate +issue form and is not matched. Everything is read as text or parsed with +:mod:`ast` and :mod:`tokenize`; nothing is imported or built. + +""" + +from __future__ import annotations + +import ast +import io +import pathlib +import re +import tokenize +import unittest +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Iterator + +#: Test tier (#1958): reads the docs sources and the package's docstrings as text. +__tier__ = 'LATEST' + +ROOT = pathlib.Path(__file__).resolve().parents[2] +DOCS = ROOT / 'docs' / 'source' +PACKAGE = ROOT / 'pcapkit' + +#: A hand-written link, of any caption, to an issue or pull request of this +#: repository. A URL with a fragment or a further path, such as a comment on an +#: issue, is not matched: no role can express it. +HAND_LINK = re.compile(r'`[^`<]*`_{1,2}') + +#: A bare ``#NNN``. The look-behind leaves out URL fragments (``page#12``), +#: character references (`` ``) and ``##12``. +BARE_NUMBER = re.compile(r'(?`__'), + ('docs/source/contributing/workflows.rst', + '`#1938 `__'), + ('docs/source/contributing/workflows.rst', + '`#1954 `__'), + ('docs/source/contributing/workflows.rst', '#1962'), + ('docs/source/contributing/workflows.rst', + '`#1963 `__'), + ('docs/source/contributing/workflows.rst', + '`#2005 `__'), + ('docs/source/contributing/workflows.rst', + '`#2073 `__'), + ('docs/source/contributing/workflows.rst', + '`#2077 `__'), +}) + + +def _indent(line: 'str') -> 'int': + return len(line) - len(line.lstrip()) + + +def rendered_lines(text: 'str') -> 'list[str]': + """``text`` with comments and literal or code blocks blanked, line for line. + + reST comments do not render, and a literal block or a code directive renders + verbatim, so neither can hold a role. A blanked line stays in place as an + empty string, which keeps the line numbers of what is left. + + """ + lines = text.splitlines() + out = [] + skip_deeper_than: 'int | None' = None + literal_next, doctest, base = False, False, 0 + for line in lines: + stripped = line.strip() + if doctest: + # A doctest block runs until the next blank line. + doctest = bool(stripped) + out.append('') + continue + if skip_deeper_than is not None: + if not stripped or _indent(line) > skip_deeper_than: + out.append('') + continue + skip_deeper_than = None + if literal_next and stripped: + literal_next = False + # The literal block is whatever follows, indented deeper than the + # paragraph that introduced it. + if _indent(line) > base: + skip_deeper_than = base + out.append('') + continue + if stripped.startswith('>>>'): + doctest = True + out.append('') + continue + directive = DIRECTIVE.match(stripped) + if directive is not None: + if directive.group(1) in LITERAL_DIRECTIVES: + skip_deeper_than = _indent(line) + out.append('') + continue + out.append(line) + continue + if (stripped == '..' or stripped.startswith('.. ')) and not stripped.startswith(NOT_COMMENT): + # A comment: ``..`` followed by anything that is not explicit markup. + skip_deeper_than = _indent(line) + out.append('') + continue + if stripped.endswith('::'): + literal_next = True + base = _indent(line) + # ``Text::`` renders ``Text:``, so the paragraph itself still counts. + out.append(line) + return out + + +def _blank(match: 're.Match[str]') -> 'str': + """The newlines of ``match``, so that removing it keeps the line numbers.""" + return '\n' * match.group().count('\n') + + +def scan_text(text: 'str', first_line: 'int' = 1) -> 'Iterator[tuple[int, str]]': + """The line and text of each hand-written link and bare ``#NNN`` in ``text``. + + Inline literals render verbatim, so they are removed first; then the + hand-written links are reported, and every link and role is removed, so + that a caption is not read as a bare number. + + """ + body = INLINE_LITERAL.sub(_blank, '\n'.join(rendered_lines(text))) + for match in HAND_LINK.finditer(body): + yield first_line + body.count('\n', 0, match.start()), ' '.join(match.group().split()) + rest = INTERPRETED.sub(_blank, body) + for match in BARE_NUMBER.finditer(rest): + yield first_line + rest.count('\n', 0, match.start()), match.group() + + +def docstrings(source: 'str') -> 'Iterator[tuple[int, str]]': + """The first line and text of each docstring, attribute docstrings included.""" + tree = ast.parse(source) + for node in ast.walk(tree): + if not isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + continue + body = node.body + for index, child in enumerate(body): + if not (isinstance(child, ast.Expr) and isinstance(child.value, ast.Constant) + and isinstance(child.value.value, str)): + continue + # A docstring, or an attribute docstring right after an assignment. + if index == 0 or isinstance(body[index - 1], (ast.Assign, ast.AnnAssign)): + yield child.lineno, child.value.value + + +def doc_comments(source: 'str') -> 'Iterator[tuple[int, str]]': + """The first line and joined text of each run of consecutive ``#:`` comments.""" + start, block, last = 0, [], -1 + for token in tokenize.generate_tokens(io.StringIO(source).readline): + if token.type != tokenize.COMMENT or not token.string.startswith('#:'): + continue + line = token.start[0] + if block and line != last + 1: + yield start, '\n'.join(block) + block = [] + if not block: + start = line + block.append(token.string[2:]) + last = line + if block: + yield start, '\n'.join(block) + + +def scan_python(source: 'str') -> 'Iterator[tuple[int, str]]': + """Every match in the docstrings and ``#:`` doc-comments of ``source``.""" + for line, text in docstrings(source): + yield from scan_text(text, line) + for line, text in doc_comments(source): + yield from scan_text(text, line) + + +def repository_matches() -> 'list[tuple[str, int, str]]': + """Every match in the rendered text of the repository.""" + found = [] + for path in sorted(DOCS.rglob('*.rst')): + name = path.relative_to(ROOT).as_posix() + found.extend((name, line, text) for line, text in scan_text(path.read_text('utf-8'))) + for path in sorted(PACKAGE.rglob('*.py')): + name = path.relative_to(ROOT).as_posix() + found.extend((name, line, text) for line, text in scan_python(path.read_text('utf-8'))) + return found + + +class ScannerTests(unittest.TestCase): + """The scanners flag rendered text only.""" + + def matches(self, text: 'str') -> 'list[str]': + return [match for _, match in scan_text(text)] + + def test_flags_a_hand_written_link(self) -> None: + text = 'See `#12\n`__ and\n' \ + '`the fix `_.\n' + self.assertEqual([line for line, _ in scan_text(text)], [1, 3]) + + def test_flags_a_bare_number(self) -> None: + self.assertEqual(self.matches('Fixed in #12 (#34).\n'), ['#12', '#34']) + + def test_keeps_roles_and_other_links(self) -> None: + text = (':issue:`12`, :pr:`34`, GH-56, :discussion:`78`,\n' + '`a comment `__,\n' + '`upstream #90 `__ and [,\n' + 'see `Workflow Scripts (#92)`_.\n') + self.assertEqual(self.matches(text), []) + + def test_skips_literals_and_code(self) -> None: + text = ('A ``#12`` literal::\n\n # see #34\n\nback.\n\n' + '.. code-block:: yaml\n\n # #56\n\n.. a comment on #78\n still #90\n\nEnd.\n') + self.assertEqual(self.matches(text), []) + + def test_skips_doctest_blocks_only(self) -> None: + text = 'Example:\n\n>>> f() # #12\n#34\n\nFixed in #56.\n' + self.assertEqual(self.matches(text), ['#56']) + + def test_keeps_admonition_bodies(self) -> None: + self.assertEqual(self.matches('.. note::\n\n Fixed in #12.\n'), ['#12']) + + def test_reads_docstrings_and_doc_comments(self) -> None: + source = ('"""Module, #1."""\n#: Doc-comment,\n#: #2.\nX = 1\n"""Attribute, #3."""\n' + 'def f():\n """Function, #4."""\n # plain #5\n return "#6"\n') + self.assertEqual([match for _, match in scan_python(source)], ['#1', '#3', '#4', '#2']) + + def test_allowed_entries_still_match(self) -> None: + found = {(path, text) for path, _, text in repository_matches()} + self.assertEqual(sorted(ALLOWED - found), [], 'stale ALLOWED entries') + + +class RenderedTextTests(unittest.TestCase): + """No rendered text cites an issue or pull request without a role.""" + + def test_rendered_text_uses_roles(self) -> None: + offending = [f'{path}:{line}: {text}' for path, line, text in repository_matches() + if (path, text) not in ALLOWED] + self.assertEqual(offending, [], 'cite issues as :issue:`NNN` and pull requests as ' + ':pr:`NNN` in .rst pages, docstrings and #: doc-comments') + + +if __name__ == '__main__': + unittest.main()