Files
MeshTerm/tests/test_writing_style.py
T
Jean-Pierre MartineauandClaude Opus 5.5 2bb563605f The style and licence tests skip the files that git ignores
Both tests walk scripts/ with rglob, so they also read the MeshCore
checkout that build-firmware.sh makes in scripts/picocalc-lyra/
xiao-radio/_meshcore-build. Its third-party code and libraries have
contractions and no SPDX lines, and every developer who followed the
PicoCalc guide got three failures. conftest.not_ignored asks git
check-ignore which paths it ignores and removes them; a tracked file
is never ignored, and without git the tests examine everything, as
before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:26:27 -04:00

177 lines
6.8 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Keep the docstrings, the comments, and the documents in ASD-STE100.
MeshTerm writes its technical text in ASD-STE100 Simplified Technical English (refer to
``docs/development/writing-style.md``). Most of the rules of STE need a human reader. This
test finds the marks that a program can find: the em dash, the semicolon, the contraction,
and the Latin abbreviation. It does not examine text in code font, text in double quotes,
code blocks, or URLs, because code and quoted UX strings can contain these marks.
The test examines the docstrings and the comments of the Python files, and the prose of
the documents. It does not examine the legal text, the written pages in
``meshterm/assets/pages/``, or the code of conduct, because these texts are not in STE.
"""
from __future__ import annotations
import ast
import io
import re
import tokenize
from pathlib import Path
from tests.conftest import not_ignored
ROOT = Path(__file__).resolve().parent.parent
#: The folders whose Python files are examined.
PYTHON_DIRS = ("meshterm", "tests", "scripts", "packaging")
#: Python files without the ``.py`` suffix.
PYTHON_EXTRA = ("scripts/uconsole/meshterm-spi-bridge", "packaging/meshterm.spec")
#: The documents that are written in STE. A folder means all its Markdown files.
DOCUMENTS = (
"README.md",
"CONTRIBUTING.md",
"SECURITY.md",
"CHANGELOG.md",
"docs",
"scripts",
)
#: Each mark that STE does not use, and the name that the failure message gives it.
MARKS = (
("em dash", re.compile("—")),
("semicolon", re.compile(";")),
("contraction", re.compile(r"\b[A-Za-z]+(?:n't|'re|'ve|'ll|'d|'m)\b")),
("contraction", re.compile(r"\b(?:[Ii]t|[Tt]hat|[Tt]here|[Ww]hat|[Hh]ere|[Ww]ho)'s\b")),
("Latin abbreviation", re.compile(r"\b(?:e\.g|i\.e|etc)\.", re.IGNORECASE)),
)
#: Text that is not examined in one line of a document: code in backticks, text in double
#: quotes, and URLs.
QUOTED = re.compile(r"``.*?``|`[^`\n]*`|\"[^\"\n]*\"|“[^”\n]*”|<?https?://[^\s)>]+>?")
#: The same, for a docstring or a block of comments. Here a code span or a quotation can
#: continue on the next line. A limit on the length keeps a stray quote mark from hiding a
#: large part of the text.
QUOTED_BLOCK = re.compile(
r"``.{0,300}?``|`[^`\n]*`|\"[^\"]{0,600}\"|“[^”]{0,600}”|<?https?://[^\s)>]+>?",
re.DOTALL,
)
#: A Markdown link target: ``](target)``. The target is a path or a URL, not prose.
LINK_TARGET = re.compile(r"\]\([^)\s]*\)")
#: An HTML comment on one line.
HTML_COMMENT = re.compile(r"<!--.*?-->")
#: The quote marks at the start of a line in a quote block.
QUOTE_MARK = re.compile(r"^(?:\s*>)+ ?")
#: The markers around legal text in a document, which is not in STE on purpose.
STE_OFF = "<!-- ste: off -->"
STE_ON = "<!-- ste: on -->"
def _blank_quotes(text: str) -> str:
"""``text`` with each code span and quotation changed to spaces. The newlines stay."""
return QUOTED_BLOCK.sub(lambda match: re.sub(r"[^\n]", " ", match.group()), text)
def _python_files() -> list[Path]:
files = [p for d in PYTHON_DIRS for p in (ROOT / d).rglob("*.py")]
files += [ROOT / p for p in PYTHON_EXTRA]
return not_ignored(sorted(p for p in files if p.exists() and "__pycache__" not in p.parts))
def _python_prose(path: Path) -> list[tuple[int, str]]:
"""Each line of each docstring and each comment, with its line number and no quotes.
The lines of one docstring are examined together, and so are the lines of a block of
comments on consecutive lines. Thus a quotation that continues on the next line is
still a quotation.
"""
source = path.read_text(encoding="utf-8")
found: list[tuple[int, str]] = []
for node in ast.walk(ast.parse(source)):
if not isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
continue
first = node.body[0] if node.body else None
if (
isinstance(first, ast.Expr)
and isinstance(first.value, ast.Constant)
and isinstance(first.value.value, str)
):
for offset, line in enumerate(_blank_quotes(first.value.value).split("\n")):
found.append((first.lineno + offset, line))
blocks: list[list[tuple[int, str]]] = []
for token in tokenize.generate_tokens(io.StringIO(source).readline):
if token.type != tokenize.COMMENT:
continue
row = token.start[0]
if blocks and blocks[-1][-1][0] == row - 1:
blocks[-1].append((row, token.string))
else:
blocks.append([(row, token.string)])
for block in blocks:
text = _blank_quotes("\n".join(comment for _, comment in block))
lines = text.split("\n")
found.extend((row, line) for (row, _), line in zip(block, lines, strict=True))
return found
def _documents() -> list[Path]:
files: list[Path] = []
for name in DOCUMENTS:
path = ROOT / name
files += sorted(path.rglob("*.md")) if path.is_dir() else [path]
return not_ignored(files)
def _markdown_prose(path: Path) -> list[tuple[int, str]]:
"""Each line of prose in a Markdown file, without the code blocks and the legal text.
A fence can be in a quote block (``> ```bash``). The text between :data:`STE_OFF` and
:data:`STE_ON` is legal text, which stays as it is (refer to the style guide).
"""
found: list[tuple[int, str]] = []
fenced = False
off = False
for number, line in enumerate(path.read_text(encoding="utf-8").split("\n"), 1):
if STE_OFF in line:
off = True
if STE_ON in line:
off = False
unquoted = QUOTE_MARK.sub("", line)
if unquoted.lstrip().startswith(("```", "~~~")):
fenced = not fenced
continue
if not (fenced or off) and not unquoted.startswith(" "):
text = LINK_TARGET.sub("]", HTML_COMMENT.sub(" ", line))
found.append((number, QUOTED.sub(" ", text)))
return found
def _marks(path: Path, prose: list[tuple[int, str]]) -> list[str]:
where = path.relative_to(ROOT).as_posix()
found = []
for number, line in prose:
for name, pattern in MARKS:
if pattern.search(line):
found.append(f"{where}:{number}: {name}: {line.strip()[:100]}")
return found
def test_docstrings_and_comments_are_in_ste():
"""No docstring or comment has an em dash, a semicolon, a contraction, or "e.g."."""
found = [hit for path in _python_files() for hit in _marks(path, _python_prose(path))]
assert not found, "Text that is not in STE:\n" + "\n".join(found)
def test_documents_are_in_ste():
"""No document has an em dash, a semicolon, a contraction, or "e.g." in its prose."""
found = [hit for path in _documents() for hit in _marks(path, _markdown_prose(path))]
assert not found, "Text that is not in STE:\n" + "\n".join(found)