From 21cca66ef7a7113c9c665795e18bb8c3949d430a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 17 Oct 2026 22:19:20 +0300 Subject: [PATCH] test: add a shared prose check and CI10 for it tests/test_prose.py fails on an em or en dash in prose Markdown and on emoji in a README heading. Repos fetch it from main with just prose-check, the way CI9 runs the ADR check. Appendix B and DEPLOYMENT.md lose their em dashes so this repo passes its own check. --- docs/DEPLOYMENT.md | 2 +- docs/standard.md | 29 +++++++++- tests/test_prose.py | 90 +++++++++++++++++++++++++++++++ tests/test_prose_scanner.py | 103 ++++++++++++++++++++++++++++++++++++ 4 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 tests/test_prose.py create mode 100644 tests/test_prose_scanner.py diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index b74b4df..17e06d4 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -38,5 +38,5 @@ Repo → **Settings → Pages → tick "Enforce HTTPS"**. ## Notes - Only one Pages site per org can claim the apex `modern-python.org`. This - `.github` repo owns it — no other repo should set the same custom domain. + `.github` repo owns it, so no other repo should set the same custom domain. - The published custom domain is held by `docs/CNAME`; do not remove it. diff --git a/docs/standard.md b/docs/standard.md index 4babf91..56bcb17 100644 --- a/docs/standard.md +++ b/docs/standard.md @@ -302,6 +302,33 @@ coverage gate out of a one-file run; the temporary directory sits inside the rep plugins such as `pytest-alembic` reject collected files outside the root. `adr_check_source` can be pointed at a `file://` path to run an unmerged version of the check. +### CI10 · Shared prose check { #CI10 } + +The `lint` job SHOULD run `just prose-check` after `just install lint-ci`. The recipe SHOULD be +this, byte for byte, and a repo SHOULD NOT keep a copy of `tests/test_prose.py`: + +```just +prose_check_source := "https://raw.githubusercontent.com/modern-python/.github/main/tests/test_prose.py" + +# Tracks main on purpose: the shared check is unpinned. +prose-check: + #!/usr/bin/env sh + set -eu + dir="$(mktemp -d .prose-check.XXXXXX)" + trap 'rm -rf "$dir"' EXIT + curl -fsSL "{{ prose_check_source }}" -o "$dir/test_prose.py" + uv run --no-sync pytest --rootdir=. --noconftest -o addopts= "$dir/test_prose.py" +``` + +The check fails on an em dash (U+2014) or an en dash (U+2013) in the repo's prose Markdown, and on +emoji in a heading of `README.md` ([RM3](#RM3)). Prose Markdown is the root `README.md`, +`CONTEXT.md` and `AGENTS.md`, and every `.md` under `docs/` except `docs/agents/`. The check skips +fenced code, inline code, a table cell that holds only a dash (an empty default), and an en dash +between two digits, which is a range (`3–5`). + +*Why:* every repo's prose was cleared of both, and without this check nothing fails when either +comes back. The recipe has the shape of [CI9](#CI9)'s, for the reasons given there. + ## Release ### RL1 · Tags { #RL1 } @@ -510,7 +537,7 @@ isort.no-lines-before = ["standard-library", "local-folder"] The canonical paragraphs for [FL2](#FL2). ```markdown -`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth — +`just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth: `just --list`, or read it. ``` diff --git a/tests/test_prose.py b/tests/test_prose.py new file mode 100644 index 0000000..977954f --- /dev/null +++ b/tests/test_prose.py @@ -0,0 +1,90 @@ +import os +import pathlib +import re +import typing + +import pytest + + +EM_DASH: typing.Final = "em dash (U+2014)" +EN_DASH: typing.Final = "en dash (U+2013)" +EMOJI_HEADING: typing.Final = "emoji in a heading" +READMES: typing.Final = ("README.md", "readme.md") +ROOT_FILES: typing.Final = (*READMES, "CONTEXT.md", "AGENTS.md") +DOCS_DIR: typing.Final = "docs" +UNSCANNED_DOCS_DIR: typing.Final = "agents" + +_FENCE: typing.Final = re.compile(r"(`{3,}|~{3,})(.*)$") +_INLINE_CODE: typing.Final = re.compile(r"(? list[pathlib.Path]: + # Listed rather than probed: on a case-insensitive filesystem `readme.md` "exists" beside `README.md`. + found = [root / name for name in sorted(os.listdir(root)) if name in ROOT_FILES and (root / name).is_file()] + docs = root / DOCS_DIR + for dirpath, dirnames, filenames in os.walk(docs): + dirnames[:] = sorted( + name + for name in dirnames + if not name.startswith(".") and not (pathlib.Path(dirpath) == docs and name == UNSCANNED_DOCS_DIR) + ) + found.extend(pathlib.Path(dirpath, name) for name in sorted(filenames) if name.endswith(".md")) + return found + + +def _prose_lines(source: str) -> typing.Iterator[tuple[int, str]]: + fence = "" + for number, line in enumerate(source.splitlines(), start=1): + stripped = line.strip() + if fence: + if stripped.startswith(fence) and not stripped.lstrip(fence[0]): + fence = "" + continue + opening = _FENCE.match(stripped) + if opening and not (opening[1][0] == "`" and "`" in opening[2]): + fence = opening[1] + continue + yield number, _INLINE_CODE.sub("", line) + + +def _line_violations(line: str, *, is_readme: bool) -> list[str]: + found = [] + if is_readme and _HEADING.match(line) and _EMOJI.search(line): + found.append(EMOJI_HEADING) + prose = _RANGE.sub("", _LONE_DASH_CELL.sub("", line) if "|" in line else line) + found.extend(name for dash, name in (("\u2014", EM_DASH), ("\u2013", EN_DASH)) if dash in prose) + return found + + +def prose_violations(root: pathlib.Path) -> list[tuple[str, int, str]]: + return sorted( + (file.relative_to(root).as_posix(), number, found) + for file in _scanned_files(root) + for number, line in _prose_lines(file.read_text(encoding="utf-8")) + for found in _line_violations(line, is_readme=file.name in READMES and file.parent == root) + ) + + +def test_prose_has_no_long_dashes_and_readme_headings_have_no_emoji(pytestconfig: pytest.Config) -> None: + """INVARIANT: the repo's prose Markdown has no em or en dash, and no README heading carries emoji. + + Every org repo fetches this file from `main` and runs it against its own tree (CI10), so a change + here reaches all of them on their next run. + + Broken by pasting prose that uses either dash, or by restoring the emoji link headings that RM3 + replaced with a plain `## Links` list. + + Scanned: the root `README.md` (or `readme.md`), `CONTEXT.md` and `AGENTS.md`, and every `.md` + under `docs/` except `docs/agents/`. Skipped: fenced code, inline code, a table cell that holds + only a dash (an empty default), and an en dash between two digits (a range, such as `3\u20135`). + """ + violations = prose_violations(pytestconfig.rootpath) + + assert violations == [], "\n".join(f"{file}:{number}: {found}" for file, number, found in violations) diff --git a/tests/test_prose_scanner.py b/tests/test_prose_scanner.py new file mode 100644 index 0000000..8b2f433 --- /dev/null +++ b/tests/test_prose_scanner.py @@ -0,0 +1,103 @@ +import pathlib + +from test_prose import DOCS_DIR, EM_DASH, EMOJI_HEADING, EN_DASH, prose_violations + + +def _write(root: pathlib.Path, relative: str, text: str) -> None: + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def test_dashes_in_every_prose_file_are_reported_with_their_line(tmp_path: pathlib.Path) -> None: + """The scanner is exercised against a known result, so an empty scan cannot pass as a green one.""" + _write(tmp_path, "README.md", "# pkg\n\nFast \u2014 and small.\n") + _write(tmp_path, "CONTEXT.md", "A term \u2013 its meaning.\n") + _write(tmp_path, "AGENTS.md", "ok\nRead this \u2014 first.\n") + _write(tmp_path, "docs/guide/usage.md", "Use it \u2014 well.\n") + _write(tmp_path, f"{DOCS_DIR}/adr/0001-choice.md", "We chose \u2014 this.\n") + + assert prose_violations(tmp_path) == [ + ("AGENTS.md", 2, EM_DASH), + ("CONTEXT.md", 1, EN_DASH), + ("README.md", 3, EM_DASH), + (f"{DOCS_DIR}/adr/0001-choice.md", 1, EM_DASH), + ("docs/guide/usage.md", 1, EM_DASH), + ] + + +def test_a_lowercase_readme_is_scanned(tmp_path: pathlib.Path) -> None: + _write(tmp_path, "readme.md", "## \U0001f4e6 PyPI\n") + + assert prose_violations(tmp_path) == [("readme.md", 1, EMOJI_HEADING)] + + +def test_an_emoji_in_a_readme_heading_is_reported(tmp_path: pathlib.Path) -> None: + _write(tmp_path, "README.md", "# pkg\n\n## \U0001f4e6 [PyPI](https://pypi.org/project/pkg/)\n\n## \u2b50 Stars\n") + + assert prose_violations(tmp_path) == [("README.md", 3, EMOJI_HEADING), ("README.md", 5, EMOJI_HEADING)] + + +def test_an_emoji_outside_a_heading_or_outside_the_readme_is_allowed(tmp_path: pathlib.Path) -> None: + """RM3 is about the README outline that GitHub and PyPI show, not emoji in general.""" + _write(tmp_path, "README.md", "# pkg\n\nShips fast \U0001f680\n") + _write(tmp_path, "docs/index.md", "## \U0001f680 Fast\n") + + assert prose_violations(tmp_path) == [] + + +def test_fenced_code_is_skipped(tmp_path: pathlib.Path) -> None: + _write( + tmp_path, + "README.md", + "```python\nx = 1 # a \u2014 b\n```\n\n~~~~\n```\n# \U0001f4e6 not a heading\n~~~~\nAfter \u2014 fence.\n", + ) + + assert prose_violations(tmp_path) == [("README.md", 9, EM_DASH)] + + +def test_a_fence_indented_inside_a_list_item_is_skipped(tmp_path: pathlib.Path) -> None: + _write(tmp_path, "docs/index.md", "- step\n\n ```bash\n echo a \u2014 b\n ```\n") + + assert prose_violations(tmp_path) == [] + + +def test_inline_code_is_skipped(tmp_path: pathlib.Path) -> None: + _write(tmp_path, "AGENTS.md", "Run `a \u2014 b` and ``c ` \u2013 d`` then \u2014 stop.\n") + + assert prose_violations(tmp_path) == [("AGENTS.md", 1, EM_DASH)] + + +def test_a_table_cell_holding_only_a_dash_is_allowed(tmp_path: pathlib.Path) -> None: + """A lone dash marks an empty default; a dash inside a cell's prose is still reported.""" + _write( + tmp_path, + "docs/config.md", + "| Name | Default |\n|---|---|\n| `a` | \u2014 |\n| `b` |\u2013|\n| `c` | none \u2014 unset |\n", + ) + + assert prose_violations(tmp_path) == [("docs/config.md", 5, EM_DASH)] + + +def test_an_en_dash_between_digits_is_a_range_and_allowed(tmp_path: pathlib.Path) -> None: + """A range is the en dash's own job; the em dash has no such use, and a spaced en dash is a pause.""" + _write(tmp_path, "docs/index.md", "Retries 3\u20135 times on 3.11\u20133.14.\nSee 3 \u2013 5.\nSee 3\u20145.\n") + + assert prose_violations(tmp_path) == [("docs/index.md", 2, EN_DASH), ("docs/index.md", 3, EM_DASH)] + + +def test_agent_skill_docs_generated_site_and_other_files_are_not_scanned(tmp_path: pathlib.Path) -> None: + """Only the root prose files and docs/ are scanned, and docs/agents/ is left out.""" + _write(tmp_path, "docs/agents/domain.md", "a \u2014 b\n") + _write(tmp_path, "site/index.md", "a \u2014 b\n") + _write(tmp_path, "CHANGELOG.md", "a \u2014 b\n") + _write(tmp_path, "pkg/notes.md", "a \u2014 b\n") + _write(tmp_path, "docs/notes.txt", "a \u2014 b\n") + + assert prose_violations(tmp_path) == [] + + +def test_a_tree_with_no_prose_files_reports_nothing(tmp_path: pathlib.Path) -> None: + _write(tmp_path, "pkg/mod.py", "X = '\u2014'\n") + + assert prose_violations(tmp_path) == []