diff --git a/docs/source/contributing/releasing.rst b/docs/source/contributing/releasing.rst index 421b05c83f..347da17bb7 100644 --- a/docs/source/contributing/releasing.rst +++ b/docs/source/contributing/releasing.rst @@ -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 `__) are not counted. This job is + :ref:`KTLO ` legs' wheels (`#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 diff --git a/docs/source/contributing/workflows.rst b/docs/source/contributing/workflows.rst index cef8335160..3021e817ab 100644 --- a/docs/source/contributing/workflows.rst +++ b/docs/source/contributing/workflows.rst @@ -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 `__): 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 ` leg, a job whose name ends in ``(KTLO)`` (`#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 @@ -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 `__), and experimental (`#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 `__). In ``unit-tests.yml`` the KTLO legs are jobs of their own since `#1738 `__: ``test-ktlo``, diff --git a/tests/project/test_docs_ktlo_defined_1950_unit.py b/tests/project/test_docs_ktlo_defined_1950_unit.py new file mode 100644 index 0000000000..6890062e32 --- /dev/null +++ b/tests/project/test_docs_ktlo_defined_1950_unit.py @@ -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()