From 50aa666b5dc927ee71740758e316e49e4c282d21 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Wed, 12 Aug 2026 14:08:53 +0300 Subject: [PATCH 1/9] chore: scaffold the halos-docs-tools package Six console entry points are declared and guarded by a test: they become a public interface the moment a consumer repository pins them, and the translate-page skill in hatlabs/halpi2 calls them by name. No debian/ and no VERSION. This repository produces no .deb, so the workspace version-bump policy does not apply to it. Releases are a pyproject version plus a vX.Y.Z tag that consumers pin. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 24 +++++ .gitignore | 25 +++++ AGENTS.md | 59 +++++++++++ LICENSE | 21 ++++ README.md | 66 ++++++++++++ lefthook.yml | 26 +++++ pyproject.toml | 82 +++++++++++++++ run | 110 ++++++++++++++++++++ src/halos_docs_tools/__init__.py | 1 + tests/test_entry_points.py | 29 ++++++ uv.lock | 167 +++++++++++++++++++++++++++++++ 11 files changed, 610 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 LICENSE create mode 100644 lefthook.yml create mode 100644 pyproject.toml create mode 100755 run create mode 100644 src/halos_docs_tools/__init__.py create mode 100644 tests/test_entry_points.py create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..71ecc88 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python-version }} + - run: uv sync + - run: uv run ruff check src/ tests/ + - run: uv run pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d8ded0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# Distribution / packaging +build/ +dist/ +*.egg-info/ +.eggs/ + +# Virtual environments +.venv/ +venv/ + +# Test and coverage artifacts +.pytest_cache/ +.coverage +htmlcov/ + +# Tooling caches +.ruff_cache/ + +# Local workflow artifacts +_local/ +.worktrees/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..afe9a06 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# halos-docs-tools - Agent Context + +**Document Purpose**: Context for AI assistants working in this repository. + +## What this is + +Six documentation checkers, packaged so that a MkDocs documentation repository +can pin them and get the identical code in CI and on a developer's machine. The +checkers were extracted from `hatlabs/halpi2`, where they lived as `scripts/`. + +Consumers: + +- `hatlabs/halpi2` — nine locales, full translation gate +- `halos-org/docs.halos.fi` — no translations, anchor validation only + +## The six commands + +Console entry points are declared in `pyproject.toml` under `[project.scripts]`. +**They are a public interface.** A second repository pins them, and the +`translate-page` skill in `hatlabs/halpi2` calls them by name. Renaming one is a +breaking change for every consumer. + +Each module lives at `src/halos_docs_tools/.py` and exposes `main()`. + +## Conventions + +- Commands run from the root of a documentation repository. They read `docs/` + and `mkdocs.yml` relative to the working directory. Do not add hidden defaults + that make them work from elsewhere without saying so. +- Glossaries and language rules are repository content, not package content. + Where a checker needs them, the path is a CLI option with a default. +- The translation stamp format — `translated_from` in frontmatter, holding a git + blob hash — is fixed. Consumers have thousands of pages carrying it. + +## Distribution + +This repository produces no `.deb`. There is no `VERSION` file, no +`debian/changelog`, and no APT dispatch — the workspace version-bump policy +governs `.deb`-producing repositories and does not apply here. + +Releases are `pyproject.toml` version plus a `vX.Y.Z` git tag. Consumers pin the +tag. + +## Development + +``` +./run deps install dependencies +./run test run the test suite +./run lint check with ruff +./run check lint and test, as CI does +``` + +Install the pre-commit hooks after cloning with `./run install-hooks`. + +## Testing + +Tests build a miniature documentation tree in a throwaway git repository — +`tests/conftest.py` — because the staleness check reads real git blob hashes. +Reuse those fixtures rather than mocking `git`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ad551d8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hat Labs Oy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index edad285..beed809 100644 --- a/README.md +++ b/README.md @@ -1 +1,67 @@ # halos-docs-tools + +Documentation checkers for HaLOS and Hat Labs MkDocs sites: translation status, +stamping, anchor validation, glossary and typography checks. + +The same code runs in CI and on a laptop. Every check a pull request must pass is +runnable before you push. + +## Installing + +Add it to a documentation repository's `pyproject.toml`, pinned to a tag: + +```toml +dependencies = [ + "halos-docs-tools @ git+https://github.com/halos-org/docs-tools@v0.1.0", +] +``` + +`uv sync` then puts all six commands on the path. Each repository pins its own +version; upgrading is a deliberate edit to that pin. + +## Commands + +Run them from the root of a documentation repository — they expect `docs/` and +`mkdocs.yml` in the working directory. + +| Command | Purpose | +|:---|:---| +| `translation-status` | report which translations are missing or out of date | +| `stamp-translation` | record the English source a translation was written against | +| `map-anchors` | rewrite English anchor fragments to the translated slugs | +| `check-glossary` | verify a translation uses the terms its glossary prescribes | +| `check-typography` | check quotation pairing and unit spacing per language | +| `check-anchors` | verify every internal anchor in a built site resolves | + +Glossaries and per-language rules stay in the documentation repository. This +package brings the checkers, not the terminology. + +## How translation staleness is detected + +A translation records the git blob hash of the English page it was written +against, in its own frontmatter: + +```yaml +--- +translated_from: at translation time> +--- +``` + +The English page carries nothing. Editing it changes its content, which changes +its hash, which makes every translation of it report as stale on its own. + +`translation-status` classifies each page in each configured locale as `current`, +`stale`, `missing`, `unstamped` or `orphaned`. + +## Development + +``` +./run deps install dependencies +./run test run the test suite +./run lint check with ruff +./run check lint and test, as CI does +``` + +## License + +MIT. Copyright Hat Labs Oy. diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..9b40e79 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,26 @@ +pre-commit: + parallel: true + commands: + ruff-check: + glob: "*.py" + run: | + if command -v uv >/dev/null 2>&1; then + uv run ruff check src/ tests/ + elif command -v ruff >/dev/null 2>&1; then + ruff check src/ tests/ + else + echo "WARNING: ruff not found, skipping" + exit 0 + fi + fail_text: "Run 'ruff check src/ tests/' to see issues." + + pytest: + glob: "*.py" + run: | + if command -v uv >/dev/null 2>&1; then + uv run pytest -q + else + echo "WARNING: uv not found, skipping" + exit 0 + fi + fail_text: "Run './run test' to see the failures." diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9e2169d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,82 @@ +[build-system] +requires = ["setuptools>=65.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "halos-docs-tools" +version = "0.1.0" +description = "Documentation checkers for HaLOS and Hat Labs MkDocs sites" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [ + {name = "Hat Labs Oy", email = "info@hatlabs.fi"} +] +keywords = ["mkdocs", "documentation", "translation", "i18n"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Documentation", + "Topic :: Software Development :: Quality Assurance", +] + +dependencies = [ + "pyyaml>=6.0", +] + +[dependency-groups] +dev = [ + "pytest>=7.0", + "ruff>=0.1.0", +] + +[project.scripts] +translation-status = "halos_docs_tools.translation_status:main" +stamp-translation = "halos_docs_tools.stamp_translation:main" +map-anchors = "halos_docs_tools.map_anchors:main" +check-glossary = "halos_docs_tools.check_glossary:main" +check-typography = "halos_docs_tools.check_typography:main" +check-anchors = "halos_docs_tools.check_anchors:main" + +[project.urls] +Homepage = "https://github.com/halos-org/docs-tools" +Repository = "https://github.com/halos-org/docs-tools" +Issues = "https://github.com/halos-org/docs-tools/issues" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +addopts = [ + "-ra", + "--strict-markers", + "--strict-config", +] + +[tool.ruff] +line-length = 88 +target-version = "py311" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long (handled by formatter) +] diff --git a/run b/run new file mode 100755 index 0000000..aeb8991 --- /dev/null +++ b/run @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# +# usage: ./run command [argument ...] +# +# Commands for halos-docs-tools development. +# + +set -o nounset +set -o pipefail +set -o errexit + +# Change the current directory to the project root. +PROJECT_ROOT=${0%/*} +if [[ $0 != $PROJECT_ROOT && $PROJECT_ROOT != "" ]]; then + cd "$PROJECT_ROOT" +fi +readonly PROJECT_ROOT=$(pwd) + +################################################################################ +# Development Commands + +function deps { + #@ Install dependencies into the project virtualenv + #@ Category: Development + uv sync +} + +function test { + #@ Run the test suite + #@ Category: Development + uv run pytest "$@" +} + +function lint { + #@ Check the sources with ruff + #@ Category: Quality + uv run ruff check src/ tests/ +} + +function format { + #@ Reformat the sources with ruff + #@ Category: Quality + uv run ruff format src/ tests/ +} + +function check { + #@ Run lint and tests, as CI does + #@ Category: Quality + lint + test +} + +function install-hooks { + #@ Install the lefthook pre-commit hooks + #@ Category: Development + lefthook install +} + +################################################################################ +# Core + +function help { + #@ Show this help message + #@ Category: Core + echo "HaLOS Docs Tools - Development Commands" + echo "=======================================" + echo "" + echo "Available commands:" + echo "" + + # Extract function definitions and their help comments + awk '/^function / { + fname = $2 + sub(/[({].*/, "", fname) + getline + if ($0 ~ /#@/) { + desc = $0 + sub(/.*#@ /, "", desc) + sub(/ *$/, "", desc) + getline + if ($0 ~ /#@ Category:/) { + cat = $0 + sub(/.*Category: /, "", cat) + sub(/ *$/, "", cat) + if (!seen[cat]++) categories[++cat_count] = cat + commands[cat, ++cmd_count[cat]] = fname + descriptions[cat, cmd_count[cat]] = desc + } + } + } + END { + for (i = 1; i <= cat_count; i++) { + cat = categories[i] + print "\n" cat ":" + for (j = 1; j <= cmd_count[cat]; j++) { + printf " %-25s %s\n", commands[cat, j], descriptions[cat, j] + } + } + }' "$0" + + echo "" + echo "Usage: ./run [arguments...]" + echo "" +} + +################################################################################ +# Commands end. + +TIMEFORMAT=$'\nTask completed in %3lR' +time "${@:-help}" diff --git a/src/halos_docs_tools/__init__.py b/src/halos_docs_tools/__init__.py new file mode 100644 index 0000000..b4f811e --- /dev/null +++ b/src/halos_docs_tools/__init__.py @@ -0,0 +1 @@ +"""Documentation checkers for HaLOS and Hat Labs MkDocs sites.""" diff --git a/tests/test_entry_points.py b/tests/test_entry_points.py new file mode 100644 index 0000000..f60a9ca --- /dev/null +++ b/tests/test_entry_points.py @@ -0,0 +1,29 @@ +"""The six command names are a public interface. + +A consumer repository pins them in CI, and the translate-page skill in +hatlabs/halpi2 calls them by name. Renaming one breaks both, silently, at a +distance. This test is the thing that makes that a deliberate act. +""" + +from importlib.metadata import entry_points + +COMMANDS = { + "translation-status": "halos_docs_tools.translation_status:main", + "stamp-translation": "halos_docs_tools.stamp_translation:main", + "map-anchors": "halos_docs_tools.map_anchors:main", + "check-glossary": "halos_docs_tools.check_glossary:main", + "check-typography": "halos_docs_tools.check_typography:main", + "check-anchors": "halos_docs_tools.check_anchors:main", +} + + +def declared() -> dict[str, str]: + return { + e.name: e.value + for e in entry_points(group="console_scripts") + if e.module.startswith("halos_docs_tools") + } + + +def test_all_six_commands_are_declared(): + assert declared() == COMMANDS diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..584f1f3 --- /dev/null +++ b/uv.lock @@ -0,0 +1,167 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "halos-docs-tools" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [{ name = "pyyaml", specifier = ">=6.0" }] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=7.0" }, + { name = "ruff", specifier = ">=0.1.0" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/e1/4508a569211b35599016e84ba65c1a992b7a4004b4b6c4bea02a851cba1b/ruff-0.16.2.tar.gz", hash = "sha256:c3d7828d12e8927a6fc65fe38e2c2541b9e762d360a1786d752cb1b8883b3c9c", size = 4885811, upload-time = "2026-08-07T13:31:01.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/57/db19951540f98859c956b50bdb4d31089b4d91e9f15e2968e7d5193806d5/ruff-0.16.2-py3-none-linux_armv6l.whl", hash = "sha256:3c8de4cf2181f01d57946d87d777aa52916976fc09942aed89938fab5e013318", size = 10847925, upload-time = "2026-08-07T13:30:14.468Z" }, + { url = "https://files.pythonhosted.org/packages/13/5a/995fe85a8470d3e391ac0f7fa8054bb454eaf33ee138196d6172ed1079c0/ruff-0.16.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a48cc05c6fbc811ca81b5d7ba95375affea6582d1b8024e455e41afbbf55344", size = 11072662, upload-time = "2026-08-07T13:30:18.143Z" }, + { url = "https://files.pythonhosted.org/packages/32/53/370d767c61c71a971a4ace36703a7ecd8c393956349a7325d7fab2b56827/ruff-0.16.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2c0d14fcbb26c91f0f867a6dc9bd71bbc30b1b6151829c884f23faeab2e5700", size = 10566771, upload-time = "2026-08-07T13:30:20.899Z" }, + { url = "https://files.pythonhosted.org/packages/85/d6/9d96948caf5a632be62d62202d5ec914d6856f204fd79eb036e5915e79ea/ruff-0.16.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:335c621622c4650330be50842561c6586ac6971bb8ab5407fe34dcc9efb16bbe", size = 10975825, upload-time = "2026-08-07T13:30:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/ea87129b3414acb0b5770563779c51804d37ac67675c7ba35447ddb14773/ruff-0.16.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20e66910f2c37cc753f9ef6580c914a621b80c4fa3549d3e3521e29d0f5bfc3f", size = 10649437, upload-time = "2026-08-07T13:30:26.097Z" }, + { url = "https://files.pythonhosted.org/packages/ac/43/f8f291dcd4af5bb7872b74fdfa41a7cd7c856ca1d4069670971cf1b9f5cb/ruff-0.16.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7e36fbfba65510548156902bcf1350a979a958ce0347ce0f90d73894036b39f", size = 11446761, upload-time = "2026-08-07T13:30:28.752Z" }, + { url = "https://files.pythonhosted.org/packages/71/4a/ef991fb2fcf516ab71f0808adcdd8da5e18c8cde447f4ceaf5f47a5132a5/ruff-0.16.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f0eab35f80df8f134aae5d1630e751901321d317cc8e50dc39e36fa3ed34cd12", size = 12336364, upload-time = "2026-08-07T13:30:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/24/f615e74f307e6ca0e56a482872477b856c70d530aa356abfb6dfe5ca8a80/ruff-0.16.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ea8c0594feb894e89c8c61ab9c103d38b0ea72dfde6c594107147ca31b1140", size = 11630720, upload-time = "2026-08-07T13:30:34.426Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d3/8ef50149e8412a77f7ab409efdef0e2b23803707a3863da4fc64cb23d459/ruff-0.16.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab3d62dde0b19facdd632008cc4827fc28ada7736c6bd35ab6f1050f0bfed53f", size = 11466130, upload-time = "2026-08-07T13:30:36.958Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/a19334985c4dea8c381981fa252cd854c7ee52dc4b1686dc16f4a911c702/ruff-0.16.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e43e1f5b8388da9eca1b9e88328d47a5cec794633ccf6f7484ac2dd15eee92c0", size = 11523634, upload-time = "2026-08-07T13:30:39.822Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/96d192b0e742412ceda08c0a50f9669b253dde9fd6a60ea1a10c9fa79a63/ruff-0.16.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c24788a980581e1d7ea3a0cbe4344c4fbeb0a6a9b1f4713aa46bb104f8294690", size = 10949807, upload-time = "2026-08-07T13:30:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/fa/51/e26599ceca11e79ee255c7df515995561edf87e9ca1893284e44d98f5a86/ruff-0.16.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:81806b08329130005dd4a8a8394a0c9da8c6f4cafb16ba438d2a2ee6a18bedf1", size = 10646891, upload-time = "2026-08-07T13:30:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/68/01/800c4b1f97bc8d7c6029e06b1f20473a3cf1e13c4933d8f3342add83fc55/ruff-0.16.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce4e02bad779bef557f541a1b31f20d6abeae1cc05ed1b1ac019d4ffd1044c8", size = 11162063, upload-time = "2026-08-07T13:30:48.131Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d0/1477ea50fc5a0d4b0b71d1d63d50770bdd794d90b43e37a7618e63ec9894/ruff-0.16.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e0422abdf70070255fc4073ce9dfc814cc03db577013761ddd09bc1e4a9a4fbd", size = 11556038, upload-time = "2026-08-07T13:30:50.686Z" }, + { url = "https://files.pythonhosted.org/packages/b8/76/a7776f32048d991e16d4fa8ff91790b877342d3596cc3ed04acdbf1aaedc/ruff-0.16.2-py3-none-win32.whl", hash = "sha256:bf3a63d78fb39f4bf5ac8ae52051c5520505301abe19ba4e204c453b3f09bb0b", size = 10872850, upload-time = "2026-08-07T13:30:53.471Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/929c800d920e61397d82a01b60bffc68da3052c17d31de59efaad2e4ed75/ruff-0.16.2-py3-none-win_amd64.whl", hash = "sha256:bcabe2f6d0fc7819f1431793005af4e4de7371927d037345bf941252b195b9fa", size = 12023338, upload-time = "2026-08-07T13:30:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/5b/6c/93e26c22c5f78ff87363e07da49c84955affbeb1098bd1936bf3b3f293bf/ruff-0.16.2-py3-none-win_arm64.whl", hash = "sha256:d614e95cedf38a2053fd351c55b103ba30d017d61688fdbfd40ee0412852a99f", size = 11374065, upload-time = "2026-08-07T13:30:58.775Z" }, +] From 3d222a3ec13f7bb535fb5db4de8603f10e73a857 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Wed, 12 Aug 2026 14:13:43 +0300 Subject: [PATCH 2/9] feat: port the translation status and stamping commands Behaviour, flags, defaults and output are unchanged from the scripts in hatlabs/halpi2. Verified byte-identical against that repository in three modes -- text, markdown --diff, and --only-pages -- both on a clean tree and on one with nine stale translations and rendered diffs. main() takes an optional argv so the tests can drive it; the command line is unaffected. Tests build a real git repository rather than mocking git: the whole mechanism is that the hash of the English page and the hash recorded in the translation either match or do not. Co-Authored-By: Claude Opus 5 (1M context) --- src/halos_docs_tools/stamp_translation.py | 84 +++++++ src/halos_docs_tools/translation_status.py | 246 +++++++++++++++++++++ tests/conftest.py | 127 +++++++++++ tests/test_stamp_translation.py | 83 +++++++ tests/test_translation_status.py | 102 +++++++++ 5 files changed, 642 insertions(+) create mode 100644 src/halos_docs_tools/stamp_translation.py create mode 100644 src/halos_docs_tools/translation_status.py create mode 100644 tests/conftest.py create mode 100644 tests/test_stamp_translation.py create mode 100644 tests/test_translation_status.py diff --git a/src/halos_docs_tools/stamp_translation.py b/src/halos_docs_tools/stamp_translation.py new file mode 100644 index 0000000..b32c0ce --- /dev/null +++ b/src/halos_docs_tools/stamp_translation.py @@ -0,0 +1,84 @@ +"""Write the translated_from stamp into a translation's frontmatter. + +Stamp a translation only when it has actually been (re-)translated against the +current English source. A stamp updated without real translation work reports +green and makes the staleness invisible — that is the one gap the status check +cannot close. + + stamp-translation docs/fi/user-guide/hardware.md +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +from .translation_status import configured_languages + +DOCS = Path("docs") +STAMP_KEY = "translated_from" + + +def english_source(translation: Path, default: str) -> Path: + """docs// -> docs//.""" + parts = translation.parts + if len(parts) < 3 or parts[0] != DOCS.name: + raise SystemExit(f"{translation}: not a path under docs//") + if parts[1] == default: + raise SystemExit( + f"{translation}: this is a source page, not a translation. " + f"Source pages carry no stamp — that is the point: an English edit " + f"needs no ceremony." + ) + return DOCS / default / Path(*parts[2:]) + + +def blob_hash(path: Path) -> str: + return subprocess.run( + ["git", "hash-object", str(path)], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + +def restamp(text: str, value: str) -> str: + """Set the stamp, replacing an existing one and preserving other keys.""" + line = f"{STAMP_KEY}: {value}" + if not text.startswith("---\n"): + return f"---\n{line}\n---\n\n{text}" + end = text.find("\n---", 4) + if end == -1: + raise SystemExit("frontmatter is not terminated") + front, body = text[4:end], text[end + 4 :].lstrip("\n") + kept = [ + line_ for line_ in front.splitlines() if not line_.startswith(f"{STAMP_KEY}:") + ] + return "---\n" + "\n".join([*kept, line]) + "\n---\n\n" + body + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("translations", nargs="+", type=Path) + args = parser.parse_args(argv) + + default, _ = configured_languages() + for translation in args.translations: + if not translation.exists(): + raise SystemExit(f"{translation}: does not exist") + source = english_source(translation, default) + if not source.exists(): + raise SystemExit(f"{translation}: no English source at {source}") + value = blob_hash(source) + translation.write_text( + restamp(translation.read_text(encoding="utf-8"), value), + encoding="utf-8", + ) + print(f"{translation}: {STAMP_KEY} = {value}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/halos_docs_tools/translation_status.py b/src/halos_docs_tools/translation_status.py new file mode 100644 index 0000000..48d9b64 --- /dev/null +++ b/src/halos_docs_tools/translation_status.py @@ -0,0 +1,246 @@ +"""Report which translations are missing or out of date. + +A translation records the git blob hash of the English source it was written +against, in its own frontmatter: + + --- + translated_from: at translation time> + --- + +The English page carries nothing, so an English edit needs no ceremony: editing +it changes its content, which changes its hash, which makes every translation of +it report as stale on its own. + +Reports; never blocks. Exit status is 0 unless the check itself could not run. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +import yaml + +DOCS = Path("docs") +STAMP_KEY = "translated_from" + + +class _Loader(yaml.SafeLoader): + """mkdocs.yml carries python/name tags that SafeLoader refuses to parse.""" + + +_Loader.add_multi_constructor("", lambda loader, suffix, node: None) + + +def configured_languages() -> tuple[str, list[str]]: + """Return (default language, other languages) from the i18n plugin config.""" + config = yaml.load(Path("mkdocs.yml").read_text(encoding="utf-8"), Loader=_Loader) + for plugin in config.get("plugins", []): + if isinstance(plugin, dict) and "i18n" in plugin: + languages = plugin["i18n"]["languages"] + default = next(lang["locale"] for lang in languages if lang.get("default")) + others = [ + lang["locale"] for lang in languages if not lang.get("default") + ] + return default, others + raise SystemExit("mkdocs.yml has no i18n plugin configuration") + + +def blob_hash(path: Path) -> str: + return subprocess.run( + ["git", "hash-object", str(path)], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + +def stamp_of(path: Path) -> str | None: + """Read translated_from from a page's frontmatter, if it has one.""" + text = path.read_text(encoding="utf-8") + if not text.startswith("---\n"): + return None + end = text.find("\n---", 4) + if end == -1: + return None + front = yaml.safe_load(text[4:end]) or {} + value = front.get(STAMP_KEY) + return str(value) if value else None + + +def english_diff(stamped: str, current: Path) -> str | None: + """Diff the stamped English blob against the English page as it stands now. + + The current page is compared from the working tree rather than as a stored + object: `git hash-object` computes a hash without writing the object, so + diffing two hashes would fail on the side that was never stored. + """ + blob = subprocess.run( + ["git", "cat-file", "-p", stamped], + capture_output=True, + text=True, + ) + if blob.returncode != 0: + return None # stamped blob not in this clone — CI needs fetch-depth: 0 + with tempfile.TemporaryDirectory() as tmp: + was = Path(tmp) / current.name + was.write_text(blob.stdout, encoding="utf-8") + result = subprocess.run( + ["git", "diff", "--no-index", "--no-color", str(was), str(current)], + capture_output=True, + text=True, + ) + # --no-index exits 1 when the files differ, which is the expected case. + # Drop the file headers: they carry a temporary path, and the page is + # already named in the surrounding report. + noise = ("diff --git ", "index ", "--- ", "+++ ") + return "\n".join( + line for line in result.stdout.splitlines() if not line.startswith(noise) + ) + + +@dataclass +class Entry: + language: str + page: str # path relative to the language directory + state: str # missing | unstamped | stale | orphaned | current + expected: str # blob hash the translation should record + diff: str | None = None + + +def collect(default: str, languages: list[str], want_diff: bool) -> list[Entry]: + sources = sorted(p for p in (DOCS / default).rglob("*.md")) + entries: list[Entry] = [] + for source in sources: + relative = source.relative_to(DOCS / default) + expected = blob_hash(source) + for language in languages: + target = DOCS / language / relative + if not target.exists(): + entries.append(Entry(language, str(relative), "missing", expected)) + continue + stamped = stamp_of(target) + if stamped is None: + entries.append(Entry(language, str(relative), "unstamped", expected)) + elif stamped == expected: + entries.append(Entry(language, str(relative), "current", expected)) + else: + diff = english_diff(stamped, source) if want_diff else None + entries.append( + Entry(language, str(relative), "stale", expected, diff) + ) + + # A translation whose source was deleted is invisible to the loop above, + # because that walks the sources. It is still a page being served. + for language in languages: + root = DOCS / language + for translation in sorted(root.rglob("*.md")): + if not (DOCS / default / translation.relative_to(root)).exists(): + entries.append( + Entry(language, str(translation.relative_to(root)), "orphaned", "") + ) + return entries + + +def render_text(entries: list[Entry]) -> str: + out = [] + for language in sorted({e.language for e in entries}): + rows = [e for e in entries if e.language == language] + counts = { + s: sum(1 for e in rows if e.state == s) + for s in ("current", "stale", "unstamped", "missing", "orphaned") + } + out.append(f"{language}: " + " ".join(f"{k}={v}" for k, v in counts.items())) + for entry in rows: + if entry.state != "current": + out.append(f" {entry.state:9s} {entry.page}") + if entry.expected: + out.append(f" {STAMP_KEY}: {entry.expected}") + return "\n".join(out) + + +def render_markdown(entries: list[Entry], only: set[str] | None) -> str: + shown = [e for e in entries if only is None or e.page in only] + out = ["## Translation status", ""] + for language in sorted({e.language for e in entries}): + rows = [e for e in entries if e.language == language] + counts = { + s: sum(1 for e in rows if e.state == s) + for s in ("current", "stale", "unstamped", "missing", "orphaned") + } + summary = ", ".join(f"{v} {k}" for k, v in counts.items() if v) + out.append(f"**{language}** — {summary}") + out.append("") + + behind = [e for e in shown if e.state != "current"] + if not behind: + out.append("Every translation of the pages in scope is current.") + return "\n".join(out) + + out += [ + "| Language | Page | State | Stamp to record |", + "|:---|:---|:---|:---|", + ] + for entry in behind: + out.append( + f"| {entry.language} | `{entry.page}` | {entry.state} | `{entry.expected}` |" + ) + out.append("") + + for entry in behind: + if entry.diff: + out += [ + f"
English changes since " + f"{entry.language}/{entry.page} was translated", + "", + "```diff", + entry.diff.rstrip(), + "```", + "", + "
", + "", + ] + elif entry.state == "stale": + out.append( + f"" + ) + return "\n".join(out) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--format", choices=("text", "markdown"), default="text") + parser.add_argument( + "--diff", + action="store_true", + help="include the English diff for stale pages", + ) + parser.add_argument( + "--only-pages", + nargs="*", + metavar="PATH", + help="restrict the detail section to these docs//-relative paths", + ) + args = parser.parse_args(argv) + + default, languages = configured_languages() + if not languages: + print("No translation languages configured.") + return 0 + + entries = collect(default, languages, want_diff=args.diff) + if args.format == "markdown": + only = set(args.only_pages) if args.only_pages else None + print(render_markdown(entries, only)) + else: + print(render_text(entries)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..43aa5ca --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,127 @@ +"""A miniature documentation repository the checkers can be run against. + +The staleness check reads real git blob hashes, so the fixture builds a real +git repository rather than mocking `git`. Mocking it would test the mock: the +whole mechanism is that `git hash-object` of the English page and the hash +recorded in the translation either match or do not. +""" + +from __future__ import annotations + +import subprocess +import textwrap +from dataclasses import dataclass +from pathlib import Path + +import pytest + +MKDOCS = """\ +site_name: Test docs +theme: + name: material +markdown_extensions: + - pymdownx.emoji: + emoji_generator: !!python/name:material.extensions.emoji.to_svg +plugins: + - search + - i18n: + docs_structure: folder + languages: + - locale: en + name: English + default: true + build: true + - locale: fi + name: Suomi + build: true + - locale: sv + name: Svenska + build: true +""" + +MKDOCS_NO_I18N = """\ +site_name: Test docs +theme: + name: material +plugins: + - search +""" + +MKDOCS_ONLY_DEFAULT = """\ +site_name: Test docs +plugins: + - i18n: + docs_structure: folder + languages: + - locale: en + name: English + default: true + build: true +""" + + +@dataclass +class DocsRepo: + """A documentation repository under a temporary directory.""" + + root: Path + + def git(self, *args: str) -> str: + return subprocess.run( + ["git", *args], + cwd=self.root, + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + def write(self, relative: str, text: str) -> Path: + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(textwrap.dedent(text), encoding="utf-8") + return path + + def source(self, page: str, text: str = "# Title\n\nEnglish body.\n") -> Path: + return self.write(f"docs/en/{page}", text) + + def blob(self, page: str) -> str: + """The hash a translation of this English page should record.""" + return self.git("hash-object", f"docs/en/{page}") + + def translation( + self, + language: str, + page: str, + text: str = "# Otsikko\n\nKäännetty teksti.\n", + stamp: str | None = "current", + ) -> Path: + """Write a translation. stamp: "current", None, or a literal hash.""" + if stamp == "current": + stamp = self.blob(page) + front = f"---\ntranslated_from: {stamp}\n---\n\n" if stamp else "" + return self.write(f"docs/{language}/{page}", front + text) + + def commit(self, message: str = "docs: update") -> str: + self.git("add", "-A") + self.git("commit", "-m", message) + return self.git("rev-parse", "HEAD") + + +@pytest.fixture +def docs_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> DocsRepo: + """An initialised repo with one English page, current in both locales. + + The checkers read `docs/` and `mkdocs.yml` relative to the working + directory, so the fixture chdirs into the repository. + """ + repo = DocsRepo(tmp_path) + repo.git("init", "-q", "-b", "main") + repo.git("config", "user.email", "test@example.invalid") + repo.git("config", "user.name", "Test") + + repo.write("mkdocs.yml", MKDOCS) + repo.source("index.md") + monkeypatch.chdir(tmp_path) + repo.translation("fi", "index.md") + repo.translation("sv", "index.md") + return repo diff --git a/tests/test_stamp_translation.py b/tests/test_stamp_translation.py new file mode 100644 index 0000000..b721aa3 --- /dev/null +++ b/tests/test_stamp_translation.py @@ -0,0 +1,83 @@ +"""Behaviour of the stamping command.""" + +from __future__ import annotations + +import pytest +from conftest import DocsRepo + +from halos_docs_tools import stamp_translation, translation_status + + +def run(*argv: str) -> int: + return stamp_translation.main(list(argv)) + + +def state(repo: DocsRepo, language: str, page: str) -> str: + default, languages = translation_status.configured_languages() + entries = translation_status.collect(default, languages, want_diff=False) + return next( + e.state for e in entries if e.language == language and e.page == page + ) + + +def test_stamping_a_stale_translation_makes_it_current(docs_repo: DocsRepo): + docs_repo.source("index.md", "# Title\n\nRevised.\n") + assert state(docs_repo, "fi", "index.md") == "stale" + assert run("docs/fi/index.md") == 0 + assert state(docs_repo, "fi", "index.md") == "current" + + +def test_stamping_an_unstamped_translation_adds_frontmatter(docs_repo: DocsRepo): + docs_repo.translation("fi", "index.md", stamp=None) + assert run("docs/fi/index.md") == 0 + text = (docs_repo.root / "docs/fi/index.md").read_text() + assert text.startswith("---\ntranslated_from: ") + assert state(docs_repo, "fi", "index.md") == "current" + + +def test_stamping_preserves_other_frontmatter_keys(docs_repo: DocsRepo): + docs_repo.write( + "docs/fi/index.md", + """\ + --- + title: Otsikko + translated_from: 0000000000000000000000000000000000000000 + --- + + Body. + """, + ) + assert run("docs/fi/index.md") == 0 + text = (docs_repo.root / "docs/fi/index.md").read_text() + assert "title: Otsikko" in text + assert "0000000" not in text + assert text.count("translated_from:") == 1 + + +def test_stamping_several_pages_at_once(docs_repo: DocsRepo): + docs_repo.source("index.md", "# Title\n\nRevised.\n") + assert run("docs/fi/index.md", "docs/sv/index.md") == 0 + assert state(docs_repo, "fi", "index.md") == "current" + assert state(docs_repo, "sv", "index.md") == "current" + + +def test_path_outside_a_language_directory_is_rejected(docs_repo: DocsRepo): + docs_repo.write("notes.md", "Body.\n") + with pytest.raises(SystemExit, match="not a path under"): + run("notes.md") + + +def test_stamping_a_source_page_is_rejected(docs_repo: DocsRepo): + with pytest.raises(SystemExit, match="source page"): + run("docs/en/index.md") + + +def test_translation_without_an_english_source_is_rejected(docs_repo: DocsRepo): + docs_repo.translation("fi", "orphan.md", stamp=None) + with pytest.raises(SystemExit, match="no English source"): + run("docs/fi/orphan.md") + + +def test_nonexistent_path_is_rejected(docs_repo: DocsRepo): + with pytest.raises(SystemExit, match="does not exist"): + run("docs/fi/nope.md") diff --git a/tests/test_translation_status.py b/tests/test_translation_status.py new file mode 100644 index 0000000..30aa353 --- /dev/null +++ b/tests/test_translation_status.py @@ -0,0 +1,102 @@ +"""Behaviour of the translation status report.""" + +from __future__ import annotations + +import pytest +from conftest import MKDOCS_NO_I18N, MKDOCS_ONLY_DEFAULT, DocsRepo + +from halos_docs_tools import translation_status + + +def run(*argv: str) -> int: + return translation_status.main(list(argv)) + + +def states(repo: DocsRepo) -> dict[tuple[str, str], str]: + default, languages = translation_status.configured_languages() + entries = translation_status.collect(default, languages, want_diff=False) + return {(e.language, e.page): e.state for e in entries} + + +def test_matching_stamp_reports_current(docs_repo: DocsRepo): + assert states(docs_repo)[("fi", "index.md")] == "current" + + +def test_absent_translation_reports_missing(docs_repo: DocsRepo): + docs_repo.source("guide.md") + assert states(docs_repo)[("fi", "guide.md")] == "missing" + + +def test_translation_without_frontmatter_reports_unstamped(docs_repo: DocsRepo): + docs_repo.translation("fi", "index.md", stamp=None) + assert states(docs_repo)[("fi", "index.md")] == "unstamped" + + +def test_stamp_behind_the_source_reports_stale(docs_repo: DocsRepo): + docs_repo.source("index.md", "# Title\n\nEnglish body, revised.\n") + assert states(docs_repo)[("fi", "index.md")] == "stale" + + +def test_translation_whose_source_was_deleted_reports_orphaned(docs_repo: DocsRepo): + (docs_repo.root / "docs/en/index.md").unlink() + assert states(docs_repo)[("fi", "index.md")] == "orphaned" + + +def test_one_locale_stale_does_not_affect_the_others(docs_repo: DocsRepo): + docs_repo.source("index.md", "# Title\n\nRevised.\n") + docs_repo.translation("sv", "index.md") + assert states(docs_repo)[("fi", "index.md")] == "stale" + assert states(docs_repo)[("sv", "index.md")] == "current" + + +def test_no_translation_locales_configured_says_so(docs_repo: DocsRepo, capsys): + docs_repo.write("mkdocs.yml", MKDOCS_ONLY_DEFAULT) + assert run() == 0 + assert "No translation languages configured." in capsys.readouterr().out + + +def test_missing_i18n_plugin_is_an_error_not_an_empty_success(docs_repo: DocsRepo): + docs_repo.write("mkdocs.yml", MKDOCS_NO_I18N) + with pytest.raises(SystemExit, match="no i18n plugin"): + run() + + +def test_unreachable_stamped_blob_omits_the_diff_and_hints(docs_repo: DocsRepo, capsys): + # A well-formed hash of an object no clone has. An all-digit stamp would + # not do: YAML reads it as a number, and `translated_from: 000...0` then + # reports unstamped rather than stale. + absent = "deadbeef" * 5 + docs_repo.translation("fi", "index.md", stamp=absent) + docs_repo.translation("sv", "index.md", stamp=absent) + assert run("--format", "markdown", "--diff") == 0 + out = capsys.readouterr().out + assert "fetch-depth: 0" in out + assert "```diff" not in out + + +def test_diff_shows_the_english_change_since_the_translation(docs_repo: DocsRepo, capsys): + docs_repo.commit("docs: seed") + docs_repo.source("index.md", "# Title\n\nEnglish body, revised.\n") + assert run("--format", "markdown", "--diff") == 0 + out = capsys.readouterr().out + assert "+English body, revised." in out + + +def test_only_pages_narrows_the_detail_but_not_the_summary(docs_repo: DocsRepo, capsys): + docs_repo.source("guide.md") + docs_repo.source("index.md", "# Title\n\nRevised.\n") + assert run("--format", "markdown", "--only-pages", "guide.md") == 0 + out = capsys.readouterr().out + assert "`guide.md`" in out + assert "| `index.md` |" not in out + # The per-locale summary still counts every page, filtered or not. + assert "**fi** — 1 stale, 1 missing" in out + assert "**sv** — 1 stale, 1 missing" in out + + +def test_text_format_lists_every_page_that_is_not_current(docs_repo: DocsRepo, capsys): + docs_repo.source("guide.md") + assert run() == 0 + out = capsys.readouterr().out + assert "missing guide.md" in out + assert "current=1" in out From 9f9e03348dca4780b3b9b4f041146527f3b7ca07 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Wed, 12 Aug 2026 14:17:05 +0300 Subject: [PATCH 3/9] feat: port the anchor tools, with generated-page exclusion check-anchors gains --exclude. Measured on a docs.halos.fi build: 690 of 3120 anchor links are broken and every one of them is on the mkdocs-print-site-plugin export; the 36 content pages are clean. With 'print_page/*' excluded the same site checks 1905 links and passes. An excluded page still keeps its ids, so other pages may link into it. Two hard-coded halpi2 assumptions could not survive the extraction: --base defaulted to /halpi2/. It is now read from site_url in mkdocs.yml. A base that does not match the site silently skips every root-absolute link, which reports a pass rather than a failure -- so a wrong constant is worse here than a missing one. map-anchors assumed the default locale was 'en'. It now asks the i18n configuration. Both checkers verified against real builds: identical output to the scripts on halpi2 (10610 links, 202 pages) and on docs.halos.fi. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 19 +++ src/halos_docs_tools/check_anchors.py | 159 ++++++++++++++++++++++++++ src/halos_docs_tools/map_anchors.py | 129 +++++++++++++++++++++ tests/test_check_anchors.py | 127 ++++++++++++++++++++ tests/test_map_anchors.py | 113 ++++++++++++++++++ 5 files changed, 547 insertions(+) create mode 100644 src/halos_docs_tools/check_anchors.py create mode 100644 src/halos_docs_tools/map_anchors.py create mode 100644 tests/test_check_anchors.py create mode 100644 tests/test_map_anchors.py diff --git a/README.md b/README.md index beed809..ab3a803 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,25 @@ Run them from the root of a documentation repository — they expect `docs/` and Glossaries and per-language rules stay in the documentation repository. This package brings the checkers, not the terminology. +### Generated pages and `check-anchors` + +Some plugins generate a page whose internal fragments the checker cannot +resolve. `mkdocs-print-site-plugin` is one: on `docs.halos.fi` its single-page +export accounts for 690 broken fragments while the 36 content pages are clean. +Exclude such pages by path pattern: + +``` +check-anchors site --exclude 'print_page/*' +``` + +An excluded page contributes no links to the check. Its own headings stay +linkable, so other pages may still point into it. + +`--base`, used to resolve root-absolute links, is read from `site_url` in +`mkdocs.yml`. A base that does not match the site makes the checker skip every +root-absolute link and report a pass it did not earn, so override it only when +you know the built site differs from the configuration. + ## How translation staleness is detected A translation records the git blob hash of the English page it was written diff --git a/src/halos_docs_tools/check_anchors.py b/src/halos_docs_tools/check_anchors.py new file mode 100644 index 0000000..0e73270 --- /dev/null +++ b/src/halos_docs_tools/check_anchors.py @@ -0,0 +1,159 @@ +"""Verify that every internal anchor in the built site resolves to a real id. + +Anchors are generated from heading text, so translating a heading changes its +slug and silently breaks every link pointing at it — including links on pages +that were not touched, which is why this is a delayed fault: a cross-page anchor +keeps working until its *target* page is translated. `mkdocs build --strict` +does not validate anchors at all. + +Run against a built site directory. Exit status is 1 if any anchor is broken. +""" + +from __future__ import annotations + +import argparse +import fnmatch +import os +import re +import sys +from pathlib import Path +from urllib.parse import unquote, urldefrag, urlsplit + +import yaml + +HREF = re.compile(r'href="([^"]+)"') +ID = re.compile(r'\sid="([^"]+)"') + + +class _Loader(yaml.SafeLoader): + """mkdocs.yml carries python/name tags that SafeLoader refuses to parse.""" + + +_Loader.add_multi_constructor("", lambda loader, suffix, node: None) + + +def configured_base() -> str: + """The path component of site_url, for resolving root-absolute links. + + A base that does not match the site makes every root-absolute link look + like somebody else's, so the checker skips them and reports a pass it did + not earn. Reading it from the same file the build reads keeps the two from + disagreeing. + """ + config_path = Path("mkdocs.yml") + if not config_path.exists(): + return "/" + config = yaml.load(config_path.read_text(encoding="utf-8"), Loader=_Loader) or {} + site_url = config.get("site_url") + if not site_url: + return "/" + path = urlsplit(str(site_url)).path or "/" + return path if path.endswith("/") else path + "/" + + +def collect_pages(site: str, exclude: list[str]) -> tuple[dict[str, set[str]], set[str]]: + """Map each built page to the ids it defines, and note which are excluded. + + An excluded page contributes no links to the check but keeps its ids: it is + still a page other pages may legitimately link into. + """ + ids: dict[str, set[str]] = {} + excluded: set[str] = set() + for root, _, files in os.walk(site): + for name in files: + if not name.endswith(".html"): + continue + path = os.path.join(root, name) + real = os.path.realpath(path) + with open(path, encoding="utf-8") as handle: + ids[real] = set(ID.findall(handle.read())) + relative = os.path.relpath(path, site) + if any(fnmatch.fnmatch(relative, pattern) for pattern in exclude): + excluded.add(real) + return ids, excluded + + +def resolve(href: str, page: str, site: str, base: str) -> str | None: + """Resolve an href to the built file it points at, or None if not ours.""" + target, _ = urldefrag(href) + target = unquote(target) + if not target: + return os.path.realpath(page) + if target.startswith("/"): + if not target.startswith(base): + return None + path = os.path.normpath(os.path.join(site, target[len(base) :])) + else: + path = os.path.normpath(os.path.join(os.path.dirname(page), target)) + if not path.endswith(".html"): + path = os.path.join(path, "index.html") + return os.path.realpath(path) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("site", nargs="?", default="site") + parser.add_argument( + "--base", + help="path component of site_url, for root-absolute links " + "(default: read from mkdocs.yml)", + ) + parser.add_argument( + "--exclude", + nargs="*", + default=[], + metavar="PATTERN", + help="skip links on built pages matching these glob patterns, relative " + "to the site directory. Their own ids stay linkable. Generated " + "single-page exports need this", + ) + args = parser.parse_args(argv) + base = args.base if args.base is not None else configured_base() + + ids, excluded = collect_pages(args.site, args.exclude) + if not ids: + # Passing on an empty site would be a false green: the build produced + # nothing, or the path is wrong, and neither is "all anchors resolve". + print( + f"No built pages found under {args.site!r} — nothing to check.", + file=sys.stderr, + ) + return 2 + + broken: list[tuple[str, str, str]] = [] + checked = 0 + + for page in sorted(ids): + if page in excluded: + continue + with open(page, encoding="utf-8") as handle: + hrefs = HREF.findall(handle.read()) + for href in hrefs: + if href.startswith(("http://", "https://", "mailto:", "data:")): + continue + _, fragment = urldefrag(href) + if not fragment: + continue + target = resolve(href, page, args.site, base) + if target is None: + continue + checked += 1 + relative = os.path.relpath(page, args.site) + if target not in ids: + broken.append((relative, href, "target page does not exist")) + elif unquote(fragment) not in ids[target]: + broken.append((relative, href, "no such anchor on the target page")) + + skipped = f", {len(excluded)} excluded" if excluded else "" + print(f"Checked {checked} anchor links across {len(ids) - len(excluded)} pages{skipped}.") + if broken: + print(f"\n{len(broken)} broken:\n") + for page, href, why in broken: + print(f" {page}\n -> {href} ({why})") + return 1 + print("All anchors resolve.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/halos_docs_tools/map_anchors.py b/src/halos_docs_tools/map_anchors.py new file mode 100644 index 0000000..c809c06 --- /dev/null +++ b/src/halos_docs_tools/map_anchors.py @@ -0,0 +1,129 @@ +"""Rewrite English anchor fragments in a translation to the translated slugs. + +Anchor slugs come from heading text, so a translated heading gets a different +slug and every link pointing at it breaks — including links on pages nobody +touched. Translators leave the English fragment in place; this maps it across. + +The mapping is positional: the structure comparison already proves the +translation has the same headings in the same order, so the nth heading of the +source page and the nth heading of the translation are the same heading. That is +stronger than matching on text, which cannot work once the text is in another +language. + + map-anchors site fi --apply + +Without --apply it only reports what it would change. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +from .translation_status import configured_languages + +HEADING_ID = re.compile(r" list[str]: + """Heading ids of a built page, in document order. + + The default language has no URL segment of its own — its `index.md` is + served at the site root, not under a locale directory — so source pages are + looked up without a prefix. + """ + stem = page[: -len(".md")] + stem = "" if stem == "index" else stem.removesuffix("/index") + prefix = "" if language == default else language + parts = [p for p in (prefix, stem) if p] + html = site.joinpath(*parts, "index.html") + if not html.exists(): + raise SystemExit( + f"No built page for {language}/{page} at {html} — build the site first." + ) + return HEADING_ID.findall(html.read_text(encoding="utf-8")) + + +def target_page(link: str, page: str) -> str | None: + """The markdown page a link points at, relative to the docs root.""" + path, _, _ = link.partition("#") + if link.startswith(("http://", "https://", "mailto:")): + return None + if not path: + return page + resolved = (Path(page).parent / path).as_posix() + resolved = Path(resolved).resolve().relative_to(Path.cwd().resolve()).as_posix() + return resolved if resolved.endswith(".md") else None + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("site", type=Path, help="the built site directory") + parser.add_argument("language", help="the locale directory to rewrite") + parser.add_argument( + "--apply", + action="store_true", + help="write the changes; without it, only report them", + ) + args = parser.parse_args(argv) + + default, _ = configured_languages() + docs = Path("docs") + + sources = { + p.relative_to(docs / default).as_posix(): built_ids( + args.site, default, p.relative_to(docs / default).as_posix(), default + ) + for p in (docs / default).rglob("*.md") + } + translated = { + page: built_ids(args.site, args.language, page, default) for page in sources + } + + changes, unmapped = [], [] + for page in sorted(sources): + source = docs / args.language / page + if not source.exists(): + continue + text = original = source.read_text(encoding="utf-8") + for link in set(LINK.findall(text)): + path, _, fragment = link.partition("#") + target = target_page(link, page) + if target is None or target not in sources: + continue + ids_source, ids_translated = sources[target], translated[target] + if fragment not in ids_source: + continue + if len(ids_source) != len(ids_translated): + unmapped.append( + f"{args.language}/{page} -> {link}: {target} has " + f"{len(ids_source)} headings in {default}, " + f"{len(ids_translated)} translated" + ) + continue + replacement = ids_translated[ids_source.index(fragment)] + if replacement != fragment: + text = text.replace(f"]({link})", f"]({path}#{replacement})") + changes.append( + f" {args.language}/{page}\n {fragment} -> {replacement}" + ) + if text != original and args.apply: + source.write_text(text, encoding="utf-8") + + verb = "rewritten" if args.apply else "to rewrite" + print(f"{len(changes)} anchors {verb} in docs/{args.language}.") + for change in changes: + print(change) + if unmapped: + print(f"\n{len(unmapped)} could not be mapped — structure differs:") + for problem in unmapped: + print(f" {problem}") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_check_anchors.py b/tests/test_check_anchors.py new file mode 100644 index 0000000..28afc6a --- /dev/null +++ b/tests/test_check_anchors.py @@ -0,0 +1,127 @@ +"""Behaviour of the built-site anchor checker.""" + +from __future__ import annotations + +from pathlib import Path + +from conftest import DocsRepo + +from halos_docs_tools import check_anchors + + +def page(site: Path, relative: str, body: str) -> Path: + path = site / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"{body}", encoding="utf-8") + return path + + +def run(*argv: str) -> int: + return check_anchors.main(list(argv)) + + +def test_resolving_anchors_pass(tmp_path: Path, capsys): + site = tmp_path / "site" + page(site, "index.html", '

Intro

go') + page(site, "guide/index.html", '

Setup

') + assert run(str(site)) == 0 + assert "All anchors resolve." in capsys.readouterr().out + + +def test_missing_anchor_on_the_target_page_fails(tmp_path: Path, capsys): + site = tmp_path / "site" + page(site, "index.html", 'go') + page(site, "guide/index.html", '

Setup

') + assert run(str(site)) == 1 + out = capsys.readouterr().out + assert "index.html" in out + assert "#nope" in out + assert "no such anchor" in out + + +def test_link_to_a_page_that_does_not_exist_fails(tmp_path: Path, capsys): + site = tmp_path / "site" + page(site, "index.html", 'go') + assert run(str(site)) == 1 + assert "target page does not exist" in capsys.readouterr().out + + +def test_links_without_fragments_and_external_links_are_ignored(tmp_path: Path, capsys): + site = tmp_path / "site" + page( + site, + "index.html", + 'go' + 'out' + 'mail', + ) + page(site, "guide/index.html", "

Guide

") + assert run(str(site)) == 0 + assert "Checked 0 anchor links" in capsys.readouterr().out + + +def test_empty_site_directory_is_not_a_pass(tmp_path: Path, capsys): + empty = tmp_path / "site" + empty.mkdir() + assert run(str(empty)) == 2 + assert "nothing to check" in capsys.readouterr().err + + +def test_excluded_page_contributes_no_links(tmp_path: Path, capsys): + """The case measured on docs.halos.fi: a generated single-page export. + + Every one of its 690 broken fragments came from mkdocs-print-site-plugin + output, and none from a content page. + """ + site = tmp_path / "site" + page(site, "index.html", '

Intro

') + page(site, "print_page/index.html", 'broken') + assert run(str(site)) == 1 + assert run(str(site), "--exclude", "print_page/*") == 0 + + +def test_excluded_page_is_still_a_valid_link_target(tmp_path: Path): + site = tmp_path / "site" + page(site, "index.html", 'go') + page(site, "print_page/index.html", '

Intro

') + assert run(str(site), "--exclude", "print_page/*") == 0 + + +def test_without_an_exclusion_nothing_is_skipped(tmp_path: Path, capsys): + site = tmp_path / "site" + page(site, "index.html", '

A

self') + page(site, "print_page/index.html", '

B

self') + assert run(str(site)) == 0 + assert "Checked 2 anchor links" in capsys.readouterr().out + + +def test_base_is_read_from_site_url_so_absolute_links_are_checked( + docs_repo: DocsRepo, capsys +): + """A wrong base silently skips root-absolute links instead of failing.""" + docs_repo.write( + "mkdocs.yml", + "site_name: Test\nsite_url: https://example.invalid/halpi2/\n", + ) + site = docs_repo.root / "site" + page(site, "index.html", 'go') + page(site, "guide/index.html", '

Setup

') + assert run(str(site)) == 1 + assert "Checked 1 anchor links" in capsys.readouterr().out + + +def test_explicit_base_overrides_the_configured_one(docs_repo: DocsRepo, capsys): + docs_repo.write( + "mkdocs.yml", + "site_name: Test\nsite_url: https://example.invalid/halpi2/\n", + ) + site = docs_repo.root / "site" + page(site, "index.html", 'go') + page(site, "guide/index.html", '

Setup

') + assert run(str(site), "--base", "/other/") == 1 + + +def test_root_absolute_link_outside_the_base_is_not_ours(tmp_path: Path, capsys): + site = tmp_path / "site" + page(site, "index.html", 'go') + assert run(str(site), "--base", "/halpi2/") == 0 diff --git a/tests/test_map_anchors.py b/tests/test_map_anchors.py new file mode 100644 index 0000000..d3c4537 --- /dev/null +++ b/tests/test_map_anchors.py @@ -0,0 +1,113 @@ +"""Behaviour of the anchor-fragment mapper.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from conftest import DocsRepo + +from halos_docs_tools import map_anchors + + +def built(repo: DocsRepo, relative: str, *ids: str) -> Path: + """A built page carrying the given heading ids, in order.""" + headings = "".join(f'

{i}

' for i in ids) + return repo.write(f"site/{relative}", f"{headings}") + + +def site_with_two_pages(repo: DocsRepo, translated_ids: tuple[str, ...]) -> None: + """docs/{en,fi}/{index,guide}.md, built, with fi headings renamed.""" + repo.source("guide.md") + repo.translation("fi", "guide.md") + built(repo, "index.html", "intro") + built(repo, "guide/index.html", "setup", "wiring") + built(repo, "fi/index.html", "johdanto") + built(repo, "fi/guide/index.html", *translated_ids) + + +def run(*argv: str) -> int: + return map_anchors.main(list(argv)) + + +def test_english_fragment_is_rewritten_to_the_translated_slug( + docs_repo: DocsRepo, capsys +): + site_with_two_pages(docs_repo, ("asennus", "johdotus")) + docs_repo.write("docs/fi/index.md", "Katso [ohjeet](guide.md#wiring).\n") + assert run("site", "fi", "--apply") == 0 + assert "#johdotus" in (docs_repo.root / "docs/fi/index.md").read_text() + assert "1 anchors rewritten" in capsys.readouterr().out + + +def test_without_apply_it_reports_but_does_not_write(docs_repo: DocsRepo, capsys): + site_with_two_pages(docs_repo, ("asennus", "johdotus")) + docs_repo.write("docs/fi/index.md", "Katso [ohjeet](guide.md#wiring).\n") + assert run("site", "fi") == 0 + assert "#wiring" in (docs_repo.root / "docs/fi/index.md").read_text() + assert "1 anchors to rewrite" in capsys.readouterr().out + + +def test_fragment_already_matching_is_left_alone(docs_repo: DocsRepo, capsys): + site_with_two_pages(docs_repo, ("setup", "wiring")) + docs_repo.write("docs/fi/index.md", "Katso [ohjeet](guide.md#wiring).\n") + assert run("site", "fi", "--apply") == 0 + assert "0 anchors rewritten" in capsys.readouterr().out + + +def test_structure_mismatch_is_reported_rather_than_mapped_wrongly( + docs_repo: DocsRepo, capsys +): + site_with_two_pages(docs_repo, ("asennus",)) # one heading, English has two + docs_repo.write("docs/fi/index.md", "Katso [ohjeet](guide.md#wiring).\n") + assert run("site", "fi", "--apply") == 1 + out = capsys.readouterr().out + assert "structure differs" in out + assert "2 headings in en, 1 translated" in out + assert "#wiring" in (docs_repo.root / "docs/fi/index.md").read_text() + + +def test_external_links_are_left_alone(docs_repo: DocsRepo, capsys): + site_with_two_pages(docs_repo, ("asennus", "johdotus")) + docs_repo.write( + "docs/fi/index.md", "Katso [muualta](https://example.invalid/#wiring).\n" + ) + assert run("site", "fi", "--apply") == 0 + assert "0 anchors rewritten" in capsys.readouterr().out + + +def test_unbuilt_page_is_an_error_not_a_silent_skip(docs_repo: DocsRepo): + docs_repo.source("guide.md") + built(docs_repo, "index.html", "intro") + built(docs_repo, "fi/index.html", "johdanto") + with pytest.raises(SystemExit, match="build the site first"): + run("site", "fi") + + +def test_default_locale_comes_from_mkdocs_not_from_a_hard_coded_en( + docs_repo: DocsRepo, capsys +): + """The package cannot assume the default locale is English.""" + docs_repo.write( + "mkdocs.yml", + """\ + site_name: Test docs + plugins: + - i18n: + docs_structure: folder + languages: + - locale: fi + name: Suomi + default: true + - locale: en + name: English + """, + ) + (docs_repo.root / "docs/en/index.md").unlink() + (docs_repo.root / "docs/fi/index.md").unlink() + docs_repo.write("docs/fi/guide.md", "# Ohje\n") + docs_repo.write("docs/en/guide.md", "Katso [ohjeet](guide.md#johdotus).\n") + built(docs_repo, "guide/index.html", "asennus", "johdotus") + built(docs_repo, "en/guide/index.html", "setup", "wiring") + assert run("site", "en", "--apply") == 0 + assert "#wiring" in (docs_repo.root / "docs/en/guide.md").read_text() From 9034e1f18a1e1becb4ffdf7296da068a7b0e97ca Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Wed, 12 Aug 2026 14:20:11 +0300 Subject: [PATCH 4/9] feat: port the glossary and typography checkers Both gain an argv parameter so the tests can drive them; check-typography gains argparse in place of reading sys.argv directly. check-glossary took its source locale from a hard-coded 'en' and now asks the i18n configuration, the same change map-anchors needed. Verified against hatlabs/halpi2: identical output to the scripts for typography across all nine locales, and for the glossary in each of them -- 84 terms in fi through 131 in it. The entry-point test now also resolves and runs --help on all six commands, which it could not do until every module existed. Co-Authored-By: Claude Opus 5 (1M context) --- src/halos_docs_tools/check_anchors.py | 8 +- src/halos_docs_tools/check_glossary.py | 162 +++++++++++++++++++ src/halos_docs_tools/check_typography.py | 174 +++++++++++++++++++++ src/halos_docs_tools/translation_status.py | 8 +- tests/test_check_glossary.py | 122 +++++++++++++++ tests/test_check_typography.py | 124 +++++++++++++++ tests/test_entry_points.py | 12 ++ tests/test_stamp_translation.py | 4 +- tests/test_translation_status.py | 4 +- 9 files changed, 606 insertions(+), 12 deletions(-) create mode 100644 src/halos_docs_tools/check_glossary.py create mode 100644 src/halos_docs_tools/check_typography.py create mode 100644 tests/test_check_glossary.py create mode 100644 tests/test_check_typography.py diff --git a/src/halos_docs_tools/check_anchors.py b/src/halos_docs_tools/check_anchors.py index 0e73270..fd1ca57 100644 --- a/src/halos_docs_tools/check_anchors.py +++ b/src/halos_docs_tools/check_anchors.py @@ -51,7 +51,9 @@ def configured_base() -> str: return path if path.endswith("/") else path + "/" -def collect_pages(site: str, exclude: list[str]) -> tuple[dict[str, set[str]], set[str]]: +def collect_pages( + site: str, exclude: list[str] +) -> tuple[dict[str, set[str]], set[str]]: """Map each built page to the ids it defines, and note which are excluded. An excluded page contributes no links to the check but keeps its ids: it is @@ -145,7 +147,9 @@ def main(argv: list[str] | None = None) -> int: broken.append((relative, href, "no such anchor on the target page")) skipped = f", {len(excluded)} excluded" if excluded else "" - print(f"Checked {checked} anchor links across {len(ids) - len(excluded)} pages{skipped}.") + print( + f"Checked {checked} anchor links across {len(ids) - len(excluded)} pages{skipped}." + ) if broken: print(f"\n{len(broken)} broken:\n") for page, href, why in broken: diff --git a/src/halos_docs_tools/check_glossary.py b/src/halos_docs_tools/check_glossary.py new file mode 100644 index 0000000..6498335 --- /dev/null +++ b/src/halos_docs_tools/check_glossary.py @@ -0,0 +1,162 @@ +"""Check that a translation actually uses the terms its glossary prescribes. + +A glossary read before translating looks followed afterwards, because rereading +one's own text confirms whatever it already says. Every language branch so far +reached review with a term the glossary defines and the pages ignore — a second +name for the same connector, one page apart, which no reader can reconcile. + +The check is indirect but cheap: if a glossary term appears in the English +source and its prescribed translation appears nowhere in the target language, +some other word is doing that job. Run it before opening a pull request. + +It finds a term that is never used, not a term that has acquired a rival. German +says both `Spannungsausfall` and `Stromausfall` for *blackout* and passes here, +because the prescribed word does appear. Catching that needs the rival named, +which is what the glossary cannot know in advance. + +Exit status is 1 if any prescribed term is unused. +""" + +from __future__ import annotations + +import argparse +import re +import sys +import unicodedata +from pathlib import Path + +from .translation_status import configured_languages + +GLOSSARIES = { + "fi": "finnish-glossary.md", + "fr": "french-glossary.md", + "de": "german-glossary.md", + "sv": "swedish-glossary.md", + "es": "spanish-glossary.md", + "it": "italian-glossary.md", + "nl": "dutch-glossary.md", + "nb": "norwegian-glossary.md", + "da": "danish-glossary.md", +} + +ROW = re.compile(r"^\| *`?([^|`]+?)`? *\| *`?([^|`]+?)`? *\|") +SHORTEST_TERM = 5 +# An English term used once may be phrased around; twice is a pattern. +MIN_ENGLISH_USES = 2 + + +def read_pages(directory: Path) -> str: + """Concatenate a language's markdown with code and frontmatter removed.""" + out = [] + for page in sorted(directory.rglob("*.md")): + raw = page.read_text(encoding="utf-8") + text = re.sub(r"^---\n.*?\n---\n", "", raw, flags=re.S) + text = re.sub(r"```.*?```", " ", text, flags=re.S) + out.append(re.sub(r"`[^`\n]*`", " ", text)) + return fold("\n".join(out).lower()) + + +def terms(glossary: Path) -> list[tuple[str, str]]: + """Extract (english, translation) pairs from the glossary tables.""" + pairs = [] + for line in glossary.read_text(encoding="utf-8").splitlines(): + row = ROW.match(line) + if not row: + continue + english, translated = row.group(1).strip(), row.group(2).strip() + if english.lower().startswith("english") or set(english) <= set(":- "): + continue + pairs.append((english, translated)) + return pairs + + +def fold(text: str) -> str: + """Flatten the spelling differences that inflection introduces. + + Romance plurals move accents around — `tapón` becomes `tapones`, `imagen` + becomes `imágenes` — and Italian sets its apostrophe as U+2019 where a + glossary cell is typed with U+0027. Comparing the letters underneath keeps + those from reading as a term the pages never used. + """ + text = text.replace("’", "'").replace("ʼ", "'") + return "".join( + c for c in unicodedata.normalize("NFKD", text) if not unicodedata.combining(c) + ) + + +def alternatives(term: str) -> list[str]: + """Split a glossary cell into the forms that would each satisfy it.""" + term = re.sub(r"\s*\([^)]*\)", "", term).lower() + return [part.strip() for part in term.split("/") if part.strip()] + + +def inflectable(term: str) -> re.Pattern[str]: + """Match a term in whatever form a sentence needs. + + Every word may take an ending, not just the last one: Finnish inflects both + halves of `vapaa tila` and French pluralises both halves of `bouchon + obturateur`, so anchoring on the phrase as written finds neither. A verb + phrase also takes its object in the middle — `aseta CM5 uudelleen + paikalleen` — so a couple of words are allowed to intervene. + + The match must start at a word boundary, or a compounding language reports + a term as used when only a longer word containing it is present: Finnish + `virtalähde` (power supply) is a substring of `vakiovirtalähde` (constant + current source), two different components. Without the boundary this check + returns a false green, which is worse than a false alarm — a checker that + passes when it should not is no checker at all. + + The boundary only applies when the term starts with a word character. A row + like `−32 V and +32 V` opens with a minus sign, and `\\b` before a non-word + character asserts the opposite of what is meant — it would demand a letter + immediately before the minus and match nothing. + """ + words = [re.escape(w[: max(3, len(w) - 3)]) + r"\w*" for w in fold(term).split()] + body = r"(?:\W+\w+){0,2}\W+".join(words) + boundary = r"\b" if re.match(r"\w", fold(term)) else "" + return re.compile(boundary + body) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "language", choices=sorted(GLOSSARIES), help="target language code" + ) + parser.add_argument("--docs", default="docs", help="documentation root") + parser.add_argument( + "--glossaries", + default="solutions/translation", + help="directory holding the glossaries", + ) + args = parser.parse_args(argv) + + source_locale, _ = configured_languages() + english = read_pages(Path(args.docs) / source_locale) + translated = read_pages(Path(args.docs) / args.language) + glossary = Path(args.glossaries) / GLOSSARIES[args.language] + + checked, unused = 0, [] + for source, target in terms(glossary): + wanted = [w for w in alternatives(source) if len(w) >= SHORTEST_TERM] + have = [h for h in alternatives(target) if len(h) >= SHORTEST_TERM] + if not wanted or not have: + continue + uses = sum(english.count(w) for w in wanted) + if uses < MIN_ENGLISH_USES: + continue + checked += 1 + if not any(inflectable(h).search(translated) for h in have): + unused.append((source, target, uses)) + + print(f"Checked {checked} glossary terms against docs/{args.language}.") + if unused: + print(f"\n{len(unused)} prescribed but unused — something else took over:\n") + for source, target, uses in unused: + print(f" {source} -> {target} (English {uses}×, translation never)") + return 1 + print("Every prescribed term is in use.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/halos_docs_tools/check_typography.py b/src/halos_docs_tools/check_typography.py new file mode 100644 index 0000000..f967001 --- /dev/null +++ b/src/halos_docs_tools/check_typography.py @@ -0,0 +1,174 @@ +"""Count the typography rules a translation has to obey, per language. + +Written after two naive greps produced only false positives: searching for the +character pair »…« in Norwegian matches the gap *between* two correct «…» pairs, +and searching for a space before a colon matches English comments inside code +fences. Both looked like defects and neither was one. + +So quotations are checked by walking the marks in order and requiring them to +alternate open, close, open, close — which is what "the pairs are the right way +round" actually means — and everything is measured with code fences, inline code +and admonition syntax removed first. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +# Which mark opens a quotation, and which closes it, per language. +QUOTES = { + "fi": ("”", "”"), # ”…” — same character both sides + "fr": ("«", "»"), # «…» + "de": ("„", "“"), # „…“ + "sv": ("”", "”"), # ”…” + "es": ("«", "»"), # «…» + "it": ("“", "”"), # “…” + "nl": ("“", "”"), # “…” + "nb": ("«", "»"), # «…» + "da": ("»", "«"), # »…« — outward, the opposite of Norwegian +} +# French is the one language that *requires* a space before ; : ! ? — and +# requires it to be unbreakable, so the line never breaks before the mark. +# Everywhere else any space there is an error, which is why this cannot be one +# rule for all: applying the French habit elsewhere is a known leak, and +# applying the majority rule to French would flag every correct sentence. +SPACE_REQUIRED = {"fr"} +PLAIN_SPACE_BEFORE_PUNCT = re.compile(r"\u0020[;:!?]") +# German compounds a multi-word proper name with hyphens throughout — +# NMEA-2000-Netzwerk, Signal-K-Server — and its glossary calls a missing hyphen +# there the most visible marker of a translation done by someone who does not +# write German. Every other language treats that same chain as an error, and a +# hyphen at the *junction* between a product name and a common noun +# (HaLOS-avbilder) is right in the Germanic languages and wrong in the Romance +# ones. One rule cannot serve all three cases, so each is scoped to where its +# glossary asks for it. +HYPHEN_CHAINS = re.compile(r"NMEA-2000|Signal-K|Raspberry-Pi|Compute-Module") +CHAINS_ALLOWED = {"de"} +JUNCTION_HYPHEN = re.compile( + r"\b(?:HALPI2|HaLOS|NMEA 2000|Signal K|Raspberry Pi|E7T)-" + r"[a-z\u00e1\u00e9\u00ed\u00f3\u00fa\u00f1\u00e0\u00e8\u00ec\u00f2\u00f9]" +) +JUNCTION_FORBIDDEN = {"es", "it"} +SPACE_BEFORE_PUNCT = re.compile(r"[   ][;:!?]") + + +def prose(text: str) -> str: + """The text a reader sees, with everything that is markup taken out. + + Inline code becomes a placeholder rather than nothing: deleting it joins the + words on either side and manufactures a space before the next punctuation + mark, which is exactly the false positive this function exists to avoid. + """ + text = re.sub(r"^---\n.*?\n---\n", "", text, flags=re.S) + text = re.sub(r"```.*?```", "\n", text, flags=re.S) + text = re.sub(r"`[^`\n]*`", "X", text) + text = re.sub(r'^!!! \w+ ".*"$', "", text, flags=re.M) # admonition syntax quotes + text = re.sub(r"\]\([^)]*\)", "]", text) # link targets + # A table's delimiter row carries the column alignment as colons — | ---: | + # — which reads as a space before a colon and is not prose at all. + text = re.sub(r"^[|\s:-]+$", "", text, flags=re.M) + # Repository names and filenames are identifiers that happen to contain + # hyphens — HALPI2-hardware, HALPI2-schematic_v0.6.1.pdf — and reading them + # as compounds of the target language invents defects that are not there. + text = re.sub(r"https?://\S+", "X", text) + text = re.sub( + r"\b[\w.-]+\.(?:pdf|zip|png|jpe?g|md|txt|json|ya?ml|step|bin|conf|sock)\b", + "X", + text, + ) + return text + + +def quotation_faults(text: str, opening: str, closing: str) -> list[str]: + """Marks must alternate open, close, open, close — and end closed.""" + if opening == closing: + count = text.count(opening) + return [] if count % 2 == 0 else [f"odd number of {opening} ({count})"] + faults, depth = [], 0 + for index, char in enumerate(text): + if char == opening: + if depth: + faults.append( + f"{opening} opens while already open: " + f"...{text[max(0, index - 40) : index + 20]}..." + ) + depth += 1 + elif char == closing: + if not depth: + faults.append( + f"{closing} closes nothing: " + f"...{text[max(0, index - 40) : index + 20]}..." + ) + else: + depth -= 1 + if depth: + faults.append(f"{depth} quotation(s) never closed") + return faults + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "languages", + nargs="*", + choices=[*sorted(QUOTES), []], + help="locales to check (default: every language with a configured rule)", + ) + args = parser.parse_args(argv) + + worst = 0 + for language in args.languages or sorted(QUOTES): + opening, closing = QUOTES[language] + pages = sorted(Path("docs", language).rglob("*.md")) + quotes = spacing = chains = 0 + problems: list[str] = [] + for page in pages: + text = prose(page.read_text(encoding="utf-8")) + for fault in quotation_faults(text, opening, closing): + quotes += 1 + problems.append(f" {page}: {fault}") + rule = ( + PLAIN_SPACE_BEFORE_PUNCT + if language in SPACE_REQUIRED + else SPACE_BEFORE_PUNCT + ) + for match in rule.finditer(text): + spacing += 1 + wrong = "breakable space" if language in SPACE_REQUIRED else "space" + problems.append( + f" {page}: {wrong} before '{match.group()[-1]}': " + f"...{text[max(0, match.start() - 40) : match.end() + 10]}..." + ) + allowed = language in CHAINS_ALLOWED + chain_rule = () if allowed else HYPHEN_CHAINS.finditer(text) + for match in chain_rule: + chains += 1 + problems.append( + f" {page}: hyphen inside a product name '{match.group()}'" + ) + if language in JUNCTION_FORBIDDEN: + for match in JUNCTION_HYPHEN.finditer(text): + chains += 1 + problems.append( + f" {page}: junction hyphen '{match.group()}' " + f"— not used in this language" + ) + + marks = sum(prose(p.read_text(encoding="utf-8")).count(opening) for p in pages) + status = "ok" if not problems else f"{len(problems)} PROBLEMS" + print( + f"{language}: {len(pages)} pages, {marks} quotations " + f"({opening}…{closing}), quote faults {quotes}, spacing {spacing}, " + f"hyphen chains {chains} — {status}" + ) + for problem in problems[:8]: + print(problem) + worst = max(worst, len(problems)) + return 1 if worst else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/halos_docs_tools/translation_status.py b/src/halos_docs_tools/translation_status.py index 48d9b64..cf1d09b 100644 --- a/src/halos_docs_tools/translation_status.py +++ b/src/halos_docs_tools/translation_status.py @@ -43,9 +43,7 @@ def configured_languages() -> tuple[str, list[str]]: if isinstance(plugin, dict) and "i18n" in plugin: languages = plugin["i18n"]["languages"] default = next(lang["locale"] for lang in languages if lang.get("default")) - others = [ - lang["locale"] for lang in languages if not lang.get("default") - ] + others = [lang["locale"] for lang in languages if not lang.get("default")] return default, others raise SystemExit("mkdocs.yml has no i18n plugin configuration") @@ -130,9 +128,7 @@ def collect(default: str, languages: list[str], want_diff: bool) -> list[Entry]: entries.append(Entry(language, str(relative), "current", expected)) else: diff = english_diff(stamped, source) if want_diff else None - entries.append( - Entry(language, str(relative), "stale", expected, diff) - ) + entries.append(Entry(language, str(relative), "stale", expected, diff)) # A translation whose source was deleted is invisible to the loop above, # because that walks the sources. It is still a page being served. diff --git a/tests/test_check_glossary.py b/tests/test_check_glossary.py new file mode 100644 index 0000000..583dcad --- /dev/null +++ b/tests/test_check_glossary.py @@ -0,0 +1,122 @@ +"""Behaviour of the glossary-adherence checker.""" + +from __future__ import annotations + +from conftest import DocsRepo + +from halos_docs_tools import check_glossary + + +def glossary(repo: DocsRepo, *rows: tuple[str, str]) -> None: + table = ["| English | Finnish |", "|:---|:---|"] + table += [f"| {source} | {target} |" for source, target in rows] + repo.write("solutions/translation/finnish-glossary.md", "\n".join(table) + "\n") + + +def run(*argv: str) -> int: + return check_glossary.main(list(argv)) + + +def test_prescribed_term_in_use_passes(docs_repo: DocsRepo, capsys): + glossary(docs_repo, ("power supply", "virtalähde")) + docs_repo.source("index.md", "The power supply is fine. The power supply works.\n") + docs_repo.write("docs/fi/index.md", "Virtalähde on kunnossa.\n") + assert run("fi") == 0 + out = capsys.readouterr().out + assert "Checked 1 glossary terms" in out + assert "Every prescribed term is in use." in out + + +def test_prescribed_term_never_used_is_reported(docs_repo: DocsRepo, capsys): + glossary(docs_repo, ("power supply", "virtalähde")) + docs_repo.source("index.md", "The power supply is fine. The power supply works.\n") + docs_repo.write("docs/fi/index.md", "Teholähde on kunnossa.\n") + assert run("fi") == 1 + out = capsys.readouterr().out + assert "prescribed but unused" in out + assert "power supply" in out + + +def test_a_term_used_once_in_english_is_not_checked(docs_repo: DocsRepo, capsys): + glossary(docs_repo, ("power supply", "virtalähde")) + docs_repo.source("index.md", "The power supply is fine.\n") + docs_repo.write("docs/fi/index.md", "Teholähde on kunnossa.\n") + assert run("fi") == 0 + assert "Checked 0 glossary terms" in capsys.readouterr().out + + +def test_occurrences_inside_a_code_fence_do_not_count(docs_repo: DocsRepo, capsys): + glossary(docs_repo, ("power supply", "virtalähde")) + docs_repo.source( + "index.md", + "Intro.\n\n```\n# power supply\n# power supply\n```\n", + ) + docs_repo.write("docs/fi/index.md", "Teholähde on kunnossa.\n") + assert run("fi") == 0 + assert "Checked 0 glossary terms" in capsys.readouterr().out + + +def test_an_inflected_form_counts_as_used(docs_repo: DocsRepo, capsys): + glossary(docs_repo, ("power supply", "virtalähde")) + docs_repo.source("index.md", "The power supply is fine. The power supply works.\n") + docs_repo.write("docs/fi/index.md", "Virtalähteen jännite on oikea.\n") + assert run("fi") == 0 + assert "Every prescribed term is in use." in capsys.readouterr().out + + +def test_a_longer_compound_does_not_satisfy_the_term(docs_repo: DocsRepo, capsys): + """The false green the word boundary exists to prevent. + + Finnish `virtalähde` is a substring of `vakiovirtalähde`, a different + component. A checker that passes when it should not is no checker at all. + """ + glossary(docs_repo, ("power supply", "virtalähde")) + docs_repo.source("index.md", "The power supply is fine. The power supply works.\n") + docs_repo.write("docs/fi/index.md", "Vakiovirtalähde on kytketty.\n") + assert run("fi") == 1 + assert "prescribed but unused" in capsys.readouterr().out + + +def test_alternatives_separated_by_a_slash_each_satisfy_the_row( + docs_repo: DocsRepo, capsys +): + glossary(docs_repo, ("power supply", "virtalähde / syöttöjännite")) + docs_repo.source("index.md", "The power supply is fine. The power supply works.\n") + docs_repo.write("docs/fi/index.md", "Syöttöjännite on oikea.\n") + assert run("fi") == 0 + + +def test_header_and_separator_rows_are_not_terms(docs_repo: DocsRepo, capsys): + glossary(docs_repo) + docs_repo.source("index.md", "Nothing here.\n") + docs_repo.write("docs/fi/index.md", "Ei mitään.\n") + assert run("fi") == 0 + assert "Checked 0 glossary terms" in capsys.readouterr().out + + +def test_source_locale_comes_from_mkdocs_not_a_hard_coded_en( + docs_repo: DocsRepo, capsys +): + """The package cannot assume the source locale is English.""" + docs_repo.write( + "mkdocs.yml", + """\ + site_name: Test docs + plugins: + - i18n: + docs_structure: folder + languages: + - locale: sv + name: Svenska + default: true + - locale: fi + name: Suomi + """, + ) + glossary(docs_repo, ("power supply", "virtalähde")) + docs_repo.write( + "docs/sv/index.md", "The power supply works. The power supply is on.\n" + ) + docs_repo.write("docs/fi/index.md", "Teholähde on kunnossa.\n") + assert run("fi") == 1 + assert "prescribed but unused" in capsys.readouterr().out diff --git a/tests/test_check_typography.py b/tests/test_check_typography.py new file mode 100644 index 0000000..843fced --- /dev/null +++ b/tests/test_check_typography.py @@ -0,0 +1,124 @@ +"""Behaviour of the per-language typography checker.""" + +from __future__ import annotations + +from conftest import DocsRepo + +from halos_docs_tools import check_typography + +NARROW_NBSP = "\u202f" + + +def run(*argv: str) -> int: + return check_typography.main(list(argv)) + + +def test_correctly_nested_guillemets_are_not_a_reversed_pair( + docs_repo: DocsRepo, capsys +): + """The false positive that motivated the alternation logic. + + Searching for the pair »…« in Norwegian matches the gap *between* two + correct «…» quotations. + """ + docs_repo.write("docs/nb/index.md", "Se «her» og «der» i teksten.\n") + assert run("nb") == 0 + assert "quote faults 0" in capsys.readouterr().out + + +def test_outward_marks_in_norwegian_are_faults(docs_repo: DocsRepo, capsys): + docs_repo.write("docs/nb/index.md", "Se »her« i teksten.\n") + assert run("nb") == 1 + assert "closes nothing" in capsys.readouterr().out + + +def test_danish_uses_the_outward_pair(docs_repo: DocsRepo, capsys): + docs_repo.write("docs/da/index.md", "Se »her« i teksten.\n") + assert run("da") == 0 + assert "quote faults 0" in capsys.readouterr().out + + +def test_unclosed_quotation_is_reported(docs_repo: DocsRepo, capsys): + docs_repo.write("docs/nb/index.md", "Se «her i teksten.\n") + assert run("nb") == 1 + assert "never closed" in capsys.readouterr().out + + +def test_odd_number_of_symmetric_marks_is_reported(docs_repo: DocsRepo, capsys): + docs_repo.write("docs/fi/index.md", "Katso ”tästä ja tuosta.\n") + assert run("fi") == 1 + assert "odd number of" in capsys.readouterr().out + + +def test_space_before_a_colon_inside_a_code_fence_is_not_a_fault( + docs_repo: DocsRepo, capsys +): + """The second false positive: English comments inside code fences.""" + docs_repo.write( + "docs/fi/index.md", + "Katso:\n\n```\nnote : this is code\n```\n", + ) + assert run("fi") == 0 + assert "spacing 0" in capsys.readouterr().out + + +def test_space_before_a_colon_in_prose_is_a_fault(docs_repo: DocsRepo, capsys): + docs_repo.write("docs/fi/index.md", "Katso tätä : se on väärin.\n") + assert run("fi") == 1 + assert "spacing 1" in capsys.readouterr().out + + +def test_french_requires_an_unbreakable_space_before_punctuation( + docs_repo: DocsRepo, capsys +): + docs_repo.write("docs/fr/index.md", f"Voyez ceci{NARROW_NBSP}: correct.\n") + assert run("fr") == 0 + assert "spacing 0" in capsys.readouterr().out + + +def test_french_plain_space_before_punctuation_is_a_fault(docs_repo: DocsRepo, capsys): + docs_repo.write("docs/fr/index.md", "Voyez ceci : incorrect.\n") + assert run("fr") == 1 + assert "breakable space" in capsys.readouterr().out + + +def test_table_alignment_row_is_not_prose(docs_repo: DocsRepo, capsys): + docs_repo.write( + "docs/fi/index.md", + "| Nimi | Arvo |\n| --- | ---: |\n| a | 1 |\n", + ) + assert run("fi") == 0 + assert "spacing 0" in capsys.readouterr().out + + +def test_hyphen_chain_is_allowed_in_german_and_not_elsewhere( + docs_repo: DocsRepo, capsys +): + docs_repo.write("docs/de/index.md", "Das NMEA-2000-Netzwerk ist aktiv.\n") + assert run("de") == 0 + docs_repo.write("docs/fi/index.md", "NMEA-2000-verkko on aktiivinen.\n") + assert run("fi") == 1 + assert "hyphen inside a product name" in capsys.readouterr().out + + +def test_junction_hyphen_is_a_fault_in_the_romance_languages( + docs_repo: DocsRepo, capsys +): + docs_repo.write("docs/es/index.md", "La HaLOS-imagen se instala.\n") + assert run("es") == 1 + assert "junction hyphen" in capsys.readouterr().out + docs_repo.write("docs/nb/index.md", "HaLOS-avbilder installeres.\n") + assert run("nb") == 0 + + +def test_filenames_are_not_read_as_compounds(docs_repo: DocsRepo, capsys): + docs_repo.write("docs/fi/index.md", "Lataa HALPI2-schematic_v0.6.1.pdf tiedosto.\n") + assert run("fi") == 0 + + +def test_no_arguments_checks_every_configured_language(docs_repo: DocsRepo, capsys): + docs_repo.write("docs/nb/index.md", "Se »her« i teksten.\n") + assert run() == 1 + out = capsys.readouterr().out + for language in ("fi", "fr", "de", "sv", "es", "it", "nl", "nb", "da"): + assert f"{language}: " in out diff --git a/tests/test_entry_points.py b/tests/test_entry_points.py index f60a9ca..9e05dc8 100644 --- a/tests/test_entry_points.py +++ b/tests/test_entry_points.py @@ -5,8 +5,11 @@ distance. This test is the thing that makes that a deliberate act. """ +import importlib from importlib.metadata import entry_points +import pytest + COMMANDS = { "translation-status": "halos_docs_tools.translation_status:main", "stamp-translation": "halos_docs_tools.stamp_translation:main", @@ -27,3 +30,12 @@ def declared() -> dict[str, str]: def test_all_six_commands_are_declared(): assert declared() == COMMANDS + + +@pytest.mark.parametrize("command", sorted(COMMANDS)) +def test_each_command_resolves_and_answers_help(command: str): + module_path, _, function = COMMANDS[command].partition(":") + main = getattr(importlib.import_module(module_path), function) + with pytest.raises(SystemExit) as exit_: + main(["--help"]) + assert exit_.value.code == 0 diff --git a/tests/test_stamp_translation.py b/tests/test_stamp_translation.py index b721aa3..714856d 100644 --- a/tests/test_stamp_translation.py +++ b/tests/test_stamp_translation.py @@ -15,9 +15,7 @@ def run(*argv: str) -> int: def state(repo: DocsRepo, language: str, page: str) -> str: default, languages = translation_status.configured_languages() entries = translation_status.collect(default, languages, want_diff=False) - return next( - e.state for e in entries if e.language == language and e.page == page - ) + return next(e.state for e in entries if e.language == language and e.page == page) def test_stamping_a_stale_translation_makes_it_current(docs_repo: DocsRepo): diff --git a/tests/test_translation_status.py b/tests/test_translation_status.py index 30aa353..09aaf3c 100644 --- a/tests/test_translation_status.py +++ b/tests/test_translation_status.py @@ -74,7 +74,9 @@ def test_unreachable_stamped_blob_omits_the_diff_and_hints(docs_repo: DocsRepo, assert "```diff" not in out -def test_diff_shows_the_english_change_since_the_translation(docs_repo: DocsRepo, capsys): +def test_diff_shows_the_english_change_since_the_translation( + docs_repo: DocsRepo, capsys +): docs_repo.commit("docs: seed") docs_repo.source("index.md", "# Title\n\nEnglish body, revised.\n") assert run("--format", "markdown", "--diff") == 0 From 0bb8ad0a5fd86e55f619582436dc04aa2cf9a7c8 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Wed, 12 Aug 2026 14:21:37 +0300 Subject: [PATCH 5/9] feat: add --check, the mode that fails a build Exits non-zero when any page in any configured locale is stale, missing, unstamped or orphaned. Without the flag the command reports and exits 0 as before, so nothing that calls it today changes meaning. The gate ignores --only-pages. That flag narrows the report; the rule is a property of the repository. A gate that shrank with the report would go red over a page its own output never mentioned. The failure block names every entry responsible rather than a count. A count sends the reader into the job log, and the report above it may have been filtered to a subset of pages. Verified against hatlabs/halpi2: main exits 0; one English-only edit to faq.md exits 1 and names all nine locales. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 17 +++++ src/halos_docs_tools/translation_status.py | 38 +++++++++- tests/test_translation_gate.py | 84 ++++++++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 tests/test_translation_gate.py diff --git a/README.md b/README.md index ab3a803..0595597 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,23 @@ its hash, which makes every translation of it report as stale on its own. `translation-status` classifies each page in each configured locale as `current`, `stale`, `missing`, `unstamped` or `orphaned`. +### Failing a build on it + +``` +translation-status --check +``` + +exits non-zero when any page in any configured locale is anything but +`current`, and names every entry responsible. Without `--check` the command +only reports, whatever it finds. + +The gate is a property of the repository, not of a pull request's diff, so +`--only-pages` narrows the report and never the rule. + +One consequence is worth knowing before you meet it: adding a locale to +`mkdocs.yml` makes every page `missing` in that locale immediately. A new +locale therefore arrives in a single pull request, together with its pages. + ## Development ``` diff --git a/src/halos_docs_tools/translation_status.py b/src/halos_docs_tools/translation_status.py index cf1d09b..0e606b1 100644 --- a/src/halos_docs_tools/translation_status.py +++ b/src/halos_docs_tools/translation_status.py @@ -11,7 +11,9 @@ it changes its content, which changes its hash, which makes every translation of it report as stale on its own. -Reports; never blocks. Exit status is 0 unless the check itself could not run. +Reports by default. With --check it also fails: any page that is not current, +in any configured locale, exits non-zero. That is a property of the repository, +so the gate ignores --only-pages, which narrows the report and not the rule. """ from __future__ import annotations @@ -208,6 +210,29 @@ def render_markdown(entries: list[Entry], only: set[str] | None) -> str: return "\n".join(out) +def render_failure(behind: list[Entry]) -> str: + """Name every entry the gate is failing on. + + A non-zero exit carrying only a count sends the reader into the job log to + find out what to do, and the report above may have been filtered to a + subset of pages. This block is the one thing that always lists all of it. + """ + out = [ + "", + f"Translation gate: {len(behind)} of the configured translations are " + f"not current.", + "", + ] + for entry in sorted(behind, key=lambda e: (e.state, e.language, e.page)): + out.append(f" {entry.state:9s} {entry.language}/{entry.page}") + out += [ + "", + "Translate the pages above, stamp them with stamp-translation, and " + "run this check again.", + ] + return "\n".join(out) + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--format", choices=("text", "markdown"), default="text") @@ -222,6 +247,12 @@ def main(argv: list[str] | None = None) -> int: metavar="PATH", help="restrict the detail section to these docs//-relative paths", ) + parser.add_argument( + "--check", + action="store_true", + help="exit non-zero when any translation is stale, missing, unstamped " + "or orphaned, across the whole repository", + ) args = parser.parse_args(argv) default, languages = configured_languages() @@ -235,6 +266,11 @@ def main(argv: list[str] | None = None) -> int: print(render_markdown(entries, only)) else: print(render_text(entries)) + + behind = [e for e in entries if e.state != "current"] + if args.check and behind: + print(render_failure(behind)) + return 1 return 0 diff --git a/tests/test_translation_gate.py b/tests/test_translation_gate.py new file mode 100644 index 0000000..6285140 --- /dev/null +++ b/tests/test_translation_gate.py @@ -0,0 +1,84 @@ +"""The blocking mode: when the translation status check fails a build. + +The invariant is a property of the repository, not of a pull request's diff, +so the gate looks at every page in every configured locale whatever the report +above it was asked to show. +""" + +from __future__ import annotations + +from conftest import DocsRepo + +from halos_docs_tools import translation_status + + +def run(*argv: str) -> int: + return translation_status.main(list(argv)) + + +def test_a_fully_current_repository_passes(docs_repo: DocsRepo): + assert run("--check") == 0 + + +def test_stale_fails(docs_repo: DocsRepo): + docs_repo.source("index.md", "# Title\n\nRevised.\n") + assert run("--check") != 0 + + +def test_missing_fails(docs_repo: DocsRepo): + docs_repo.source("guide.md") + assert run("--check") != 0 + + +def test_unstamped_fails(docs_repo: DocsRepo): + docs_repo.translation("fi", "index.md", stamp=None) + assert run("--check") != 0 + + +def test_orphaned_fails(docs_repo: DocsRepo): + docs_repo.write("docs/fi/gone.md", "---\ntranslated_from: x\n---\n\nBody.\n") + assert run("--check") != 0 + + +def test_one_locale_behind_is_enough_to_fail(docs_repo: DocsRepo): + docs_repo.source("index.md", "# Title\n\nRevised.\n") + docs_repo.translation("fi", "index.md") # fi brought current, sv left behind + assert run("--check") != 0 + + +def test_only_pages_does_not_narrow_what_the_gate_considers(docs_repo: DocsRepo): + """The report may be filtered. The gate may not.""" + docs_repo.source("guide.md") # missing in both locales + docs_repo.source("index.md", "# Title\n\nRevised.\n") # stale in both + assert run("--check", "--format", "markdown", "--only-pages", "index.md") != 0 + + +def test_without_the_flag_a_failing_repository_still_exits_zero(docs_repo: DocsRepo): + docs_repo.source("index.md", "# Title\n\nRevised.\n") + assert run() == 0 + assert run("--format", "markdown") == 0 + + +def test_the_failure_names_every_page_and_locale_responsible( + docs_repo: DocsRepo, capsys +): + docs_repo.source("guide.md") + docs_repo.source("index.md", "# Title\n\nRevised.\n") + assert run("--check") != 0 + out = capsys.readouterr().out + for locale in ("fi", "sv"): + assert f"{locale}/guide.md" in out + assert f"{locale}/index.md" in out + assert "missing" in out + assert "stale" in out + + +def test_the_failure_names_entries_the_filtered_report_omitted( + docs_repo: DocsRepo, capsys +): + """A red check whose comment never mentions the offending page is worse + than no check: the reader cannot act on it.""" + docs_repo.source("guide.md") + assert run("--check", "--format", "markdown", "--only-pages", "index.md") != 0 + out = capsys.readouterr().out + assert "fi/guide.md" in out From abb1011c97ddb0a991becad73b2ce6d25a84b358 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Wed, 12 Aug 2026 14:24:03 +0300 Subject: [PATCH 6/9] feat: build the pull request comment body in the package --comment emits a body covering every entry the gate fails on, rather than the pages a pull request touched. Under a repository-wide gate a touched-pages comment can omit the very page that turned the check red. It keeps what the workflow shell script established: the 60000-character ceiling below GitHub's 65536 limit, the fallback that drops the diffs and points at the job summary, and the marker that lets a workflow update its own previous comment. The command writes a body and makes no API calls, so posting stays where the token is and the body itself is testable. Verified against hatlabs/halpi2 with every English page edited: the comment and the gate name the same 180 entries, and the oversized body falls back to 33916 characters with no diffs. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 15 +++ src/halos_docs_tools/translation_status.py | 39 +++++++- tests/test_translation_comment.py | 106 +++++++++++++++++++++ 3 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 tests/test_translation_comment.py diff --git a/README.md b/README.md index 0595597..4bf86aa 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,21 @@ One consequence is worth knowing before you meet it: adding a locale to `mkdocs.yml` makes every page `missing` in that locale immediately. A new locale therefore arrives in a single pull request, together with its pages. +### The pull request comment + +``` +translation-status --comment > body.md +``` + +writes a comment body describing every entry the gate fails on, with the +English changes since each translation was written, collapsed. The command +writes a body and nothing else; posting it belongs to whatever holds the token. + +The body carries a `` marker so a workflow can find +and update its own previous comment rather than adding another one. If the body +would exceed GitHub's 65536-character limit, the diffs come out and the reader +is pointed at the job summary for them. + ## Development ``` diff --git a/src/halos_docs_tools/translation_status.py b/src/halos_docs_tools/translation_status.py index 0e606b1..43ebd68 100644 --- a/src/halos_docs_tools/translation_status.py +++ b/src/halos_docs_tools/translation_status.py @@ -29,6 +29,11 @@ DOCS = Path("docs") STAMP_KEY = "translated_from" +# GitHub rejects a comment body over 65536 characters with HTTP 422. A wide +# change produces a report far beyond that, so the diffs come out below this +# and the reader is sent to the job summary for them. +COMMENT_CEILING = 60000 +COMMENT_MARKER = "" class _Loader(yaml.SafeLoader): @@ -210,6 +215,28 @@ def render_markdown(entries: list[Entry], only: set[str] | None) -> str: return "\n".join(out) +def render_comment(entries: list[Entry]) -> str: + """The pull request comment body: every entry the gate fails on. + + Scoping this to the pages a pull request touched would let the comment omit + the page that turned the check red, because the gate reads the whole + repository and a diff does not. + """ + full = render_markdown(entries, None) + if len(full) <= COMMENT_CEILING: + return f"{full}\n\n{COMMENT_MARKER}\n" + + without_diffs = render_markdown( + [Entry(e.language, e.page, e.state, e.expected) for e in entries], None + ) + return ( + f"{without_diffs}\n\n" + "_Diffs omitted: the full report exceeds GitHub's comment size limit._\n" + "_See the workflow run's job summary for the complete report._\n" + f"\n{COMMENT_MARKER}\n" + ) + + def render_failure(behind: list[Entry]) -> str: """Name every entry the gate is failing on. @@ -247,6 +274,12 @@ def main(argv: list[str] | None = None) -> int: metavar="PATH", help="restrict the detail section to these docs//-relative paths", ) + parser.add_argument( + "--comment", + action="store_true", + help="emit a pull request comment body covering every entry the gate " + "fails on, size-capped, instead of the report", + ) parser.add_argument( "--check", action="store_true", @@ -260,8 +293,10 @@ def main(argv: list[str] | None = None) -> int: print("No translation languages configured.") return 0 - entries = collect(default, languages, want_diff=args.diff) - if args.format == "markdown": + entries = collect(default, languages, want_diff=args.diff or args.comment) + if args.comment: + print(render_comment(entries)) + elif args.format == "markdown": only = set(args.only_pages) if args.only_pages else None print(render_markdown(entries, only)) else: diff --git a/tests/test_translation_comment.py b/tests/test_translation_comment.py new file mode 100644 index 0000000..3047b08 --- /dev/null +++ b/tests/test_translation_comment.py @@ -0,0 +1,106 @@ +"""The pull request comment body. + +The comment has to describe exactly what the gate fails on. A red check whose +comment never mentions the offending page is worse than no comment: the reader +has nothing to act on. +""" + +from __future__ import annotations + +import re + +from conftest import DocsRepo + +from halos_docs_tools import translation_status + +MARKER = "" + + +def run(*argv: str) -> int: + return translation_status.main(list(argv)) + + +def body(capsys) -> str: + return capsys.readouterr().out + + +def named_in(text: str) -> set[tuple[str, str]]: + """The (locale, page) pairs the comment table lists.""" + return set(re.findall(r"^\| (\w+) \| `([^`]+)` \|", text, flags=re.M)) + + +def failing(docs_repo: DocsRepo) -> set[tuple[str, str]]: + default, languages = translation_status.configured_languages() + entries = translation_status.collect(default, languages, want_diff=False) + return {(e.language, e.page) for e in entries if e.state != "current"} + + +def test_a_stale_page_is_described_in_full(docs_repo: DocsRepo, capsys): + docs_repo.commit("docs: seed") + docs_repo.source("index.md", "# Title\n\nEnglish body, revised.\n") + assert run("--comment") == 0 + out = body(capsys) + assert "| fi | `index.md` | stale |" in out + assert docs_repo.blob("index.md") in out + assert "+English body, revised." in out + assert MARKER in out + + +def test_a_current_repository_still_produces_a_body(docs_repo: DocsRepo, capsys): + assert run("--comment") == 0 + out = body(capsys) + assert "Every translation" in out + assert MARKER in out + + +def test_the_comment_covers_everything_the_gate_fails_on(docs_repo: DocsRepo, capsys): + docs_repo.source("guide.md") + docs_repo.source("index.md", "# Title\n\nRevised.\n") + docs_repo.write("docs/fi/gone.md", "---\ntranslated_from: x\n---\n\nBody.\n") + assert run("--comment") == 0 + assert named_in(body(capsys)) == failing(docs_repo) + + +def test_only_pages_does_not_narrow_the_comment(docs_repo: DocsRepo, capsys): + """The comment answers for the gate, and the gate is repo-wide.""" + docs_repo.source("guide.md") + docs_repo.source("index.md", "# Title\n\nRevised.\n") + assert run("--comment", "--only-pages", "index.md") == 0 + assert named_in(body(capsys)) == failing(docs_repo) + + +def test_an_oversized_body_drops_the_diffs_and_points_at_the_summary( + docs_repo: DocsRepo, capsys +): + """GitHub rejects a comment body over 65536 characters with HTTP 422.""" + docs_repo.source("index.md", "# Title\n\n" + "original line\n" * 4000) + docs_repo.translation("fi", "index.md") + docs_repo.translation("sv", "index.md") + docs_repo.commit("docs: seed") + docs_repo.source("index.md", "# Title\n\n" + "replaced line\n" * 4000) + + assert run("--comment") == 0 + out = body(capsys) + assert len(out) < 65536 + assert "```diff" not in out + assert "Diffs omitted" in out + assert "job summary" in out + assert MARKER in out + assert "| fi | `index.md` | stale |" in out + + +def test_a_body_that_fits_keeps_its_diffs(docs_repo: DocsRepo, capsys): + docs_repo.commit("docs: seed") + docs_repo.source("index.md", "# Title\n\nSmall change.\n") + assert run("--comment") == 0 + out = body(capsys) + assert "```diff" in out + assert "Diffs omitted" not in out + + +def test_comment_and_check_compose(docs_repo: DocsRepo, capsys): + docs_repo.source("index.md", "# Title\n\nRevised.\n") + assert run("--comment", "--check") != 0 + out = body(capsys) + assert MARKER in out + assert "Translation gate:" in out From 8fc2cff1a6936e99d562328fa80282ca9f9a9de3 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Wed, 12 Aug 2026 16:11:41 +0300 Subject: [PATCH 7/9] fix: apply the safe review findings Ten findings with a deterministic fix and no behaviour question: --base given without a trailing slash misresolved every root-absolute link and reported them all broken; it now gets the same normalisation configured_base() already applied. english_diff is memoised on (stamp, page). Nine locales stamped against the same blob produced nine identical diffs, each paying a git cat-file, a git diff and a temporary directory -- 44.9s to 6.3s at 200 pages. _local/ leaves the tracked .gitignore; it belongs in global excludes. CLAUDE.md points at AGENTS.md so this repo's context loads. Python 3.13 joins the classifiers, which CI already tested. uv sync --locked, so a drifted lockfile fails instead of being rewritten. timeout-minutes on the CI job. README documents the exit statuses, the git requirement, and stops pinning a tag that does not exist. AGENTS.md stops describing consumers that do not consume yet. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 3 ++- .gitignore | 1 - AGENTS.md | 3 ++- CLAUDE.md | 1 + README.md | 17 ++++++++++++++++- pyproject.toml | 1 + src/halos_docs_tools/check_anchors.py | 5 +++++ src/halos_docs_tools/translation_status.py | 7 +++++++ 8 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 CLAUDE.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71ecc88..c13ef94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ permissions: jobs: check: runs-on: ubuntu-latest + timeout-minutes: 10 strategy: matrix: python-version: ["3.11", "3.12", "3.13"] @@ -19,6 +20,6 @@ jobs: - uses: astral-sh/setup-uv@v5 with: python-version: ${{ matrix.python-version }} - - run: uv sync + - run: uv sync --locked - run: uv run ruff check src/ tests/ - run: uv run pytest diff --git a/.gitignore b/.gitignore index 9d8ded0..4573447 100644 --- a/.gitignore +++ b/.gitignore @@ -21,5 +21,4 @@ htmlcov/ .ruff_cache/ # Local workflow artifacts -_local/ .worktrees/ diff --git a/AGENTS.md b/AGENTS.md index afe9a06..a8aff0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,8 @@ Six documentation checkers, packaged so that a MkDocs documentation repository can pin them and get the identical code in CI and on a developer's machine. The checkers were extracted from `hatlabs/halpi2`, where they lived as `scripts/`. -Consumers: +Intended consumers. Neither pins the package yet; both migrations are still +open, so `hatlabs/halpi2` still runs its own copies under `scripts/`: - `hatlabs/halpi2` — nine locales, full translation gate - `halos-org/docs.halos.fi` — no translations, anchor validation only diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index 4bf86aa..750f2c8 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,18 @@ Add it to a documentation repository's `pyproject.toml`, pinned to a tag: ```toml dependencies = [ - "halos-docs-tools @ git+https://github.com/halos-org/docs-tools@v0.1.0", + "halos-docs-tools @ git+https://github.com/halos-org/docs-tools@vX.Y.Z", ] ``` +Use a tag from the [releases page](https://github.com/halos-org/docs-tools/releases). `uv sync` then puts all six commands on the path. Each repository pins its own version; upgrading is a deliberate edit to that pin. +`git` must be on the path. `translation-status`, `stamp-translation` and +`check-glossary` shell out to it, and it is not something a Python dependency +can bring. + ## Commands Run them from the root of a documentation repository — they expect `docs/` and @@ -33,6 +38,16 @@ Run them from the root of a documentation repository — they expect `docs/` and | `check-typography` | check quotation pairing and unit spacing per language | | `check-anchors` | verify every internal anchor in a built site resolves | +### Exit statuses + +A workflow branches on these, so they are part of the interface. + +| Status | Meaning | +|:---|:---| +| 0 | the check passed | +| 1 | the check found problems — broken anchors, unused glossary terms, typography faults, or (with `--check`) translations that are not current | +| 2 | the check could not run: `check-anchors` was given a site directory holding no built pages | + Glossaries and per-language rules stay in the documentation repository. This package brings the checkers, not the terminology. diff --git a/pyproject.toml b/pyproject.toml index 9e2169d..1984e57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Documentation", "Topic :: Software Development :: Quality Assurance", ] diff --git a/src/halos_docs_tools/check_anchors.py b/src/halos_docs_tools/check_anchors.py index fd1ca57..ebb1662 100644 --- a/src/halos_docs_tools/check_anchors.py +++ b/src/halos_docs_tools/check_anchors.py @@ -110,7 +110,12 @@ def main(argv: list[str] | None = None) -> int: "single-page exports need this", ) args = parser.parse_args(argv) + # The same normalisation configured_base() applies. Without it, --base + # /halpi2 strips one character too few and every root-absolute link + # resolves outside the built tree, reported as breakage that is not there. base = args.base if args.base is not None else configured_base() + if not base.endswith("/"): + base += "/" ids, excluded = collect_pages(args.site, args.exclude) if not ids: diff --git a/src/halos_docs_tools/translation_status.py b/src/halos_docs_tools/translation_status.py index 43ebd68..73614f3 100644 --- a/src/halos_docs_tools/translation_status.py +++ b/src/halos_docs_tools/translation_status.py @@ -23,6 +23,7 @@ import sys import tempfile from dataclasses import dataclass +from functools import cache from pathlib import Path import yaml @@ -77,12 +78,18 @@ def stamp_of(path: Path) -> str | None: return str(value) if value else None +@cache def english_diff(stamped: str, current: Path) -> str | None: """Diff the stamped English blob against the English page as it stands now. The current page is compared from the working tree rather than as a stored object: `git hash-object` computes a hash without writing the object, so diffing two hashes would fail on the side that was never stored. + + Cached because the answer is a property of the page and the stamp, not of + the locale asking. Nine locales stamped against the same blob would + otherwise each pay a `git cat-file`, a `git diff` and a temporary + directory for identical output. """ blob = subprocess.run( ["git", "cat-file", "-p", stamped], From 8d5426854bc8f3820724132915f888ef0b8741f3 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 13 Aug 2026 14:41:56 +0300 Subject: [PATCH 8/9] fix: close every path where a checker passes without looking Five findings from the review, all verified against hatlabs/halpi2. The gate enumerated docs/ with rglob over *.md, a narrower set than mkdocs publishes. Pages with the other markdown extensions, and pages under a symlinked directory, were served in every locale carrying English text while the report said missing=0. Sources now come from os.walk with followlinks over mkdocs' full extension tuple. Markdown under docs/ that is in no configured locale cannot be classified -- mkdocs serves it under every locale untranslated -- so the check names those pages and exits 2 rather than passing over them. --check exited 0 when it found no source pages at all, and when the config declared no non-default locales. Both now exit 2: a gate that passes because it had no work reads exactly like a gate that passed. check-anchors exited 0 when the exclusion patterns matched every page. fnmatch crosses the path separator, so '*' and '*.html' both reach the whole site -- the same false green the empty-site guard above it prevents, arrived at by a pattern instead of a wrong path. check-typography and check-glossary reported success on an empty corpus, in wording identical to a real pass. Both now separate nothing-to-check, which exits 2, from nothing-met-the-thresholds, which exits 0 and says so. Unnamed, check-typography visits the locales that exist rather than printing ok for nine. git hash-object applies eol and .gitattributes filters, so the stamp was a function of git configuration: adding a text=auto attribute flipped every translation to stale at once, and a client that normalises differently produced stamps CI rejects. Both blob_hash implementations now pass --no-filters. Free today -- halpi2 has no .gitattributes and no CRLF under docs/, and all 20 English pages hash identically either way. Co-Authored-By: Claude Opus 5 (1M context) --- src/halos_docs_tools/check_anchors.py | 10 +++ src/halos_docs_tools/check_glossary.py | 25 ++++++- src/halos_docs_tools/check_typography.py | 23 +++++- src/halos_docs_tools/stamp_translation.py | 4 +- src/halos_docs_tools/translation_status.py | 81 +++++++++++++++++++--- tests/test_check_anchors.py | 11 +++ tests/test_check_glossary.py | 34 ++++++++- tests/test_check_typography.py | 30 ++++++-- tests/test_translation_gate.py | 50 +++++++++++++ tests/test_translation_status.py | 25 +++++++ 10 files changed, 276 insertions(+), 17 deletions(-) diff --git a/src/halos_docs_tools/check_anchors.py b/src/halos_docs_tools/check_anchors.py index ebb1662..2b9cdd6 100644 --- a/src/halos_docs_tools/check_anchors.py +++ b/src/halos_docs_tools/check_anchors.py @@ -127,6 +127,16 @@ def main(argv: list[str] | None = None) -> int: ) return 2 + if excluded == set(ids): + # fnmatch crosses '/', so '*' and '*.html' both reach every page. + # Silencing the whole site is the same false green the guard above + # exists to prevent, reached by a pattern rather than a wrong path. + print( + f"every built page is excluded by {args.exclude} — nothing to check.", + file=sys.stderr, + ) + return 2 + broken: list[tuple[str, str, str]] = [] checked = 0 diff --git a/src/halos_docs_tools/check_glossary.py b/src/halos_docs_tools/check_glossary.py index 6498335..63c19af 100644 --- a/src/halos_docs_tools/check_glossary.py +++ b/src/halos_docs_tools/check_glossary.py @@ -131,10 +131,24 @@ def main(argv: list[str] | None = None) -> int: args = parser.parse_args(argv) source_locale, _ = configured_languages() - english = read_pages(Path(args.docs) / source_locale) + source_root = Path(args.docs) / source_locale + english = read_pages(source_root) translated = read_pages(Path(args.docs) / args.language) glossary = Path(args.glossaries) / GLOSSARIES[args.language] + # An absent corpus and a glossary with no rows both make every comparison + # vacuous, and the success line they print is the one a correct run + # prints. Distinguish them here; a glossary whose terms are simply too + # rare to check is a different, legitimate case, reported below. + if not english.strip(): + print( + f"No source pages under {source_root} — nothing to check.", file=sys.stderr + ) + return 2 + if not terms(glossary): + print(f"{glossary} defines no terms — nothing to check.", file=sys.stderr) + return 2 + checked, unused = 0, [] for source, target in terms(glossary): wanted = [w for w in alternatives(source) if len(w) >= SHORTEST_TERM] @@ -149,6 +163,15 @@ def main(argv: list[str] | None = None) -> int: unused.append((source, target, uses)) print(f"Checked {checked} glossary terms against docs/{args.language}.") + if not checked: + # Not a failure: the corpus and the glossary both exist, and no term + # met the length and frequency thresholds. Say so, so the log does not + # read as a clean check. + print( + f"No term met the thresholds ({SHORTEST_TERM} characters, " + f"{MIN_ENGLISH_USES} uses in {source_locale}) — nothing compared." + ) + return 0 if unused: print(f"\n{len(unused)} prescribed but unused — something else took over:\n") for source, target, uses in unused: diff --git a/src/halos_docs_tools/check_typography.py b/src/halos_docs_tools/check_typography.py index f967001..6cdc86c 100644 --- a/src/halos_docs_tools/check_typography.py +++ b/src/halos_docs_tools/check_typography.py @@ -119,10 +119,23 @@ def main(argv: list[str] | None = None) -> int: ) args = parser.parse_args(argv) + # Named locales are checked whether or not they exist, so a renamed + # directory is reported. Unnamed, only the locales with pages are visited: + # printing "ok" for a locale that has no content is how a checker comes to + # report success over something it never read. + if args.languages: + languages = args.languages + else: + languages = [q for q in sorted(QUOTES) if Path("docs", q).is_dir()] + worst = 0 - for language in args.languages or sorted(QUOTES): + empty = [] + for language in languages: opening, closing = QUOTES[language] pages = sorted(Path("docs", language).rglob("*.md")) + if not pages: + empty.append(language) + continue quotes = spacing = chains = 0 problems: list[str] = [] for page in pages: @@ -167,6 +180,14 @@ def main(argv: list[str] | None = None) -> int: for problem in problems[:8]: print(problem) worst = max(worst, len(problems)) + + if empty or not languages: + named = ", ".join(empty) if empty else "any configured locale" + print( + f"Found no pages for {named} under docs/ — nothing to check.", + file=sys.stderr, + ) + return 2 return 1 if worst else 0 diff --git a/src/halos_docs_tools/stamp_translation.py b/src/halos_docs_tools/stamp_translation.py index b32c0ce..bcd58f0 100644 --- a/src/halos_docs_tools/stamp_translation.py +++ b/src/halos_docs_tools/stamp_translation.py @@ -37,7 +37,9 @@ def english_source(translation: Path, default: str) -> Path: def blob_hash(path: Path) -> str: return subprocess.run( - ["git", "hash-object", str(path)], + # --no-filters, matching translation_status.blob_hash. The two must + # agree exactly: one writes the stamp and the other reads it. + ["git", "hash-object", "--no-filters", str(path)], capture_output=True, text=True, check=True, diff --git a/src/halos_docs_tools/translation_status.py b/src/halos_docs_tools/translation_status.py index 73614f3..b43cb72 100644 --- a/src/halos_docs_tools/translation_status.py +++ b/src/halos_docs_tools/translation_status.py @@ -19,6 +19,7 @@ from __future__ import annotations import argparse +import os import subprocess import sys import tempfile @@ -30,6 +31,10 @@ DOCS = Path("docs") STAMP_KEY = "translated_from" +# What mkdocs treats as a page. Enumerating only *.md leaves the rest published +# in every locale carrying the default language's text, with nothing to report +# them: they are not missing translations, they are pages nobody looked at. +MARKDOWN = (".md", ".markdown", ".mdown", ".mkdn", ".mkd") # GitHub rejects a comment body over 65536 characters with HTTP 422. A wide # change produces a report far beyond that, so the diffs come out below this # and the reader is sent to the job summary for them. @@ -58,7 +63,11 @@ def configured_languages() -> tuple[str, list[str]]: def blob_hash(path: Path) -> str: return subprocess.run( - ["git", "hash-object", str(path)], + # --no-filters: without it the hash is of the content after eol and + # .gitattributes filtering, so the stamp moves when repository or + # client configuration changes and no page does. Adding `* text=auto` + # would flip every translation to stale at once. + ["git", "hash-object", "--no-filters", str(path)], capture_output=True, text=True, check=True, @@ -124,8 +133,37 @@ class Entry: diff: str | None = None +def pages_under(root: Path) -> list[Path]: + """Every markdown page mkdocs would publish from this directory. + + os.walk with followlinks, not rglob: mkdocs walks the docs tree following + symlinks, so a linked directory of shared pages is built and served. rglob + does not descend into one, which would leave those pages unexamined while + the report showed nothing wrong. + """ + found: list[Path] = [] + for directory, _, names in os.walk(root, followlinks=True): + found += [Path(directory) / name for name in names if name.endswith(MARKDOWN)] + return sorted(found) + + +def unclassified_pages(default: str, languages: list[str]) -> list[Path]: + """Markdown under docs/ that belongs to no configured locale. + + mkdocs-static-i18n serves such a page under every locale, untranslated. + Whether it should be translated is a question about the page, which this + tool cannot answer -- so it reports them rather than passing over them. + """ + locales = {default, *languages} + return [ + page + for page in pages_under(DOCS) + if page.relative_to(DOCS).parts[0] not in locales + ] + + def collect(default: str, languages: list[str], want_diff: bool) -> list[Entry]: - sources = sorted(p for p in (DOCS / default).rglob("*.md")) + sources = pages_under(DOCS / default) entries: list[Entry] = [] for source in sources: relative = source.relative_to(DOCS / default) @@ -148,7 +186,7 @@ def collect(default: str, languages: list[str], want_diff: bool) -> list[Entry]: # because that walks the sources. It is still a page being served. for language in languages: root = DOCS / language - for translation in sorted(root.rglob("*.md")): + for translation in pages_under(root): if not (DOCS / default / translation.relative_to(root)).exists(): entries.append( Entry(language, str(translation.relative_to(root)), "orphaned", "") @@ -298,7 +336,9 @@ def main(argv: list[str] | None = None) -> int: default, languages = configured_languages() if not languages: print("No translation languages configured.") - return 0 + # Reporting nothing is fine. Gating on nothing is not: the check would + # pass because it had no work, which reads exactly like passing. + return 2 if args.check else 0 entries = collect(default, languages, want_diff=args.diff or args.comment) if args.comment: @@ -309,10 +349,35 @@ def main(argv: list[str] | None = None) -> int: else: print(render_text(entries)) - behind = [e for e in entries if e.state != "current"] - if args.check and behind: - print(render_failure(behind)) - return 1 + if args.check: + # Exit 2 for "the check could not run over everything", distinct from + # 1 for "the check found stale translations". A gate that reports + # success over content it never examined is worse than no gate. + if not pages_under(DOCS / default): + print( + f"\nFound no source pages under {DOCS / default}. Nothing was checked." + ) + return 2 + stray = unclassified_pages(default, languages) + if stray: + print( + "\nThe check cannot classify these pages: they are under " + f"{DOCS} but in none of the configured locales " + f"({', '.join(sorted({default, *languages}))}), and mkdocs " + "serves such a page under every locale untranslated.\n" + ) + for page in stray: + print(f" {page}") + print( + "\nMove each one into a locale directory, or exclude it from " + "the documentation tree." + ) + return 2 + + behind = [e for e in entries if e.state != "current"] + if behind: + print(render_failure(behind)) + return 1 return 0 diff --git a/tests/test_check_anchors.py b/tests/test_check_anchors.py index 28afc6a..a045b30 100644 --- a/tests/test_check_anchors.py +++ b/tests/test_check_anchors.py @@ -125,3 +125,14 @@ def test_root_absolute_link_outside_the_base_is_not_ours(tmp_path: Path, capsys) site = tmp_path / "site" page(site, "index.html", 'go') assert run(str(site), "--base", "/halpi2/") == 0 + + +def test_excluding_every_page_is_not_a_pass(tmp_path: Path, capsys): + """fnmatch crosses '/', so a broad pattern can silence the whole site.""" + site = tmp_path / "site" + page(site, "index.html", 'go') + page(site, "sub/deep.html", 'go') + assert run(str(site)) == 1 + assert run(str(site), "--exclude", "*") == 2 + assert "every built page is excluded" in capsys.readouterr().err + assert run(str(site), "--exclude", "*.html") == 2 diff --git a/tests/test_check_glossary.py b/tests/test_check_glossary.py index 583dcad..0946043 100644 --- a/tests/test_check_glossary.py +++ b/tests/test_check_glossary.py @@ -87,11 +87,31 @@ def test_alternatives_separated_by_a_slash_each_satisfy_the_row( def test_header_and_separator_rows_are_not_terms(docs_repo: DocsRepo, capsys): + glossary(docs_repo, ("power supply", "virtalähde")) + docs_repo.source("index.md", "The power supply is fine. The power supply works.\n") + docs_repo.write("docs/fi/index.md", "Virtalähde on kunnossa.\n") + assert run("fi") == 0 + # One data row, not three: the header and the alignment row are markup. + assert "Checked 1 glossary terms" in capsys.readouterr().out + + +def test_a_glossary_with_no_data_rows_is_not_a_pass(docs_repo: DocsRepo, capsys): glossary(docs_repo) docs_repo.source("index.md", "Nothing here.\n") docs_repo.write("docs/fi/index.md", "Ei mitään.\n") + assert run("fi") == 2 + assert "defines no terms" in capsys.readouterr().err + + +def test_a_corpus_where_no_term_meets_the_thresholds_still_passes( + docs_repo: DocsRepo, capsys +): + """A quiet run is not a broken one, but it must say which it was.""" + glossary(docs_repo, ("power supply", "virtalähde")) + docs_repo.source("index.md", "The power supply is fine.\n") + docs_repo.write("docs/fi/index.md", "Teholähde on kunnossa.\n") assert run("fi") == 0 - assert "Checked 0 glossary terms" in capsys.readouterr().out + assert "No term met the thresholds" in capsys.readouterr().out def test_source_locale_comes_from_mkdocs_not_a_hard_coded_en( @@ -120,3 +140,15 @@ def test_source_locale_comes_from_mkdocs_not_a_hard_coded_en( docs_repo.write("docs/fi/index.md", "Teholähde on kunnossa.\n") assert run("fi") == 1 assert "prescribed but unused" in capsys.readouterr().out + + +def test_checking_no_terms_at_all_is_not_a_pass(docs_repo: DocsRepo, capsys): + """Exit 0 must mean "checked and passed", never "looked at nothing". + + A renamed source directory would otherwise print the same success line a + correct run prints, with nothing in the log to tell them apart. + """ + glossary(docs_repo, ("power supply", "virtalähde")) + (docs_repo.root / "docs/en").rename(docs_repo.root / "docs/english") + assert run("fi") == 2 + assert "No source pages" in capsys.readouterr().err diff --git a/tests/test_check_typography.py b/tests/test_check_typography.py index 843fced..744500f 100644 --- a/tests/test_check_typography.py +++ b/tests/test_check_typography.py @@ -116,9 +116,29 @@ def test_filenames_are_not_read_as_compounds(docs_repo: DocsRepo, capsys): assert run("fi") == 0 -def test_no_arguments_checks_every_configured_language(docs_repo: DocsRepo, capsys): - docs_repo.write("docs/nb/index.md", "Se »her« i teksten.\n") - assert run() == 1 +def test_a_named_locale_with_no_pages_is_not_a_pass(docs_repo: DocsRepo, capsys): + """A renamed locale directory would otherwise silence the checker.""" + import shutil + + shutil.rmtree(docs_repo.root / "docs/fi") + assert run("fi") == 2 + assert "no pages" in capsys.readouterr().err + + +def test_no_arguments_checks_the_locales_that_exist(docs_repo: DocsRepo, capsys): + """The fixture has fi and sv on disk; the other seven rules have nothing.""" + docs_repo.write("docs/nb/index.md", "Se «her» i teksten.\n") + assert run() == 0 out = capsys.readouterr().out - for language in ("fi", "fr", "de", "sv", "es", "it", "nl", "nb", "da"): - assert f"{language}: " in out + for present in ("fi", "sv", "nb"): + assert f"{present}: " in out + assert "de: " not in out + + +def test_no_locale_directory_at_all_is_not_a_pass(docs_repo: DocsRepo, capsys): + import shutil + + for locale in ("fi", "sv"): + shutil.rmtree(docs_repo.root / f"docs/{locale}") + assert run() == 2 + assert "no pages" in capsys.readouterr().err diff --git a/tests/test_translation_gate.py b/tests/test_translation_gate.py index 6285140..369428f 100644 --- a/tests/test_translation_gate.py +++ b/tests/test_translation_gate.py @@ -82,3 +82,53 @@ def test_the_failure_names_entries_the_filtered_report_omitted( assert run("--check", "--format", "markdown", "--only-pages", "index.md") != 0 out = capsys.readouterr().out assert "fi/guide.md" in out + + +def test_a_page_with_another_markdown_extension_is_checked(docs_repo: DocsRepo): + """mkdocs publishes .markdown, .mdown, .mkdn and .mkd as well as .md. + + A source page the checker does not enumerate is served in every locale + with the default language's content while the gate reports missing=0. + """ + docs_repo.write("docs/en/guide.markdown", "# Guide\n\nBody.\n") + assert run("--check") != 0 + + +def test_a_page_under_a_symlinked_directory_is_checked(docs_repo: DocsRepo): + """mkdocs walks the docs tree with followlinks=True; rglob does not.""" + shared = docs_repo.root / "shared_pages" + shared.mkdir() + (shared / "wiring.md").write_text("# Wiring\n\nBody.\n", encoding="utf-8") + (docs_repo.root / "docs/en/shared").symlink_to(shared) + assert run("--check") != 0 + + +def test_markdown_outside_every_configured_locale_stops_the_check( + docs_repo: DocsRepo, capsys +): + """mkdocs-static-i18n serves a root-level page under every locale. + + The checker cannot tell whether such a page needs translating, so it says + so rather than passing over it. + """ + docs_repo.write("docs/safety.md", "# Safety\n\nBody.\n") + assert run("--check") == 2 + out = capsys.readouterr().out + assert "docs/safety.md" in out + assert "cannot classify" in out + + +def test_no_source_pages_is_not_a_pass(docs_repo: DocsRepo, capsys): + """A gate that fails open on a misconfiguration is the worst outcome.""" + for page in (docs_repo.root / "docs/en").rglob("*.md"): + page.unlink() + assert run("--check") == 2 + assert "no source pages" in capsys.readouterr().out + + +def test_no_configured_locales_is_not_a_pass_under_check(docs_repo: DocsRepo, capsys): + from conftest import MKDOCS_ONLY_DEFAULT + + docs_repo.write("mkdocs.yml", MKDOCS_ONLY_DEFAULT) + assert run("--check") == 2 + assert "No translation languages configured." in capsys.readouterr().out diff --git a/tests/test_translation_status.py b/tests/test_translation_status.py index 09aaf3c..c9665a4 100644 --- a/tests/test_translation_status.py +++ b/tests/test_translation_status.py @@ -102,3 +102,28 @@ def test_text_format_lists_every_page_that_is_not_current(docs_repo: DocsRepo, c out = capsys.readouterr().out assert "missing guide.md" in out assert "current=1" in out + + +def test_the_stamp_does_not_move_when_gitattributes_changes(docs_repo: DocsRepo): + """The stamp must be a function of the file bytes, not of git config. + + `git hash-object` applies eol and .gitattributes filters by default, so + adding `* text=auto` -- housekeeping that touches no page -- would flip + every translation to stale at once, and a contributor whose client + normalises differently would produce a stamp CI rejects. + """ + (docs_repo.root / "docs/en/crlf.md").write_bytes(b"# Title\r\n\r\nBody.\r\n") + docs_repo.translation("fi", "crlf.md") + docs_repo.translation("sv", "crlf.md") + assert states(docs_repo)[("fi", "crlf.md")] == "current" + + docs_repo.write(".gitattributes", "* text=auto\n") + assert states(docs_repo)[("fi", "crlf.md")] == "current" + + +def test_the_stamp_does_not_move_under_core_autocrlf(docs_repo: DocsRepo): + (docs_repo.root / "docs/en/crlf.md").write_bytes(b"# Title\r\n\r\nBody.\r\n") + docs_repo.git("config", "core.autocrlf", "true") + plain = translation_status.blob_hash(docs_repo.root / "docs/en/crlf.md") + docs_repo.git("config", "core.autocrlf", "false") + assert translation_status.blob_hash(docs_repo.root / "docs/en/crlf.md") == plain From 1c092683779a5ef667195d09767f994c4b2ca0c6 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Thu, 13 Aug 2026 14:54:57 +0300 Subject: [PATCH 9/9] fix: check a root-absolute link written without the base trailing slash The base always carries a trailing slash, so an href of /halpi2#section failed startswith and resolve() returned None -- the link was skipped as somebody else's rather than checked. That is the silent pass this module exists to prevent, applied to the site root page. Found by CodeRabbit on the pull request. Co-Authored-By: Claude Opus 5 (1M context) --- src/halos_docs_tools/check_anchors.py | 11 +++++++++-- tests/test_check_anchors.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/halos_docs_tools/check_anchors.py b/src/halos_docs_tools/check_anchors.py index 2b9cdd6..eaae721 100644 --- a/src/halos_docs_tools/check_anchors.py +++ b/src/halos_docs_tools/check_anchors.py @@ -82,9 +82,16 @@ def resolve(href: str, page: str, site: str, base: str) -> str | None: if not target: return os.path.realpath(page) if target.startswith("/"): - if not target.startswith(base): + # The base always carries a trailing slash, so a link to the site root + # written without one -- /halpi2#section -- fails startswith and would + # be skipped as somebody else's. Skipping is the silent pass this + # module exists to prevent. + if target == base.rstrip("/"): + path = site + elif not target.startswith(base): return None - path = os.path.normpath(os.path.join(site, target[len(base) :])) + else: + path = os.path.normpath(os.path.join(site, target[len(base) :])) else: path = os.path.normpath(os.path.join(os.path.dirname(page), target)) if not path.endswith(".html"): diff --git a/tests/test_check_anchors.py b/tests/test_check_anchors.py index a045b30..66c6f4c 100644 --- a/tests/test_check_anchors.py +++ b/tests/test_check_anchors.py @@ -136,3 +136,21 @@ def test_excluding_every_page_is_not_a_pass(tmp_path: Path, capsys): assert run(str(site), "--exclude", "*") == 2 assert "every built page is excluded" in capsys.readouterr().err assert run(str(site), "--exclude", "*.html") == 2 + + +def test_a_root_absolute_link_to_the_site_root_is_checked(docs_repo: DocsRepo, capsys): + """`/halpi2#frag` is ours; only `/halpi2/#frag` was recognised as such. + + The base always carries a trailing slash, so the form without one failed + startswith and was skipped as somebody else's link -- the silent pass this + module's docstring warns about, applied to the site root page. + """ + docs_repo.write( + "mkdocs.yml", "site_name: Test\nsite_url: https://example.invalid/halpi2/\n" + ) + site = docs_repo.root / "site" + page(site, "index.html", '

Intro

go') + assert run(str(site)) == 1 + out = capsys.readouterr().out + assert "Checked 1 anchor links" in out + assert "no such anchor" in out