diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c13ef94 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 10 + 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 --locked + - run: uv run ruff check src/ tests/ + - run: uv run pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4573447 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# 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 +.worktrees/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a8aff0c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# 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/`. + +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 + +## 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/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/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..750f2c8 100644 --- a/README.md +++ b/README.md @@ -1 +1,133 @@ # 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@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 +`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 | + +### 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. + +### 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 +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`. + +### 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. + +### 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 + +``` +./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..1984e57 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,83 @@ +[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", + "Programming Language :: Python :: 3.13", + "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/src/halos_docs_tools/check_anchors.py b/src/halos_docs_tools/check_anchors.py new file mode 100644 index 0000000..eaae721 --- /dev/null +++ b/src/halos_docs_tools/check_anchors.py @@ -0,0 +1,185 @@ +"""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("/"): + # 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 + 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"): + 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) + # 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: + # 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 + + 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 + + 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/check_glossary.py b/src/halos_docs_tools/check_glossary.py new file mode 100644 index 0000000..63c19af --- /dev/null +++ b/src/halos_docs_tools/check_glossary.py @@ -0,0 +1,185 @@ +"""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() + 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] + 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 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: + 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..6cdc86c --- /dev/null +++ b/src/halos_docs_tools/check_typography.py @@ -0,0 +1,195 @@ +"""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) + + # 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 + 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: + 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)) + + 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 + + +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/src/halos_docs_tools/stamp_translation.py b/src/halos_docs_tools/stamp_translation.py new file mode 100644 index 0000000..bcd58f0 --- /dev/null +++ b/src/halos_docs_tools/stamp_translation.py @@ -0,0 +1,86 @@ +"""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( + # --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, + ).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..b43cb72 --- /dev/null +++ b/src/halos_docs_tools/translation_status.py @@ -0,0 +1,385 @@ +"""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 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 + +import argparse +import os +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from functools import cache +from pathlib import Path + +import yaml + +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. +COMMENT_CEILING = 60000 +COMMENT_MARKER = "" + + +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( + # --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, + ).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 + + +@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], + 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 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 = pages_under(DOCS / default) + 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 pages_under(root): + 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 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. + + 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") + 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", + ) + 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", + 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() + if not languages: + print("No translation languages configured.") + # 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: + 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: + print(render_text(entries)) + + 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 + + +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_check_anchors.py b/tests/test_check_anchors.py new file mode 100644 index 0000000..66c6f4c --- /dev/null +++ b/tests/test_check_anchors.py @@ -0,0 +1,156 @@ +"""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 + + +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 + + +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 diff --git a/tests/test_check_glossary.py b/tests/test_check_glossary.py new file mode 100644 index 0000000..0946043 --- /dev/null +++ b/tests/test_check_glossary.py @@ -0,0 +1,154 @@ +"""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, ("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 "No term met the thresholds" 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 + + +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 new file mode 100644 index 0000000..744500f --- /dev/null +++ b/tests/test_check_typography.py @@ -0,0 +1,144 @@ +"""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_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 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_entry_points.py b/tests/test_entry_points.py new file mode 100644 index 0000000..9e05dc8 --- /dev/null +++ b/tests/test_entry_points.py @@ -0,0 +1,41 @@ +"""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. +""" + +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", + "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 + + +@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_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() diff --git a/tests/test_stamp_translation.py b/tests/test_stamp_translation.py new file mode 100644 index 0000000..714856d --- /dev/null +++ b/tests/test_stamp_translation.py @@ -0,0 +1,81 @@ +"""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_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 diff --git a/tests/test_translation_gate.py b/tests/test_translation_gate.py new file mode 100644 index 0000000..369428f --- /dev/null +++ b/tests/test_translation_gate.py @@ -0,0 +1,134 @@ +"""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 + + +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 new file mode 100644 index 0000000..c9665a4 --- /dev/null +++ b/tests/test_translation_status.py @@ -0,0 +1,129 @@ +"""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 + + +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 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" }, +]