Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ jobs:
python-version: '3.12'
cache: pip
- run: pip install -r requirements-docs.txt
# An op the pages name and the checkout no longer exports is reported
# here, rather than aborting mkdocs mid-build on `Could not collect`.
- name: Check the API pages against the checkout
run: python scripts/check_api_pages.py

# Every warning fails the job except one class: mkdocstrings reports one
# per parameter in TileOPs whose docstring carries no type, and the
# docstrings of another repository are not this repository's gate.
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ jobs:
with:
python-version: '3.12'
- run: pip install -r requirements-docs.txt
# An op the pages name and the checkout no longer exports is reported
# here, rather than aborting mkdocs mid-build on `Could not collect`.
- name: Check the API pages against the checkout
run: python scripts/check_api_pages.py

# Regenerate the Benchmarks page from the latest nightly snapshot so a
# push deploy never serves a stale page. Falls back to the placeholder if
# the snapshot is unavailable.
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/render-benchmarks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ jobs:

- run: pip install -r requirements-docs.txt

# An op the pages name and the checkout no longer exports is reported
# here, rather than aborting mkdocs mid-build on `Could not collect`.
- name: Check the API pages against the checkout
run: python scripts/check_api_pages.py

- name: Render benchmark page from the newest snapshot
run: bash scripts/render_bench.sh

Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ and `design/` mirrors its `docs/design/`. Without it, mkdocstrings cannot import
| `ruff check scripts hooks.py tests` | `pyproject.toml`; `E501` off, the prose here is wrapped by hand |
| `npx stylelint "docs/assets/**/*.css"` | No duplicate selector, no `color-mix()` — an engine that cannot parse a function drops the whole declaration, so a border vanishes and an SVG `fill` paints black |
| `mkdocs build` | Fails on any warning of ours; griffe's are TileOPs' docstrings, not this repo's gate |
| `python scripts/check_api_pages.py` | Every `::: tileops.<family>.<Op>` under `docs/api/` is in that family's `__all__` in the checkout; an exported op no page names is printed, not failed |

Seven tests, and that is the intended size. `tests/fixtures/` is a trimmed
snapshot — one testcase per path the renderer takes — and `tests/golden/` the
Expand All @@ -52,6 +53,11 @@ Never edit these by hand — change what produces them.
`hooks.py` rewrites the repo-relative paths mirrored content arrives with, and
expands the single `Benchmarks` nav entry to whichever pages the renderer produced.

Which ops a `docs/api/` page names is written by hand, so it drifts as TileOPs
adds and removes ops. The deploy and the daily refresh run
`scripts/check_api_pages.py` against the checkout they just made, before mkdocs
reads it.

## Benchmarks pages

They answer one question per workload: how TileOPs compares to the fastest other
Expand Down
116 changes: 116 additions & 0 deletions scripts/check_api_pages.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Compare the ops the API pages name against the ops a TileOPs checkout exports.

The pages under `docs/api/` name their ops one by one, and every build checks
TileOPs out fresh. An op the pages name and the checkout no longer exports fails
this check: mkdocs would otherwise abort mid-build on `Could not collect`. An op
exported with no page is printed and does not fail — TileOPs adds ops on its own
schedule, and an unrelated pull request here is not the place to stop for one.

Families come from `_FAMILIES` in `tileops/__init__.py`, their ops from the
`__all__` of `tileops/<family>.py`, read with `ast`: importing a family pulls in
torch, which the docs environment does not install. A path of more than two
segments, such as `tileops.trace.api._Trace`, belongs to no family and is left
alone.

Usage:
python scripts/check_api_pages.py [--tileops TileOPs/src] [--docs docs/api]
"""

from __future__ import annotations

import argparse
import ast
import re
import sys
from pathlib import Path

IDENTIFIER = re.compile(r"^\s*::: +tileops\.(\w+)\.(\w+)\s*$", re.MULTILINE)


def _names(path: Path, variable: str) -> list[str]:
"""The strings assigned to `variable` at the top level of the module at `path`."""
if not path.is_file():
raise SystemExit(f"no such file: {path} — is there a TileOPs checkout?")
for node in ast.parse(path.read_text(encoding="utf-8"), filename=str(path)).body:
if not isinstance(node, ast.Assign):
continue
if not any(isinstance(t, ast.Name) and t.id == variable for t in node.targets):
continue
# Anything but a literal of strings is refused rather than read past: a
# name silently dropped here is an op this check would stop looking at.
if not isinstance(node.value, ast.Tuple | ast.List):
raise SystemExit(f"{path}: {variable} is not a list or tuple literal")
names = [
e.value
for e in node.value.elts
if isinstance(e, ast.Constant) and isinstance(e.value, str)
]
if len(names) != len(node.value.elts):
raise SystemExit(f"{path}: {variable} holds something other than plain strings")
return names
raise SystemExit(f"{path}: no top-level {variable}")


def exported(src: Path) -> dict[str, list[str]]:
"""Op names per family, from the TileOPs source tree at `src`."""
families = _names(src / "tileops" / "__init__.py", "_FAMILIES")
return {f: _names(src / "tileops" / f"{f}.py", "__all__") for f in families}


def documented(pages: Path) -> dict[str, dict[str, str]]:
"""Op names per family, each mapped to the page that names it."""
found: dict[str, dict[str, str]] = {}
for page in sorted(pages.glob("*.md")):
for family, op in IDENTIFIER.findall(page.read_text(encoding="utf-8")):
found.setdefault(family, {})[op] = page.name
return found


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--tileops", type=Path, default=Path("TileOPs/src"))
parser.add_argument("--docs", type=Path, default=Path("docs/api"))
args = parser.parse_args()

families = exported(args.tileops)
pages = documented(args.docs)
# Empty means the pages carry no identifier at all — a regex that stopped
# matching, not a page naming an op wrongly, which is reported below.
if not pages:
raise SystemExit(f"no `::: tileops.<family>.<Op>` identifier under {args.docs}")

uncollectable, undocumented = [], []
for family, on_page in sorted(pages.items()):
if family not in families:
uncollectable += [
f"{page}: tileops.{family}.{op} names no op family of the checkout"
for op, page in sorted(on_page.items())
]
continue
uncollectable += [
f"{on_page[op]}: tileops.{family}.{op} is not in the `__all__` of "
f"tileops/{family}.py — the build cannot collect it"
for op in sorted(set(on_page) - set(families[family]))
]
for family, ops in families.items():
undocumented += [
f"tileops.{family}.{op} is exported, and no page under {args.docs} names it"
for op in ops
if op not in pages.get(family, {})
]

for line in uncollectable + undocumented:
print(line, file=sys.stderr)
if uncollectable:
print(f"\n{len(uncollectable)} op(s) the build cannot collect", file=sys.stderr)
return 1
if undocumented:
print(f"{len(undocumented)} exported op(s) no page names")
return 0
print(f"{sum(len(ops) for ops in families.values())} ops, on the page and exported")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading