Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/source/contributing/releasing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ 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
KTLO legs' wheels (`#1633 <https://github.com/JarryShaw/PyPCAPKit/issues/1633>`__) are not counted. This job is
:ref:`KTLO <ktlo-tier>` legs' wheels (`#1633 <https://github.com/JarryShaw/PyPCAPKit/issues/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
Expand Down
6 changes: 4 additions & 2 deletions docs/source/contributing/workflows.rst
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@ fire while the pool stays saturated, and the JUnit timings are what show whether

``H1``, ``H2`` and ``H4`` on a non-blocking job are warnings, not HARD, so they do
not fail the run (`#1732 <https://github.com/JarryShaw/PyPCAPKit/issues/1732>`__): on every job of :file:`experimental.yml`, which no
required context names, on every KTLO leg, a job whose name ends in ``(KTLO)``
required context names, on every :ref:`KTLO <ktlo-tier>` leg, a job whose name ends in ``(KTLO)``
(`#1633 <https://github.com/JarryShaw/PyPCAPKit/issues/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
Expand Down Expand Up @@ -686,12 +686,14 @@ triggers; the ``gate-only: true`` reusable calls documented above skip it via
``if: ${{ always() && inputs.gate-only != true }}`` (``unit-tests.yml:1939``),
so it is never produced -- and never expected -- on those paths.

.. _ktlo-tier:

Python versions are tiered -- supported and KTLO
(`#1633 <https://github.com/JarryShaw/PyPCAPKit/issues/1633>`__), and experimental
(`#1660 <https://github.com/JarryShaw/PyPCAPKit/issues/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** tier -- end-of-life 3.7-3.10, still built and
required ``Compat`` context. The **KTLO** (*keep the lights on*) tier -- end-of-life 3.7-3.10, still built and
tested -- never blocks, is never a required context, and never shows a commit or a
pull request red (`#1672 <https://github.com/JarryShaw/PyPCAPKit/issues/1672>`__). In ``unit-tests.yml`` the KTLO legs are jobs of their own
since `#1738 <https://github.com/JarryShaw/PyPCAPKit/issues/1738>`__: ``test-ktlo``,
Expand Down
135 changes: 135 additions & 0 deletions tests/project/test_docs_ktlo_defined_1950_unit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
"""Pins that the KTLO acronym is spelled out where it is defined and linked before that (#1950).

KTLO -- *keep the lights on* -- names the tier of end-of-life Python versions
that stay built and tested on non-blocking legs. The contributor pages use the
acronym ahead of the paragraph on :file:`docs/source/contributing/workflows.rst`
that defines the tier, so that paragraph spells it out and every page links its
first mention there.

* :class:`DefinitionTests` -- the defining paragraph spells the acronym out and
carries the ``ktlo-tier`` label directly above it, declared on that page only.
* :class:`FirstMentionTests` -- on every reStructuredText page outside the
changelog, the first mention of KTLO in prose links to that label, unless the
page is the defining one and the mention comes after the definition; and every
Markdown file spells the acronym out at its first use, since ``:ref:`` does not
work there.

A mention inside a literal -- a double-backtick one in reStructuredText, a code
span or fence in Markdown -- such as a job name ending in ``(KTLO)``, is not prose
and is not checked.

"""

from __future__ import annotations

import os
import pathlib
import re
import unittest
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from typing import Iterator

ROOT = pathlib.Path(__file__).resolve().parents[2]
DOCS = ROOT / 'docs' / 'source'

#: The page that defines the tier, relative to :data:`DOCS`.
DEFINING_PAGE = 'contributing/workflows.rst'

LABEL = 'ktlo-tier'
LABEL_LINE = re.compile(r'^\.\. _ktlo-tier:[ \t]*$', re.MULTILINE)
EXPANSION = 'keep the lights on'
MENTION = re.compile(r'\bKTLO\b')
LITERAL = re.compile(r'``.+?``', re.DOTALL)
MARKDOWN_CODE = re.compile(r'^```.*?^```|`[^`\n]+`', re.DOTALL | re.MULTILINE)
LINKED = re.compile(r':ref:`[^`<]*<' + re.escape(LABEL) + r'>`')

#: Directories never searched for Markdown: tool state, environments, build output.
SKIPPED_DIRS = frozenset({'.git', '.claude', '.venv', 'venv', 'node_modules', 'build', 'dist', '_build'})


def mask_literals(text: str) -> str:
"""Blank out double-backtick literals, keeping every other offset where it was."""
return LITERAL.sub(lambda match: ' ' * len(match.group()), text)


def rst_pages() -> Iterator[pathlib.Path]:
"""Every reStructuredText page under :data:`DOCS` outside the changelog."""
for path in sorted(DOCS.rglob('*.rst')):
relative = path.relative_to(DOCS).as_posix()
if relative == 'changelog.rst' or relative.startswith('changelog/'):
continue
yield path


def markdown_files() -> Iterator[pathlib.Path]:
"""Every Markdown file in the repository, outside :data:`SKIPPED_DIRS`."""
for dirpath, dirnames, filenames in os.walk(ROOT):
dirnames[:] = sorted(name for name in dirnames if name not in SKIPPED_DIRS)
for name in sorted(filenames):
if name.endswith('.md'):
yield pathlib.Path(dirpath, name)


class DefinitionTests(unittest.TestCase):
"""The tier's paragraph spells KTLO out, with the label above it."""

def setUp(self) -> None:
self.text = (DOCS / DEFINING_PAGE).read_text(encoding='utf-8')

def test_the_label_is_declared_once_on_the_defining_page(self) -> None:
self.assertEqual(len(LABEL_LINE.findall(self.text)), 1)
for path in rst_pages():
relative = path.relative_to(DOCS).as_posix()
if relative != DEFINING_PAGE:
with self.subTest(page=relative):
self.assertIsNone(LABEL_LINE.search(path.read_text(encoding='utf-8')))

def test_the_paragraph_under_the_label_spells_the_acronym_out(self) -> None:
match = LABEL_LINE.search(self.text)
self.assertIsNotNone(match, f'no ``.. _{LABEL}:`` label on {DEFINING_PAGE}')
assert match is not None
paragraph = self.text[match.end():].lstrip('\n').split('\n\n', 1)[0]
prose = ' '.join(paragraph.split())
self.assertIn('**KTLO**', prose)
self.assertIn(EXPANSION, prose)
self.assertLess(prose.index('**KTLO**'), prose.index(EXPANSION))


class FirstMentionTests(unittest.TestCase):
"""Each page's first prose mention of KTLO reaches the definition."""

def test_each_page_links_its_first_mention_or_follows_the_definition(self) -> None:
pages = 0
for path in rst_pages():
text = path.read_text(encoding='utf-8')
mention = MENTION.search(mask_literals(text))
if mention is None:
continue
pages += 1
relative = path.relative_to(DOCS).as_posix()
with self.subTest(page=relative):
label = LABEL_LINE.search(text)
if relative == DEFINING_PAGE and label is not None and mention.start() > label.end():
continue
linked = any(link.start() <= mention.start() < link.end() for link in LINKED.finditer(text))
line = text.count('\n', 0, mention.start()) + 1
self.assertTrue(linked, f'{relative}:{line}: first KTLO mention does not link to {LABEL!r}')
self.assertGreater(pages, 0, 'no page mentions KTLO, so this test checks nothing')

def test_markdown_spells_the_acronym_out_at_first_use(self) -> None:
for path in markdown_files():
text = path.read_text(encoding='utf-8', errors='replace')
prose = MARKDOWN_CODE.sub(lambda match: ' ' * len(match.group()), text)
mention = MENTION.search(prose)
if mention is None:
continue
with self.subTest(file=path.relative_to(ROOT).as_posix()):
sentence = ' '.join(prose[mention.start():mention.start() + 200].split())
self.assertIn(EXPANSION, sentence.lower())


if __name__ == '__main__':
unittest.main()
Loading