Skip to content
Closed
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/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
29 changes: 28 additions & 1 deletion docs/standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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.
```

Expand Down
90 changes: 90 additions & 0 deletions tests/test_prose.py
Original file line number Diff line number Diff line change
@@ -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"(?<!`)(`+)(?!`).+?(?<!`)\1(?!`)")
_LONE_DASH_CELL: typing.Final = re.compile(r"(?<=\|)[ \t]*[\u2013\u2014][ \t]*(?=\||$)")
_RANGE: typing.Final = re.compile(r"(?<=\d)\u2013(?=\d)")
_HEADING: typing.Final = re.compile(r" {0,3}#{1,6}(?:[ \t]|$)")
_EMOJI: typing.Final = re.compile(
"[\U0001f000-\U0001faff\u2600-\u27bf\u2b00-\u2bff\u231a\u231b\u2328\u23cf\u23e9-\u23f3\u23f8-\u23fa"
"\u3030\u303d\u3297\u3299\ufe0f]"
)


def _scanned_files(root: pathlib.Path) -> 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)
103 changes: 103 additions & 0 deletions tests/test_prose_scanner.py
Original file line number Diff line number Diff line change
@@ -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) == []
Loading