From ec0cdc04c5bb02244fb949f7363691b0e57a1c19 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 03:16:39 +0300 Subject: [PATCH 01/23] Add a docs build gate that fails on any site build warning check_docs_build.py runs the API reference site build, streams its log and reads every line. Zensical's --strict fails on broken links and anchors but not on the Griffe and mkdocstrings warnings about docstrings, which it prints as bare lines while the build exits 0; the gate fails on those too. It exits 1 on a failed build or any warning line, and 2 when the build did not run to the end: the command could not start, a signal stopped it, or it wrote no index.html. Its tests run in the Audit Script Tests job. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check_docs_build.py | 197 ++++++++++++ .github/scripts/test_check_docs_build.py | 362 +++++++++++++++++++++++ .github/workflows/test.yml | 11 +- 3 files changed, 565 insertions(+), 5 deletions(-) create mode 100755 .github/scripts/check_docs_build.py create mode 100644 .github/scripts/test_check_docs_build.py diff --git a/.github/scripts/check_docs_build.py b/.github/scripts/check_docs_build.py new file mode 100755 index 0000000..c0c426f --- /dev/null +++ b/.github/scripts/check_docs_build.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Build the API reference site and fail on any warning in the build log. + +Runs the site build, streams its log, and reads every line of it. Zensical's +--strict fails the build on Zensical's own diagnostics: a link to a page or an +anchor that does not exist, an unresolved cross-reference or link reference. It +does not count what Griffe and mkdocstrings log while they read the SDK's +docstrings: Zensical sets up no logging handler, so Python prints those records +(level WARNING and up) as bare lines, each starting with the logger's package +name, and the build still exits 0. This script fails on those lines too. + +Run it in the docs environment, where zensical is on PATH: + + uv run --locked --group docs python .github/scripts/check_docs_build.py + +When uv.lock is out of date or the docs group cannot be installed, uv stops +before the gate starts, with exit status 2: a build that did not run, as below. + +The default command passes --clean: Zensical caches rendered pages in .cache/ +and does not render an unchanged page again, so without it a second build would +not repeat the Griffe warnings of the first. Zensical has no option for the +output directory; it writes to site_dir in mkdocs.yml, which --site-dir must name. + +Contract (test.yml's docs job and docs-deploy.yml depend on it): + +* Exit 0: the build exited 0, its log holds no warning, and it wrote + /index.html. +* Exit 1: the build exited non-zero, or its log holds a warning: a Zensical + diagnostic (`Warning: ...` or `Error: ...`), a Griffe or mkdocstrings record + (`griffe: ...`, `mkdocstrings: ...`), a record printed with its level + (`WARNING ...`, as MkDocs prints them), or a Python warning + (`path:line: SomeWarning: ...`). +* Exit 2: the build did not run to the end, so there is no result: the command + could not be started, a signal stopped it, or it exited 0 without writing + /index.html (one that is still the file it was before the build does + not count). Any error in this script is also exit 2, never a pass. +* The build's stdout and stderr are streamed to stdout as they arrive. The + verdict follows on stdout, with one line per warning, a Zensical diagnostic + prefixed with the place it points at. + +Stdlib only. +""" + +from __future__ import annotations + +import argparse +import re +import shlex +import subprocess +import sys +import traceback +from pathlib import Path + +DEFAULT_COMMAND = "zensical build --strict --clean" +DEFAULT_SITE_DIR = Path("site") +RUN_IN_DOCS_ENVIRONMENT = "uv run --locked --group docs python .github/scripts/check_docs_build.py" + +# Zensical colours its output whether or not it goes to a terminal. +ANSI_ESCAPE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") + +ZENSICAL_DIAGNOSTIC = re.compile(r"^(?:Warning|Error): ") +# The first line of the box Zensical draws under a diagnostic: `╭─[ index.md:3:5 ]`. +ZENSICAL_LOCATION = re.compile(r"^╭─\[\s*(?P[^\]]+?)\s*\]$") +LOGGED_WARNINGS = ( + # mkdocstrings' logger adapters, which Griffe's loggers go through too, start + # every message with the package name of the logger. + re.compile(r"^(?:griffe|mkdocstrings|mkdocstrings_handlers|mkdocs_autorefs): "), + # A record printed with its level, as MkDocs (`WARNING - ...`) and + # logging.basicConfig (`WARNING:griffe:...`) print them. + re.compile(r"^(?:WARNING|ERROR|CRITICAL)\b"), + # A warning from the warnings module: `path:line: SomeWarning: message`. + re.compile(r"^\S.*:\d+: [A-Z]\w*Warning: "), +) + +PREFIX = "Docs build gate:" + + +def find_warnings(log: list[str]) -> list[str]: + """Return the warnings in a build log, one line each. + + Args: + log: The build's output, one line per item, colour codes included. + + Returns: + Each warning line without its colour codes, in log order. A Zensical + diagnostic is prefixed with the file, line and column it points at. + """ + lines = [ANSI_ESCAPE.sub("", line).strip() for line in log] + warnings: list[str] = [] + for index, line in enumerate(lines): + if ZENSICAL_DIAGNOSTIC.match(line): + following = lines[index + 1] if index + 1 < len(lines) else "" + location = ZENSICAL_LOCATION.match(following) + warnings.append(f"{location['where']}: {line}" if location else line) + elif any(pattern.match(line) for pattern in LOGGED_WARNINGS): + warnings.append(line) + return warnings + + +def file_version(path: Path) -> tuple[int, int] | None: + """Return the inode and modification time of a file, or None if there is none. + + A build that writes the file anew or over the old one changes one of the two. + """ + try: + status = path.stat() + except OSError: + return None + return status.st_ino, status.st_mtime_ns + + +def stream(process: subprocess.Popen[str]) -> list[str]: + """Copy the process's output to stdout line by line as it arrives, and return it.""" + log: list[str] = [] + if process.stdout is None: + msg = "the build's output is not piped" + raise RuntimeError(msg) + for line in process.stdout: + sys.stdout.write(line) + sys.stdout.flush() + log.append(line) + return log + + +def gate(command: list[str], site_dir: Path) -> int: + """Run the build, stream its log, print the verdict and return the exit status.""" + index = site_dir / "index.html" + before = file_version(index) + try: + process = subprocess.Popen( # noqa: S603 - runs the build command it was given + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + ) + except OSError as exc: + print(f"{PREFIX} the build did not run: could not start {command[0]!r}: {exc}") + print(f" Run the gate in the docs environment: {RUN_IN_DOCS_ENVIRONMENT}") + return 2 + with process: + log = stream(process) + returncode = process.wait() + + if returncode < 0: + print(f"{PREFIX} the build did not run to the end: signal {-returncode} stopped it.") + return 2 + warnings = find_warnings(log) + if returncode != 0 or warnings: + print(f"{PREFIX} failed.") + if returncode != 0: + print(f" The build exited with status {returncode}.") + if warnings: + print(f" The build log has {len(warnings)} warning or error line(s):") + for warning in warnings: + print(f" {warning}") + return 1 + written = file_version(index) + if written is None or written == before: + print( + f"{PREFIX} the build did not run to the end: it exited 0 but did not write " + f"{index}. Check that site_dir in mkdocs.yml is {site_dir}." + ) + return 2 + print(f"{PREFIX} passed. The build wrote {site_dir} and logged no warning.") + return 0 + + +def main(argv: list[str] | None = None) -> int: + """Parse the arguments and run the gate; return the exit status (0, 1 or 2).""" + parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + parser.add_argument( + "--site-dir", + type=Path, + default=DEFAULT_SITE_DIR, + help="where the build writes the site, site_dir in mkdocs.yml (default: %(default)s)", + ) + parser.add_argument( + "command", + nargs=argparse.REMAINDER, + help=f"the build command, after -- (default: {DEFAULT_COMMAND})", + ) + args = parser.parse_args(argv) + command: list[str] = args.command[1:] if args.command[:1] == ["--"] else args.command + try: + return gate(command or shlex.split(DEFAULT_COMMAND), args.site_dir) + # A gate that broke has no verdict: exit 1 would read as a docs problem with + # none listed, and exit 0 as a clean build. + except Exception: # noqa: BLE001 - mapped to exit 2 with its traceback + traceback.print_exc() + print(f"{PREFIX} the gate itself failed, so there is no result.") + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/test_check_docs_build.py b/.github/scripts/test_check_docs_build.py new file mode 100644 index 0000000..c2cd63c --- /dev/null +++ b/.github/scripts/test_check_docs_build.py @@ -0,0 +1,362 @@ +"""Contract tests for check_docs_build.py, the docs build gate. + +These pin what test.yml's docs job and docs-deploy.yml rely on: the gate fails +on a failed build and on every kind of warning line, Griffe's included, even when +the build exits 0; it exits 2, never 0 or 1, when the build did not run to the +end; and it streams the build's log as it arrives. No Zensical: each test runs a +fake build command that prints a planted log in the format Zensical 0.0.65 +prints, and writes the site or not. + +Run with: +uv run --only-dev pytest -c .github/scripts/pytest.ini .github/scripts/test_check_docs_build.py +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parent / "check_docs_build.py" + +sys.path.insert(0, str(Path(__file__).parent)) + +import check_docs_build # noqa: E402 - importable only once sys.path has its directory + +GREY = "\x1b[38;5;246m" +RESET = "\x1b[0m" + + +def zensical_diagnostic(message: str, where: str) -> str: + """A diagnostic as Zensical 0.0.65 prints it: a coloured label, then a box at `where`.""" + return ( + f"\x1b[33mWarning:{RESET} {message}\n" + f" {GREY}╭{RESET}{GREY}─{RESET}{GREY}[{RESET} {where} {GREY}]{RESET}\n" + f" {GREY}│{RESET}\n" + f" {GREY}3 │{RESET} \x1b[38;5;249m[a](missing.md#nope){RESET}\n" + f" \x1b[38;5;240m │{RESET} \x1b[33m─────┬────{RESET} \n" + f" \x1b[38;5;240m │{RESET} \x1b[33m╰──────{RESET} {message}\n" + f"{GREY}───╯{RESET}\n" + ) + + +CLEAN_LOG = "Build started\nNo issues found\nBuild finished in 0.28s\n" +GRIFFE_WARNINGS = [ + "griffe: permit/api/users.py:20: No type or annotation for parameter 'y'", + "griffe: permit/api/users.py:20: Parameter 'y' does not appear in the function signature", +] +# What Zensical prints when --strict stops a build on its diagnostics. +STRICT_ABORT = ( + "2 issues found\n" + "Traceback (most recent call last):\n" + ' File "/venv/bin/zensical", line 12, in \n' + " sys.exit(cli())\n" + "RuntimeError: Aborted because --strict flag is set\n" +) + + +def fake_build( + tmp_path: Path, + log: str = CLEAN_LOG, + *, + exit_code: int = 0, + site: str | None = "site", + out: str = "", +) -> list[str]: + """Return a build command that prints a planted log and exits with `exit_code`. + + It prints `out` to stdout and `log` to stderr, as Zensical splits its output, + and writes `site`/index.html unless `site` is None. + """ + script = tmp_path / "fake_build.py" + writes_site = ( + f"Path({site!r}).mkdir(parents=True, exist_ok=True)\n" + f"Path({site!r}, 'index.html').write_text('', encoding='utf-8')\n" + if site is not None + else "" + ) + script.write_text( + "import sys\n" + "from pathlib import Path\n" + f"sys.stdout.write({out!r})\n" + "sys.stdout.flush()\n" + f"sys.stderr.write({log!r})\n" + f"{writes_site}" + f"sys.exit({exit_code})\n", + encoding="utf-8", + ) + return [sys.executable, str(script)] + + +def run_gate(tmp_path: Path, command: list[str], *options: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 - runs the script under test with this interpreter + [sys.executable, str(SCRIPT), *options, "--", *command], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + timeout=60, + ) + + +def verdict(completed: subprocess.CompletedProcess[str]) -> str: + """The gate's own output: everything from its first line on.""" + return completed.stdout[completed.stdout.index(check_docs_build.PREFIX) :] + + +# --- passing ------------------------------------------------------------------ + + +def test_a_clean_build_passes_and_its_log_comes_first(tmp_path: Path) -> None: + completed = run_gate(tmp_path, fake_build(tmp_path, out="on stdout\n")) + assert completed.returncode == 0, completed.stdout + assert completed.stdout.index("on stdout") < completed.stdout.index("Build finished") + assert completed.stdout.index("Build finished") < completed.stdout.index("passed") + assert "The build wrote site and logged no warning" in completed.stdout + + +def test_lines_that_only_mention_warnings_pass(tmp_path: Path) -> None: + log = ( + "Build started\n" + "Copying the WARNINGS page and the griffe: examples\n" + "warnings: none\n" + "Rendered 'Warning: deprecated' admonitions\n" + "No issues found\n" + ) + completed = run_gate(tmp_path, fake_build(tmp_path, log)) + assert completed.returncode == 0, completed.stdout + + +def test_the_site_dir_option_names_where_the_site_is(tmp_path: Path) -> None: + command = fake_build(tmp_path, site="build/html") + completed = run_gate(tmp_path, command, "--site-dir", "build/html") + assert completed.returncode == 0, completed.stdout + + +def test_the_command_needs_no_double_dash(tmp_path: Path) -> None: + completed = subprocess.run( # noqa: S603 - runs the script under test with this interpreter + [sys.executable, str(SCRIPT), *fake_build(tmp_path)], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + timeout=60, + ) + assert completed.returncode == 0, completed.stdout + + +# --- exit 1: a failed build or a warning -------------------------------------- + + +def test_griffe_warnings_fail_a_build_that_exited_0(tmp_path: Path) -> None: + log = "Build started\n" + "".join(f"{line}\n" for line in GRIFFE_WARNINGS) + "No issues found\n" + completed = run_gate(tmp_path, fake_build(tmp_path, log)) + assert completed.returncode == 1 + report = verdict(completed) + assert "The build log has 2 warning or error line(s):" in report + for line in GRIFFE_WARNINGS: + assert f" {line}\n" in report + assert "exited with status" not in report + + +@pytest.mark.parametrize( + "message", ["page does not exist", "anchor does not exist", "unresolved autoref"] +) +def test_a_zensical_diagnostic_fails_and_names_its_place(tmp_path: Path, message: str) -> None: + log = "Build started\n" + zensical_diagnostic(message, "index.md:3:5") + "1 issue found\n" + completed = run_gate(tmp_path, fake_build(tmp_path, log)) + assert completed.returncode == 1 + assert f" index.md:3:5: Warning: {message}\n" in verdict(completed) + + +def test_a_strict_abort_lists_its_diagnostics_and_the_exit_status(tmp_path: Path) -> None: + log = ( + "Build started\n" + + zensical_diagnostic("anchor does not exist", "api.md:3:14") + + zensical_diagnostic("page does not exist", "index.md:3:5") + + STRICT_ABORT + ) + completed = run_gate(tmp_path, fake_build(tmp_path, log, exit_code=1)) + assert completed.returncode == 1 + report = verdict(completed) + assert "The build exited with status 1." in report + assert " api.md:3:14: Warning: anchor does not exist\n" in report + assert " index.md:3:5: Warning: page does not exist\n" in report + + +@pytest.mark.parametrize( + "line", + [ + "mkdocstrings: permit.missing could not be found", + "mkdocstrings_handlers: Could not render the signature of permit.Permit.check", + "WARNING - griffe: permit/api/users.py:20: Parameter 'y' does not appear", + "WARNING:griffe:permit/api/users.py:20: Parameter 'y' does not appear", + "ERROR - Config value 'nav': a page does not exist", + "/venv/lib/markdown/core.py:120: DeprecationWarning: 'md_globals' is deprecated", + "Error: page output escaped the site directory", + ], + ids=[ + "mkdocstrings", + "mkdocstrings handler", + "MkDocs format", + "logging format", + "error with its level", + "Python warning", + "Zensical error", + ], +) +def test_every_kind_of_warning_line_fails(tmp_path: Path, line: str) -> None: + completed = run_gate(tmp_path, fake_build(tmp_path, f"Build started\n{line}\nDone\n")) + assert completed.returncode == 1 + assert f" {line}\n" in verdict(completed) + + +def test_a_failed_build_with_no_warning_fails_with_its_status(tmp_path: Path) -> None: + completed = run_gate(tmp_path, fake_build(tmp_path, "Build started\n", exit_code=3)) + assert completed.returncode == 1 + report = verdict(completed) + assert "The build exited with status 3." in report + assert "line(s)" not in report + + +def test_a_failed_build_fails_even_without_a_site(tmp_path: Path) -> None: + completed = run_gate(tmp_path, fake_build(tmp_path, exit_code=1, site=None)) + assert completed.returncode == 1 + + +def test_output_that_is_not_utf8_is_still_read(tmp_path: Path) -> None: + script = tmp_path / "fake_build.py" + script.write_text( + "import sys\n" + "from pathlib import Path\n" + "sys.stderr.buffer.write(b'\\xff\\xfe garbage\\n' + b'griffe: permit/x.py:1: Bad\\n')\n" + "Path('site').mkdir()\n" + "Path('site', 'index.html').write_text('', encoding='utf-8')\n", + encoding="utf-8", + ) + completed = run_gate(tmp_path, [sys.executable, str(script)]) + assert completed.returncode == 1 + assert " griffe: permit/x.py:1: Bad\n" in verdict(completed) + + +# --- exit 2: the build did not run to the end ----------------------------------- + + +def test_exit_2_when_the_build_writes_no_site(tmp_path: Path) -> None: + completed = run_gate(tmp_path, fake_build(tmp_path, site=None)) + assert completed.returncode == 2 + assert "did not write site/index.html" in verdict(completed) + + +def plant_index_html(tmp_path: Path) -> None: + """Leave an index.html from an earlier build, last written an hour ago.""" + stale = tmp_path / "site" / "index.html" + stale.parent.mkdir() + stale.write_text("", encoding="utf-8") + an_hour_ago = time.time() - 3600 + os.utime(stale, (an_hour_ago, an_hour_ago)) + + +def test_exit_2_when_index_html_is_left_from_an_earlier_build(tmp_path: Path) -> None: + plant_index_html(tmp_path) + completed = run_gate(tmp_path, fake_build(tmp_path, site=None)) + assert completed.returncode == 2 + assert "did not write site/index.html" in verdict(completed) + + +def test_a_build_that_writes_over_an_earlier_index_html_passes(tmp_path: Path) -> None: + plant_index_html(tmp_path) + completed = run_gate(tmp_path, fake_build(tmp_path)) + assert completed.returncode == 0, completed.stdout + + +def test_exit_2_when_the_site_is_elsewhere(tmp_path: Path) -> None: + completed = run_gate(tmp_path, fake_build(tmp_path), "--site-dir", "public") + assert completed.returncode == 2 + assert "Check that site_dir in mkdocs.yml is public." in verdict(completed) + + +def test_exit_2_when_the_command_cannot_start(tmp_path: Path) -> None: + completed = run_gate(tmp_path, [str(tmp_path / "no-such-zensical"), "build"]) + assert completed.returncode == 2 + assert "the build did not run: could not start" in verdict(completed) + + +def test_exit_2_when_the_default_command_cannot_start(tmp_path: Path) -> None: + completed = subprocess.run( # noqa: S603 - runs the script under test with this interpreter + [sys.executable, str(SCRIPT)], + cwd=tmp_path, + env={"PATH": str(tmp_path)}, + capture_output=True, + text=True, + check=False, + timeout=60, + ) + assert completed.returncode == 2 + report = verdict(completed) + assert "could not start 'zensical'" in report + assert "Run the gate in the docs environment: uv run --locked --group docs python" in report + + +def test_exit_2_when_a_signal_stops_the_build(tmp_path: Path) -> None: + script = tmp_path / "fake_build.py" + script.write_text( + "import os\nimport signal\nprint('Build started', flush=True)\n" + "os.kill(os.getpid(), signal.SIGKILL)\n", + encoding="utf-8", + ) + completed = run_gate(tmp_path, [sys.executable, str(script)]) + assert completed.returncode == 2 + assert f"signal {int(signal.SIGKILL)} stopped it" in verdict(completed) + + +def test_exit_2_when_the_gate_itself_breaks( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + def broken(log: list[str]) -> list[str]: + raise ValueError(len(log)) + + monkeypatch.setattr(check_docs_build, "find_warnings", broken) + monkeypatch.chdir(tmp_path) + assert check_docs_build.main(["--", *fake_build(tmp_path)]) == 2 + captured = capsys.readouterr() + assert "the gate itself failed" in captured.out + assert "ValueError" in captured.err + + +# --- streaming ------------------------------------------------------------------ + + +def test_the_log_is_streamed_as_it_arrives(tmp_path: Path) -> None: + """The fake build prints a line, then waits for the test to see it before it ends.""" + seen = tmp_path / "seen" + script = tmp_path / "fake_build.py" + script.write_text( + "import sys, time\n" + "from pathlib import Path\n" + "print('first line', flush=True)\n" + "deadline = time.monotonic() + 20\n" + f"while not Path({str(seen)!r}).exists():\n" + " if time.monotonic() > deadline:\n" + " sys.exit('nobody saw the first line')\n" + " time.sleep(0.05)\n" + "Path('site').mkdir()\n" + "Path('site', 'index.html').write_text('', encoding='utf-8')\n", + encoding="utf-8", + ) + with subprocess.Popen( # noqa: S603 - runs the script under test with this interpreter + [sys.executable, str(SCRIPT), "--", sys.executable, str(script)], + cwd=tmp_path, + stdout=subprocess.PIPE, + text=True, + ) as gate: + assert gate.stdout is not None + assert gate.stdout.readline() == "first line\n" + seen.touch() + rest = gate.stdout.read() + assert gate.returncode == 0, rest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ff6becb..c4088dd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1016,17 +1016,18 @@ jobs: # fail this job through .github/scripts/pytest.ini, which turns every # warning into an error; -c reads that file rather than the SDK's # [tool.pytest] in pyproject.toml. The scripts under test are stdlib only, - # so --only-dev leaves the project uninstalled. The schema drift check's and - # the API coverage report's tests run here too: they live next to the audit - # scripts and need no more. So do the tests of the bash of the CI job, the - # job-list check and the local actions' shellcheck, which also run bash, - # jq, yq and shellcheck from the runner image. + # so --only-dev leaves the project uninstalled. The schema drift check's, + # the API coverage report's and the docs build gate's tests run here too: + # they live next to the audit scripts and need no more. So do the tests of + # the bash of the CI job, the job-list check and the local actions' + # shellcheck, which also run bash, jq, yq and shellcheck from the runner image. - name: Run CI script tests run: >- uv run --locked --only-dev pytest -c .github/scripts/pytest.ini -q .github/scripts/test_format_audit.py .github/scripts/test_check_schema_drift.py .github/scripts/test_api_coverage.py .github/scripts/test_ci_checks.py + .github/scripts/test_check_docs_build.py - name: Shellcheck the shell scripts run: shellcheck .github/scripts/audit-deps.sh scripts/generate_models.sh From 5e09604163f577011550d0775cf2580602cebf4f Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 03:17:08 +0300 Subject: [PATCH 02/23] Build the API reference site in a docs job that CI needs The docs job installs the locked docs group and builds the site through check_docs_build.py, so a pull request fails CI when the site build fails, logs a docstring warning or has a broken link. CI now needs 11 jobs. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 53 +++++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c4088dd..f4fe1e3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -769,6 +769,56 @@ jobs: print(f"Python 3.9: {len(report['findings'])} findings") PY + # The API reference site (PER-16775), built from mkdocs.yml with Zensical. The + # build runs under .github/scripts/check_docs_build.py, which fails the job when + # the build fails or logs any warning: a broken link or anchor, and the Griffe + # and mkdocstrings warnings about docstrings, which Zensical's --strict does not + # count. + docs: + name: Docs + runs-on: ubuntu-24.04 + # A build takes about a minute; the limit stops a hung one. + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version-file: "uv.lock" + python-version: "3.11.8" + enable-cache: true + # The docs group's tools are in no other job's environment. + cache-suffix: docs + + # --locked fails the job if uv.lock is out of date with pyproject.toml + # instead of silently re-resolving. + - name: Install the docs dependencies + run: uv sync --locked --group docs + + # Exit 1 is a failed build or a warning, each warning listed at the end of + # the log; exit 2 means the build did not run to the end. Both fail the job. + - name: Build the site + run: | + set -uo pipefail + set +e + uv run --no-sync python .github/scripts/check_docs_build.py + gate_exit=$? + set -e + if [ "${gate_exit}" -eq 1 ]; then + echo "::error title=Docs build::The site build failed or logged a warning." \ + "The end of the log lists them." + elif [ "${gate_exit}" -ne 0 ]; then + echo "::error title=Docs build did not run::The site build did not run to" \ + "the end, so there is no result. See the log." + fi + exit "${gate_exit}" + # These steps do what pre-commit/action v3.0.1 does, written out: its last # release pins actions/cache@v4, which targets the deprecated Node 20 # runtime, and it has had no release since. pre-commit itself comes from @@ -1189,6 +1239,7 @@ jobs: - comment - compatibility - dependency-review + - docs - migration-skill - pre-commit - pytest @@ -1202,7 +1253,7 @@ jobs: env: NEEDS: ${{ toJSON(needs) }} EVENT: ${{ github.event_name }} - EXPECTED_JOBS: 10 + EXPECTED_JOBS: 11 run: | if ! results=$(jq -r 'to_entries[] | "\(.key) \(.value.result)"' <<<"$NEEDS"); then echo "::error title=CI::Could not read the job results." From ee6ecb6955a99fe283fb1b93ba3a00ff9c7a16a0 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 03:17:30 +0300 Subject: [PATCH 03/23] Deploy the API reference site to GitHub Pages on release docs-deploy.yml runs when a release is published, except a prerelease, and when started by hand; never on a push. Its build job installs the docs group and builds the site through check_docs_build.py with the same two steps as the docs job in test.yml, which the gate's tests check, then uploads it. Only the deploy job holds pages: write and id-token: write, in the github-pages environment. The Pages actions are pinned to the commits of their release tags, and the build uses no cache. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/pytest.ini | 3 +- .github/scripts/test_check_docs_build.py | 38 +++++++- .github/workflows/docs-deploy.yml | 105 +++++++++++++++++++++++ .github/workflows/test.yml | 11 ++- 4 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/docs-deploy.yml diff --git a/.github/scripts/pytest.ini b/.github/scripts/pytest.ini index 13ef605..70628df 100644 --- a/.github/scripts/pytest.ini +++ b/.github/scripts/pytest.ini @@ -3,7 +3,8 @@ # configuration in pyproject.toml ([tool.pytest]), whose testpaths and # asyncio_mode belong to the SDK's suite. These tests need only pytest and the # standard library, though test_ci_checks.py also runs bash, jq, yq (mikefarah -# v4) and shellcheck. They warn about nothing: any warning is an error. +# v4) and shellcheck, and test_check_docs_build.py runs yq. They warn about +# nothing: any warning is an error. [pytest] # strict_config, strict_markers, strict_xfail and strict_parametrization_ids. strict = true diff --git a/.github/scripts/test_check_docs_build.py b/.github/scripts/test_check_docs_build.py index c2cd63c..bd89879 100644 --- a/.github/scripts/test_check_docs_build.py +++ b/.github/scripts/test_check_docs_build.py @@ -5,7 +5,8 @@ the build exits 0; it exits 2, never 0 or 1, when the build did not run to the end; and it streams the build's log as it arrives. No Zensical: each test runs a fake build command that prints a planted log in the format Zensical 0.0.65 -prints, and writes the site or not. +prints, and writes the site or not. The last tests read both workflows with yq +(mikefarah v4) and check that they build the site the same way, through the gate. Run with: uv run --only-dev pytest -c .github/scripts/pytest.ini .github/scripts/test_check_docs_build.py @@ -13,7 +14,9 @@ from __future__ import annotations +import json import os +import shutil import signal import subprocess import sys @@ -23,6 +26,7 @@ import pytest SCRIPT = Path(__file__).parent / "check_docs_build.py" +REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(Path(__file__).parent)) @@ -360,3 +364,35 @@ def test_the_log_is_streamed_as_it_arrives(tmp_path: Path) -> None: seen.touch() rest = gate.stdout.read() assert gate.returncode == 0, rest + + +# --- the workflows --------------------------------------------------------------- + + +def workflow_step(workflow: str, job: str, name: str) -> dict[str, object]: + yq = shutil.which("yq") + if yq is None: + pytest.fail("yq is not on PATH; this test reads the workflows with it") + completed = subprocess.run( # noqa: S603 - yq reads a workflow of this repository + [yq, "-o=json", ".", str(REPO_ROOT / ".github" / "workflows" / workflow)], + capture_output=True, + text=True, + check=True, + ) + steps = json.loads(completed.stdout)["jobs"][job]["steps"] + found = [step for step in steps if step.get("name") == name] + assert len(found) == 1, f"expected one {name!r} step in job {job!r} of {workflow}" + step: dict[str, object] = found[0] + return step + + +@pytest.mark.parametrize("name", ["Install the docs dependencies", "Build the site"]) +def test_ci_and_the_deploy_build_the_site_the_same_way(name: str) -> None: + ci = workflow_step("test.yml", "docs", name) + deploy = workflow_step("docs-deploy.yml", "build", name) + assert ci == deploy + + +def test_the_workflows_build_the_site_through_the_gate() -> None: + step = workflow_step("test.yml", "docs", "Build the site") + assert "uv run --no-sync python .github/scripts/check_docs_build.py\n" in str(step["run"]) diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 0000000..c445b6a --- /dev/null +++ b/.github/workflows/docs-deploy.yml @@ -0,0 +1,105 @@ +name: Deploy Docs + +# Builds the API reference site (PER-16775) and publishes it to GitHub Pages, at +# https://permitio.github.io/permit-python/. It runs when a release is published +# and when started by hand from the Actions tab, never on a push: the site +# documents the released SDK, not main. A prerelease does not deploy, since a +# plain `pip install permit` does not install one; a manual run deploys whatever +# ref it is started on. +# +# The build is the docs job's in test.yml, with the same install step and the same +# gate (.github/scripts/check_docs_build.py; test_check_docs_build.py keeps the +# two in step), so a release whose site has a broken link or a docstring warning +# does not deploy. The github-pages environment accepts deploys from main and +# from v* tags only. +on: + release: + types: [published] + workflow_dispatch: {} + +permissions: + contents: read + +# One run at a time. A run that is deploying is never cancelled, so Pages is +# never left with a half-finished deployment; a run started meanwhile waits, and +# only the newest waiting run goes ahead. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build + if: github.event_name != 'release' || !github.event.release.prerelease + runs-on: ubuntu-24.04 + # A build takes about a minute; the limit stops a hung one. + timeout-minutes: 15 + permissions: + contents: read # the checkout + pages: read # configure-pages reads the repository's Pages settings + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + with: + version-file: "uv.lock" + python-version: "3.11.8" + # No cache on a job whose output is published: a poisoned cache entry + # would flow straight into the site. + enable-cache: false + + - name: Install the docs dependencies + run: uv sync --locked --group docs + + # Exit 1 is a failed build or a warning, each warning listed at the end of + # the log; exit 2 means the build did not run to the end. Both fail the job. + - name: Build the site + run: | + set -uo pipefail + set +e + uv run --no-sync python .github/scripts/check_docs_build.py + gate_exit=$? + set -e + if [ "${gate_exit}" -eq 1 ]; then + echo "::error title=Docs build::The site build failed or logged a warning." \ + "The end of the log lists them." + elif [ "${gate_exit}" -ne 0 ]; then + echo "::error title=Docs build did not run::The site build did not run to" \ + "the end, so there is no result. See the log." + fi + exit "${gate_exit}" + + # Fails before the upload when Pages is not enabled for this repository or + # does not publish from GitHub Actions. + - name: Check the Pages settings + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + # site_dir in mkdocs.yml. The artifact is named github-pages, which + # deploy-pages downloads. + - name: Upload the site + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: site/ + + deploy: + name: Deploy + needs: build + runs-on: ubuntu-24.04 + # deploy-pages gives up on a deployment after 10 minutes. + timeout-minutes: 15 + # The only job that can write to Pages. It checks out nothing and runs no + # code from the repository. + permissions: + pages: write # deploy-pages creates the deployment + id-token: write # deploy-pages proves with an OIDC token that this workflow made it + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f4fe1e3..ca93b89 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -773,7 +773,9 @@ jobs: # build runs under .github/scripts/check_docs_build.py, which fails the job when # the build fails or logs any warning: a broken link or anchor, and the Griffe # and mkdocstrings warnings about docstrings, which Zensical's --strict does not - # count. + # count. docs-deploy.yml builds the site with the same two steps on release and + # publishes it to GitHub Pages (test_check_docs_build.py keeps the steps the + # same); this job only builds it. docs: name: Docs runs-on: ubuntu-24.04 @@ -1068,9 +1070,10 @@ jobs: # [tool.pytest] in pyproject.toml. The scripts under test are stdlib only, # so --only-dev leaves the project uninstalled. The schema drift check's, # the API coverage report's and the docs build gate's tests run here too: - # they live next to the audit scripts and need no more. So do the tests of - # the bash of the CI job, the job-list check and the local actions' - # shellcheck, which also run bash, jq, yq and shellcheck from the runner image. + # they live next to the audit scripts and need no more (the gate's tests + # read the workflows with yq). So do the tests of the bash of the CI job, + # the job-list check and the local actions' shellcheck, which also run + # bash, jq, yq and shellcheck from the runner image. - name: Run CI script tests run: >- uv run --locked --only-dev From aa5a2dabb0ceee391e00faa20c292174060f1e3b Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 03:17:39 +0300 Subject: [PATCH 04/23] Group the docs tools' Dependabot updates in their own PR zensical, mkdocstrings and Griffe updates go to a docs-tools group, as the lint tools do, so a release that breaks the site build does not hold up the runtime floor bumps in minor-and-patch. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0f4b1b3..18abf6c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -40,6 +40,12 @@ updates: # runtime floor bumps grouped below, so they get a PR of their own. lint-tools: patterns: ["ruff", "mypy", "typos"] + # The docs group's site builder and the API reference plugin and parser + # (PER-16775). Zensical is alpha, so any release can change what the docs + # job's gate sees; a bump that breaks the site build gets a PR of its own + # rather than holding up the runtime floor bumps. + docs-tools: + patterns: ["zensical", "mkdocstrings*", "griffe*"] minor-and-patch: update-types: ["minor", "patch"] ignore: From 20ec99f2eebfae2f72ee0534aea53e3f253356c4 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 03:30:50 +0300 Subject: [PATCH 05/23] Add a Griffe extension that shows the SDK as type checkers see it The API reference site (PER-16775) renders the SDK with mkdocstrings, which reads it through Griffe. scripts/docs_griffe_extension.py adjusts what Griffe reads: - A name bound in both branches of `if TYPE_CHECKING: ... else: ...` is documented as the `if` branch binds it. Each blocking API class is then the stub class from permit/_sync_types.pyi, with blocking signatures, matched by the full path of the name: permit.pdp_api's SyncRoleAssignmentsApi is the stub's SyncPdpRoleAssignmentsApi, not the REST API's SyncRoleAssignmentsApi. - A function or class decorated with permit.utils.deprecation.deprecated or a PEP 702 deprecated gets a `deprecated` label, and its docstring ends with the decorator's message. Stub methods take the deprecation of the async method they are generated from. - A pydantic field's Field(description=...) becomes its docstring, and its default becomes its value. griffelib joins the dev group, so mypy checks the extension and its offline tests run with the suite. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 7 + scripts/docs_griffe_extension.py | 258 ++++++++++++++++++++++++++++ tests/test_docs_griffe_extension.py | 171 ++++++++++++++++++ uv.lock | 22 ++- 4 files changed, 454 insertions(+), 4 deletions(-) create mode 100644 scripts/docs_griffe_extension.py create mode 100644 tests/test_docs_griffe_extension.py diff --git a/pyproject.toml b/pyproject.toml index 246fea7..693b2f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,10 @@ dev = [ # Imported directly by the offline tests, which evaluate the version markers # in [project].dependencies the way an installer does. "packaging==26.3", + # The API reference site's Griffe extension, scripts/docs_griffe_extension.py, imports + # it, and so do its tests in tests/test_docs_griffe_extension.py, which run with the + # offline suite; mypy checks both. + "griffelib==2.3.0", "pre-commit==4.6.2", # 9.x rather than 8.x: the old 8.3.0 floor is affected by CVE-2025-71176 # (insecure temporary directory handling), fixed in 9.0.3. Caught by this @@ -296,6 +300,9 @@ runtime-evaluated-base-classes = ["pydantic.BaseModel", "pydantic.v1.BaseModel"] "INP001", # standalone scripts run by path, not an importable package ] ".github/scripts/test_*.py" = ["S101", "PLR2004", "D1"] +# Griffe calls each extension hook with keyword arguments the hook does not use, which +# the hook takes as **kwargs. +"scripts/docs_griffe_extension.py" = ["ARG002"] # The migration skill's tests are run by path with their own pytest.ini, like # the CI scripts' tests, not imported as a package. "skills/tests/*.py" = ["INP001"] diff --git a/scripts/docs_griffe_extension.py b/scripts/docs_griffe_extension.py new file mode 100644 index 0000000..783e2fc --- /dev/null +++ b/scripts/docs_griffe_extension.py @@ -0,0 +1,258 @@ +"""The Griffe extension the API reference site loads (mkdocs.yml). + +Griffe reads the SDK's source without importing it, and mkdocstrings renders what it reads. +This extension makes the site show what a type checker sees: + +- Names bound in both branches of an ``if TYPE_CHECKING: ... else: ...`` block. Griffe + keeps the ``else`` branch's binding, the runtime one; type checkers read the ``if`` + branch, so that is the one documented. This is how the blocking classes get their + blocking signatures: the SDK defines each at runtime, in the ``else`` branch, as a + subclass of an async class that a metaclass makes blocking, and its ``if`` branch binds + the name to a class of the generated stub, ``permit/_sync_types.pyi``. The match is by + the full path of the name: ``permit.pdp_api.pdp_api_client`` and + ``permit.api.sync_api_client`` both have a ``SyncRoleAssignmentsApi``, bound to different + stub classes. +- Deprecations. A function or class decorated with ``permit.utils.deprecation.deprecated`` + or a PEP 702 ``deprecated`` gets the ``deprecated`` label, and its docstring ends with the + decorator's message, which names the replacement and the release that removes it. The + stub has no decorators, so each stub method takes the deprecation of the async method it + is generated from. +- pydantic v1 models. The ``description`` of a field's ``Field(...)`` becomes the field's + docstring, and its default the field's value. + +The async client needs nothing: Griffe labels every coroutine function ``async``. +""" + +from __future__ import annotations + +import ast +import importlib +import inspect +from typing import Any + +import griffe + +_TYPE_CHECKING_TESTS = frozenset({"TYPE_CHECKING", "typing.TYPE_CHECKING"}) +_DEPRECATION_DECORATORS = frozenset( + { + "permit.utils.deprecation.deprecated", + "typing_extensions.deprecated", + "warnings.deprecated", + } +) +_PYDANTIC_FIELDS = frozenset({"pydantic.Field", "pydantic.v1.Field"}) + + +def _in_type_checking_branch(node: ast.AST) -> bool: + """Whether ``node`` is a statement of the ``if`` branch of an ``if TYPE_CHECKING:``.""" + parent = getattr(node, "parent", None) + return ( + isinstance(parent, ast.If) + and node in parent.body + and ast.unparse(parent.test) in _TYPE_CHECKING_TESTS + ) + + +def _evaluate_message(argument: ast.expr, module_path: str) -> str: + """Evaluate a deprecation decorator's message argument. + + Args: + argument: The argument: a string literal, or a call with literal arguments to a + function of the decorated object's module. + module_path: The path of that module, imported to call the function. + + Returns: + The message. + + Raises: + ValueError: If the argument has another shape, or does not evaluate to a string. + """ + message: object = None + if isinstance(argument, ast.Constant): + message = argument.value + elif ( + isinstance(argument, ast.Call) + and isinstance(argument.func, ast.Name) + and not argument.keywords + ): + function = getattr(importlib.import_module(module_path), argument.func.id) + message = function(*(ast.literal_eval(arg) for arg in argument.args)) + if not isinstance(message, str): + msg = ( + f"Cannot read the deprecation message {ast.unparse(argument)!r} in {module_path}: " + "pass a string literal, or a call with literal arguments to a function of the " + "same module, or teach scripts/docs_griffe_extension.py the new form." + ) + raise ValueError(msg) # noqa: TRY004 - a wrong value in the source, not a wrong type + return message + + +def _mark_deprecated( + obj: griffe.Object, message: str, parser: griffe.DocstringStyle | griffe.Parser | None +) -> None: + """Label ``obj`` deprecated and end its docstring with ``message``, as a warning box. + + Args: + obj: The deprecated function or class. + message: The deprecation message. + parser: The docstring parser for a docstring this creates, the one the loader uses: + it is what turns the warning section into a box. + """ + obj.deprecated = message + obj.labels.add("deprecated") + notice = f"Warning: Deprecated\n {message}" + if obj.docstring is None: + obj.docstring = griffe.Docstring(notice, parent=obj, parser=parser) + else: + obj.docstring.value = f"{obj.docstring.value}\n\n{notice}" + + +def _document_pydantic_field( + attr: griffe.Attribute, field: griffe.ExprCall, call: ast.Call +) -> None: + """Document a pydantic ``name: type = Field(...)`` as the field it declares. + + The ``description`` becomes the docstring, and the default becomes the value, so the + page shows ``name: type = default`` rather than the whole ``Field(...)`` call. + + Args: + attr: The field. + field: The ``Field(...)`` call, as Griffe reads it. + call: The same call, as parsed, for the literal value of its description. + """ + for keyword in call.keywords: + if keyword.arg == "description" and attr.docstring is None: + # No parser: the description is prose, not a Google-style docstring. + attr.docstring = griffe.Docstring( + inspect.cleandoc(ast.literal_eval(keyword.value)), + lineno=call.lineno, + endlineno=call.end_lineno, + parent=attr, + ) + keywords = { + arg.name: arg.value for arg in field.arguments if isinstance(arg, griffe.ExprKeyword) + } + positional = [arg for arg in field.arguments if not isinstance(arg, griffe.ExprKeyword)] + if "default" in keywords: + attr.value = keywords["default"] + elif "default_factory" in keywords: + factory = keywords["default_factory"] + attr.value = griffe.ExprCall(factory, []) if isinstance(factory, griffe.Expr) else None + elif positional and str(positional[0]) != "...": + attr.value = positional[0] + else: + attr.value = None + + +class PermitDocs(griffe.Extension): + """Make the API reference show the SDK as type checkers see it.""" + + def __init__(self) -> None: + super().__init__() + # Full path of a name -> what the `if TYPE_CHECKING:` branch binds it to. + self._checked_bindings: dict[str, griffe.Alias | griffe.Attribute] = {} + # Full path of a stub class -> full path of the async class it is generated from. + self._async_origins: dict[str, str] = {} + + def on_alias_instance( + self, *, alias: griffe.Alias, node: ast.AST | griffe.ObjectNode, **kwargs: Any + ) -> None: + """Record an import in an ``if TYPE_CHECKING:`` branch.""" + if isinstance(node, ast.AST) and _in_type_checking_branch(node): + self._checked_bindings[alias.path] = alias + + def on_attribute_instance( + self, *, node: ast.AST | griffe.ObjectNode, attr: griffe.Attribute, **kwargs: Any + ) -> None: + """Record an assignment in an ``if TYPE_CHECKING:`` branch; document pydantic fields.""" + if not isinstance(node, ast.AST): + return + if _in_type_checking_branch(node): + if isinstance(attr.value, griffe.ExprName): + # `Name = OtherName` makes Name another name for the class OtherName. + self._checked_bindings[attr.path] = griffe.Alias( + attr.name, + attr.value.canonical_path, + lineno=attr.lineno, + endlineno=attr.endlineno, + ) + else: + self._checked_bindings[attr.path] = attr + call = getattr(node, "value", None) + field = attr.value + if ( + isinstance(call, ast.Call) + and isinstance(field, griffe.ExprCall) + and field.function.canonical_path in _PYDANTIC_FIELDS + ): + _document_pydantic_field(attr, field, call) + + def on_function_instance( + self, + *, + node: ast.AST | griffe.ObjectNode, + func: griffe.Function, + agent: griffe.Visitor | griffe.Inspector, + **kwargs: Any, + ) -> None: + """Label a function its decorator marks as deprecated.""" + self._read_deprecation(node, func, agent) + + def on_class_instance( + self, + *, + node: ast.AST | griffe.ObjectNode, + cls: griffe.Class, + agent: griffe.Visitor | griffe.Inspector, + **kwargs: Any, + ) -> None: + """Label a class its decorator marks as deprecated.""" + self._read_deprecation(node, cls, agent) + + def on_module_members(self, *, mod: griffe.Module, **kwargs: Any) -> None: + """Put back what the ``if TYPE_CHECKING:`` branch binds where the runtime rebinds it. + + Only a runtime class or assignment is replaced. A runtime import, such as the + pydantic imports every model module makes per pydantic major, stays. + """ + for path, checked in self._checked_bindings.items(): + parent_path, name = path.rsplit(".", 1) + runtime = mod.members.get(name) if parent_path == mod.path else None + if runtime is None or runtime is checked or runtime.is_alias: + continue + if isinstance(runtime, griffe.Class) and isinstance(checked, griffe.Alias): + base = runtime.bases[0] if runtime.bases else None + if isinstance(base, griffe.Expr): + self._async_origins[checked.target_path] = base.canonical_path + mod.set_member(name, checked) + + def on_package(self, *, pkg: griffe.Module, loader: griffe.GriffeLoader, **kwargs: Any) -> None: + """Give each stub method the deprecation of the async method it is generated from.""" + collection = pkg.modules_collection + for stub_path, async_path in self._async_origins.items(): + stub_class = collection.get_member(stub_path) + async_class = collection.get_member(async_path) + for name, method in stub_class.members.items(): + origin = async_class.all_members.get(name) + if origin is None or method.is_alias: + continue + message = origin.final_target.deprecated if origin.is_alias else origin.deprecated + if isinstance(message, str): + _mark_deprecated(method, message, loader.docstring_parser) + + @staticmethod + def _read_deprecation( + node: ast.AST | griffe.ObjectNode, + obj: griffe.Function | griffe.Class, + agent: griffe.Visitor | griffe.Inspector, + ) -> None: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + return + for decorator, decorator_node in zip(obj.decorators, node.decorator_list, strict=True): + if ( + decorator.callable_path in _DEPRECATION_DECORATORS + and isinstance(decorator_node, ast.Call) + and decorator_node.args + ): + message = _evaluate_message(decorator_node.args[0], obj.module.path) + _mark_deprecated(obj, message, agent.docstring_parser) diff --git a/tests/test_docs_griffe_extension.py b/tests/test_docs_griffe_extension.py new file mode 100644 index 0000000..174cf44 --- /dev/null +++ b/tests/test_docs_griffe_extension.py @@ -0,0 +1,171 @@ +"""The API reference site's Griffe extension, scripts/docs_griffe_extension.py. + +Griffe reads the SDK the way the site's build does, statically and with the extension, and +these tests check what the site would show: blocking signatures for the blocking classes, +taken from the stub by full path, deprecation labels and pydantic field descriptions. +""" + +from collections.abc import Iterator +from pathlib import Path + +import griffe +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +EXTENSION = REPO_ROOT / "scripts" / "docs_griffe_extension.py" + + +@pytest.fixture(scope="module") +def permit_package() -> griffe.Module: + """The permit package as the site's build reads it.""" + package = griffe.load( + "permit", + search_paths=[REPO_ROOT], + extensions=griffe.load_extensions(str(EXTENSION)), + docstring_parser="google", + allow_inspection=False, + ) + assert isinstance(package, griffe.Module) + return package + + +def walk_modules(module: griffe.Module) -> Iterator[griffe.Module]: + yield module + for submodule in module.modules.values(): + yield from walk_modules(submodule) + + +def test_blocking_rest_api_classes_are_the_stub_classes(permit_package: griffe.Module) -> None: + module = permit_package["api.sync_api_client"] + blocking = [name for name in module.members if name.startswith("Sync") and "Api" in name] + assert "SyncRolesApi" in blocking + assert "SyncPermitApiClient" in blocking + + for name in blocking: + member = module.members[name] + if name == "SyncPermitApiClient": + assert not member.is_alias + continue + assert member.is_alias, name + assert member.final_target.path == f"permit._sync_types.{name}" + + roles_list = module["SyncRolesApi"].members["list"] + assert "async" not in roles_list.labels + assert str(roles_list.returns) == "list[RoleRead]" + + +def test_the_async_client_methods_are_labelled_async(permit_package: griffe.Module) -> None: + assert "async" in permit_package["api.roles.RolesApi.list"].labels + assert "async" in permit_package["permit.Permit.check"].labels + assert "async" not in permit_package["sync.Permit.check"].labels + + +def test_pdp_role_assignments_are_matched_by_full_path_not_by_name( + permit_package: griffe.Module, +) -> None: + # permit.pdp_api and permit.api both have a SyncRoleAssignmentsApi. Matched by name, + # the PDP one would show the REST API's methods. + pdp = permit_package["pdp_api.pdp_api_client.SyncRoleAssignmentsApi"] + rest = permit_package["api.sync_api_client.SyncRoleAssignmentsApi"] + + assert pdp.final_target.path == "permit._sync_types.SyncPdpRoleAssignmentsApi" + assert rest.final_target.path == "permit._sync_types.SyncRoleAssignmentsApi" + assert set(pdp.members) == {"list"} + assert {"assign", "unassign", "bulk_assign"} <= set(rest.members) + pdp_list = [parameter.name for parameter in pdp.members["list"].parameters] + assert "resource_instance_key" in pdp_list + assert "async" not in pdp.members["list"].labels + + +def test_no_class_made_blocking_at_runtime_is_documented(permit_package: griffe.Module) -> None: + """A class with the SyncClass metaclass has async methods in the source.""" + runtime_blocking = [ + cls.path + for module in walk_modules(permit_package) + for cls in module.classes.values() + if not cls.is_alias and "metaclass" in cls.keywords + ] + assert runtime_blocking == [] + + +def test_names_bound_in_both_branches_show_the_type_checking_one( + permit_package: griffe.Module, +) -> None: + assert str(permit_package["enforcement.enforcer.User"].value) == "dict[str, Any] | str" + model_input = permit_package["utils.model_input.ModelInput"] + assert model_input.is_attribute + assert str(model_input.value) == "_Model | dict[str, Any]" + # A runtime import is left alone: the models import pydantic per major. + assert permit_package["api.models.EmailStr"].is_alias + + +@pytest.mark.parametrize( + ("path", "replacement"), + [ + ("api.deprecated.DeprecatedApi.get_user", "use permit.api.users.get() instead."), + ("api.sync_api_client.SyncDeprecatedApi.get_user", "use permit.api.users.get() instead."), + ("api.tenants.TenantsApi.add_user", "use permit.api.tenants.create_user() instead."), + ("api.sync_api_client.SyncTenantsApi.add_user", "use permit.api.tenants.create_user()"), + ("exceptions.PermitException", "catch PermitConnectionError instead"), + ], +) +def test_deprecations_are_labelled_with_their_message( + permit_package: griffe.Module, path: str, replacement: str +) -> None: + obj = permit_package[path] + assert "deprecated" in obj.labels + sections = obj.docstring.parsed + notice = sections[-1] + assert notice.kind is griffe.DocstringSectionKind.admonition + assert notice.title == "Deprecated" + assert "will be removed in permit 4.0" in notice.value.contents + assert replacement in notice.value.contents + + +def test_methods_that_are_not_deprecated_carry_no_label(permit_package: griffe.Module) -> None: + assert "deprecated" not in permit_package["api.tenants.TenantsApi.create_user"].labels + stub = permit_package["api.sync_api_client.SyncTenantsApi"] + assert "deprecated" not in stub.members["create_user"].labels + + +def test_pydantic_fields_are_documented_from_their_field_call( + permit_package: griffe.Module, +) -> None: + role = permit_package["api.models.RoleRead"] + + name = role.members["name"] + assert name.docstring.value == "The name of the role" + assert name.value is None # required: Field(...) + + description = role.members["description"] + assert str(description.value) == "None" + assert description.docstring.value.startswith("optional description string") + + # The description's own indentation and surrounding newlines are dropped. + granted_to = role.members["granted_to"].docstring.value + assert granted_to.startswith("A derived role") + assert "\n " not in granted_to + + attributes = permit_package["enforcement.interfaces.TenantDetails.attributes"] + assert str(attributes.value) == "dict()" + assert role.members["v1compat_settings"].docstring is None + + +def test_a_deprecation_message_it_cannot_read_fails_the_load() -> None: + package = { + "__init__.py": "", + "api.py": ( + "from permit.utils.deprecation import deprecated\n" + "MESSAGE = 'gone'\n" + "class Api:\n" + " @deprecated(MESSAGE)\n" + " async def old(self) -> None:\n" + ' """Old."""\n' + ), + } + extensions = griffe.load_extensions(str(EXTENSION)) + with ( + pytest.raises(ValueError, match="Cannot read the deprecation message 'MESSAGE'"), + griffe.temporary_visited_package("deprecations", package, extensions=extensions), + ): + pass diff --git a/uv.lock b/uv.lock index 4f058c5..334a7db 100644 --- a/uv.lock +++ b/uv.lock @@ -5,7 +5,8 @@ resolution-markers = [ "python_full_version >= '3.15'", "python_full_version == '3.14.*'", "python_full_version == '3.13.*'", - "python_full_version < '3.13'", + "python_full_version >= '3.11' and python_full_version < '3.13'", + "python_full_version < '3.11'", ] conflicts = [[ { package = "permit", group = "pydantic-v1" }, @@ -329,7 +330,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, + { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -466,6 +467,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "griffelib" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/af/018c10bc9edd42b6ef6db2e96b09542050d5253f9b195e74bc910b2d13ab/griffelib-2.3.0.tar.gz", hash = "sha256:7b0952caf5bca6afa4bb5ee8c6a2d183fe3f21b62efc5f6c7243cb2b26d2d115", size = 234534, upload-time = "2026-09-04T15:08:17.472Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/63/e876e789525063c840ccfa8857febdabd6523bcef9ce7eb979b9305ea895/griffelib-2.3.0-py3-none-any.whl", hash = "sha256:1b8f9cd525681c26b1d6d574faa1371651e8459ca51d209684f50b8096ae06e0", size = 169423, upload-time = "2026-09-04T15:08:12.956Z" }, +] + [[package]] name = "identify" version = "2.6.19" @@ -1021,6 +1031,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "griffelib" }, { name = "mypy" }, { name = "packaging" }, { name = "pre-commit" }, @@ -1054,6 +1065,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "griffelib", specifier = "==2.3.0" }, { name = "mypy", specifier = "==2.3.1" }, { name = "packaging", specifier = "==26.3" }, { name = "pre-commit", specifier = "==4.6.2" }, @@ -1256,7 +1268,8 @@ resolution-markers = [ "python_full_version >= '3.15'", "python_full_version == '3.14.*'", "python_full_version == '3.13.*'", - "python_full_version < '3.13'", + "python_full_version >= '3.11' and python_full_version < '3.13'", + "python_full_version < '3.11'", ] dependencies = [ { name = "typing-extensions", marker = "extra == 'group-6-permit-pydantic-v1'" }, @@ -1304,7 +1317,8 @@ resolution-markers = [ "python_full_version >= '3.15'", "python_full_version == '3.14.*'", "python_full_version == '3.13.*'", - "python_full_version < '3.13'", + "python_full_version >= '3.11' and python_full_version < '3.13'", + "python_full_version < '3.11'", ] dependencies = [ { name = "annotated-types", marker = "extra == 'group-6-permit-pydantic-v2' or extra != 'group-6-permit-pydantic-v1'" }, From 00b4ac9f27a3b1310779344eccaf83e4200c341e Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 03:30:57 +0300 Subject: [PATCH 06/23] Link the migration skill from MIGRATION.md by URL The API reference site includes MIGRATION.md as a page, where a link relative to the repository root points at a page that does not exist, as it does in the sdist, which ships MIGRATION.md but not skills/. An absolute GitHub URL, as README.md uses for the same folder, works in all three places. Co-Authored-By: Claude Opus 5.5 --- MIGRATION.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 680776c..405a303 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -6,8 +6,9 @@ methods that could never have worked. Most projects need only the dependency cha lists every breaking change, who it affects, and what to do. Each change has an ID (C1, A2, ...). The same IDs are used by the -[migration skill](skills/permit-python-3-migration/), which can do the upgrade for you with an AI -agent: see [Migrate with an AI agent](#migrate-with-an-ai-agent). +[migration skill](https://github.com/permitio/permit-python/tree/main/skills/permit-python-3-migration), +which can do the upgrade for you with an AI agent: see +[Migrate with an AI agent](#migrate-with-an-ai-agent). ## Contents @@ -546,11 +547,12 @@ needs Python 3.9, because its aiohttp floor does; on 3.8, pip installs an older ## Migrate with an AI agent -The [`permit-python-3-migration`](skills/permit-python-3-migration/) skill walks an AI agent, such -as Claude Code, through this guide: it checks your Python version and stops before editing -anything if the project still allows or runs on 3.8 or 3.9, scans the project, updates the -dependencies, applies the mechanical edits, brings every judgement call to you, and runs your -tests, type checker and linter. +The +[`permit-python-3-migration`](https://github.com/permitio/permit-python/tree/main/skills/permit-python-3-migration) +skill walks an AI agent, such as Claude Code, through this guide: it checks your Python version +and stops before editing anything if the project still allows or runs on 3.8 or 3.9, scans the +project, updates the dependencies, applies the mechanical edits, brings every judgement call to +you, and runs your tests, type checker and linter. To install it: From 0b6d8c4180f86eee0b38486b4ee6817b3609cf22 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 03:31:51 +0300 Subject: [PATCH 07/23] Add the API reference site The site that https://permitio.github.io/permit-python/ will serve (PER-16775), built with Zensical 0.0.65 from mkdocs.yml, so that MkDocs with Material stays a fallback while Zensical is in alpha: - Home is README.md and "Upgrading to 3.0" is MIGRATION.md, both included rather than copied. A short page says which client to use. - The reference: the two clients, configuration, exceptions, the enforcement types, one page per permit.api API with its async class and then its blocking twin, the PDP API, Elements, the deprecated flat methods, and a models page. - The models page lists only the 84 models of permit.api.models that a public method takes or returns, with their fields, and links to the REST API reference for the rest. A test fails when a method starts or stops using one, and names it. - Every page links to docs.permit.io for guides, from the navigation. The docs dependency group pins the tools exactly. `uv run --locked --group docs zensical build --strict --clean` builds the site into site/ with no warnings. Co-Authored-By: Claude Opus 5.5 --- docs/clients.md | 36 +++ docs/index.md | 1 + docs/migration.md | 1 + docs/reference/api/condition-set-rules.md | 10 + docs/reference/api/condition-sets.md | 10 + docs/reference/api/deprecated.md | 10 + docs/reference/api/environments.md | 10 + docs/reference/api/groups.md | 10 + docs/reference/api/index.md | 34 +++ docs/reference/api/pdps.md | 10 + docs/reference/api/projects.md | 10 + docs/reference/api/relationship-tuples.md | 10 + docs/reference/api/resource-action-groups.md | 10 + docs/reference/api/resource-actions.md | 10 + docs/reference/api/resource-attributes.md | 10 + docs/reference/api/resource-instances.md | 10 + docs/reference/api/resource-relations.md | 10 + docs/reference/api/resource-roles.md | 10 + docs/reference/api/resources.md | 10 + docs/reference/api/role-assignments.md | 10 + docs/reference/api/roles.md | 10 + docs/reference/api/tenants.md | 10 + docs/reference/api/user-invites.md | 10 + docs/reference/api/users.md | 10 + docs/reference/config.md | 18 ++ docs/reference/elements.md | 14 + docs/reference/enforcement.md | 30 ++ docs/reference/exceptions.md | 26 ++ docs/reference/index.md | 26 ++ docs/reference/models.md | 105 +++++++ docs/reference/pdp-api.md | 16 ++ docs/reference/permit.md | 9 + docs/reference/sync.md | 11 + mkdocs.yml | 120 ++++++++ pyproject.toml | 15 +- tests/test_docs_griffe_extension.py | 58 +++- uv.lock | 287 +++++++++++++++++++ 37 files changed, 1005 insertions(+), 2 deletions(-) create mode 100644 docs/clients.md create mode 100644 docs/index.md create mode 100644 docs/migration.md create mode 100644 docs/reference/api/condition-set-rules.md create mode 100644 docs/reference/api/condition-sets.md create mode 100644 docs/reference/api/deprecated.md create mode 100644 docs/reference/api/environments.md create mode 100644 docs/reference/api/groups.md create mode 100644 docs/reference/api/index.md create mode 100644 docs/reference/api/pdps.md create mode 100644 docs/reference/api/projects.md create mode 100644 docs/reference/api/relationship-tuples.md create mode 100644 docs/reference/api/resource-action-groups.md create mode 100644 docs/reference/api/resource-actions.md create mode 100644 docs/reference/api/resource-attributes.md create mode 100644 docs/reference/api/resource-instances.md create mode 100644 docs/reference/api/resource-relations.md create mode 100644 docs/reference/api/resource-roles.md create mode 100644 docs/reference/api/resources.md create mode 100644 docs/reference/api/role-assignments.md create mode 100644 docs/reference/api/roles.md create mode 100644 docs/reference/api/tenants.md create mode 100644 docs/reference/api/user-invites.md create mode 100644 docs/reference/api/users.md create mode 100644 docs/reference/config.md create mode 100644 docs/reference/elements.md create mode 100644 docs/reference/enforcement.md create mode 100644 docs/reference/exceptions.md create mode 100644 docs/reference/index.md create mode 100644 docs/reference/models.md create mode 100644 docs/reference/pdp-api.md create mode 100644 docs/reference/permit.md create mode 100644 docs/reference/sync.md create mode 100644 mkdocs.yml diff --git a/docs/clients.md b/docs/clients.md new file mode 100644 index 0000000..0f44d38 --- /dev/null +++ b/docs/clients.md @@ -0,0 +1,36 @@ +# Async or blocking client + +permit has two clients with the same methods. They differ in how you call them. + +| | Async client | Blocking client | +|---|---|---| +| Import | `from permit import Permit` | `from permit.sync import Permit` | +| A call | `await permit.check(...)` | `permit.check(...)` | +| Closing | `async with Permit(...) as permit:` or `await permit.close()` | `with Permit(...) as permit:` or `permit.close()` | +| Reference | [`permit.Permit`](reference/permit.md) | [`permit.sync.Permit`](reference/sync.md) | + +## Which to choose + +- **Your code is async** (FastAPI, aiohttp, Starlette, or anything else that runs on an + asyncio event loop): use the async client. Its calls do not block the loop while they wait + for the Permit API or the PDP. +- **Your code is not async** (Django or Flask views, scripts, worker processes): use the + blocking client. It runs its calls on an event loop in a background thread of its own and + waits for them, so your code calls it like any other function. Threads can share one + client, and its connections. + +Calling the blocking client from code that runs on an event loop works, but it blocks that +loop until the call returns, as any blocking call does. In async code, use the async client. + +## In this reference + +Every page under [`permit.api`](reference/api/index.md), and the +[PDP API](reference/pdp-api.md) and [Elements](reference/elements.md) pages, documents an +API twice: first the class the async client uses, whose methods carry the `async` label, +then its blocking twin, whose name starts with `Sync` and whose methods return their +results. The blocking classes are documented from the stub that type checkers read for +them, so their signatures are the ones your type checker checks your calls against. + +[Connections](index.md#connections), on the home page, says how each client opens, shares +and closes its HTTP connections. For how to use the SDK, start with the +[Python quickstart on docs.permit.io](https://docs.permit.io/sdk/python/quickstart-python/). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..612c7a5 --- /dev/null +++ b/docs/index.md @@ -0,0 +1 @@ +--8<-- "README.md" diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..0fe9b55 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1 @@ +--8<-- "MIGRATION.md" diff --git a/docs/reference/api/condition-set-rules.md b/docs/reference/api/condition-set-rules.md new file mode 100644 index 0000000..bbd115e --- /dev/null +++ b/docs/reference/api/condition-set-rules.md @@ -0,0 +1,10 @@ +# Condition set rules + +`permit.api.condition_set_rules` is a [`ConditionSetRulesApi`][permit.api.condition_set_rules.ConditionSetRulesApi] on the async client and a +[`SyncConditionSetRulesApi`][permit.api.sync_api_client.SyncConditionSetRulesApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Condition Set Rules](https://api.permit.io/v2/redoc#tag/Condition-Set-Rules). + +::: permit.api.condition_set_rules.ConditionSetRulesApi + +::: permit.api.sync_api_client.SyncConditionSetRulesApi diff --git a/docs/reference/api/condition-sets.md b/docs/reference/api/condition-sets.md new file mode 100644 index 0000000..459ce6f --- /dev/null +++ b/docs/reference/api/condition-sets.md @@ -0,0 +1,10 @@ +# Condition sets + +`permit.api.condition_sets` is a [`ConditionSetsApi`][permit.api.condition_sets.ConditionSetsApi] on the async client and a +[`SyncConditionSetsApi`][permit.api.sync_api_client.SyncConditionSetsApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Condition Sets](https://api.permit.io/v2/redoc#tag/Condition-Sets). + +::: permit.api.condition_sets.ConditionSetsApi + +::: permit.api.sync_api_client.SyncConditionSetsApi diff --git a/docs/reference/api/deprecated.md b/docs/reference/api/deprecated.md new file mode 100644 index 0000000..79ff232 --- /dev/null +++ b/docs/reference/api/deprecated.md @@ -0,0 +1,10 @@ +# Deprecated methods + +The flat methods on `permit.api`, such as `permit.api.get_user()`, predate the per-resource +APIs. They still work in 3.x and issue a `DeprecationWarning`; permit 4.0 removes them. Each +one's docstring names the method to use instead. `PermitApiClient` and `SyncPermitApiClient` +get them from the classes below. + +::: permit.api.deprecated.DeprecatedApi + +::: permit.api.sync_api_client.SyncDeprecatedApi diff --git a/docs/reference/api/environments.md b/docs/reference/api/environments.md new file mode 100644 index 0000000..f5378a6 --- /dev/null +++ b/docs/reference/api/environments.md @@ -0,0 +1,10 @@ +# Environments + +`permit.api.environments` is a [`EnvironmentsApi`][permit.api.environments.EnvironmentsApi] on the async client and a +[`SyncEnvironmentsApi`][permit.api.sync_api_client.SyncEnvironmentsApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Environments](https://api.permit.io/v2/redoc#tag/Environments). + +::: permit.api.environments.EnvironmentsApi + +::: permit.api.sync_api_client.SyncEnvironmentsApi diff --git a/docs/reference/api/groups.md b/docs/reference/api/groups.md new file mode 100644 index 0000000..f6d72c9 --- /dev/null +++ b/docs/reference/api/groups.md @@ -0,0 +1,10 @@ +# Groups + +`permit.api.groups` is a [`GroupsApi`][permit.api.groups.GroupsApi] on the async client and a +[`SyncGroupsApi`][permit.api.sync_api_client.SyncGroupsApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Groups](https://api.permit.io/v2/redoc#tag/Groups). + +::: permit.api.groups.GroupsApi + +::: permit.api.sync_api_client.SyncGroupsApi diff --git a/docs/reference/api/index.md b/docs/reference/api/index.md new file mode 100644 index 0000000..0568267 --- /dev/null +++ b/docs/reference/api/index.md @@ -0,0 +1,34 @@ +# REST API + +`permit.api` calls the Permit REST API, one attribute per API: `permit.api.users`, +`permit.api.roles` and so on. On the async client it is a +[`PermitApiClient`][permit.api.api_client.PermitApiClient]; on the blocking client, a +[`SyncPermitApiClient`][permit.api.sync_api_client.SyncPermitApiClient]. Each page below +documents one API, first as the async client has it, then as the blocking client has it. + +The [REST API reference](https://api.permit.io/scalar) documents the endpoints these methods +call, and the [Python SDK docs on docs.permit.io](https://docs.permit.io/sdk/python/quickstart-python/) +show them in use. + +| Attribute | Page | +|---|---| +| `permit.api.condition_set_rules` | [Condition set rules](condition-set-rules.md) | +| `permit.api.condition_sets` | [Condition sets](condition-sets.md) | +| `permit.api.environments` | [Environments](environments.md) | +| `permit.api.groups` | [Groups](groups.md) | +| `permit.api.pdps` | [PDPs](pdps.md) | +| `permit.api.projects` | [Projects](projects.md) | +| `permit.api.relationship_tuples` | [Relationship tuples](relationship-tuples.md) | +| `permit.api.action_groups` | [Resource action groups](resource-action-groups.md) | +| `permit.api.resource_actions` | [Resource actions](resource-actions.md) | +| `permit.api.resource_attributes` | [Resource attributes](resource-attributes.md) | +| `permit.api.resource_instances` | [Resource instances](resource-instances.md) | +| `permit.api.resource_relations` | [Resource relations](resource-relations.md) | +| `permit.api.resource_roles` | [Resource roles](resource-roles.md) | +| `permit.api.resources` | [Resources](resources.md) | +| `permit.api.role_assignments` | [Role assignments](role-assignments.md) | +| `permit.api.roles` | [Roles](roles.md) | +| `permit.api.tenants` | [Tenants](tenants.md) | +| `permit.api.user_invites` | [User invites](user-invites.md) | +| `permit.api.users` | [Users](users.md) | +| `permit.api.get_user()` and the other flat methods | [Deprecated methods](deprecated.md) | diff --git a/docs/reference/api/pdps.md b/docs/reference/api/pdps.md new file mode 100644 index 0000000..68034e9 --- /dev/null +++ b/docs/reference/api/pdps.md @@ -0,0 +1,10 @@ +# PDPs + +`permit.api.pdps` is a [`PdpsApi`][permit.api.pdps.PdpsApi] on the async client and a +[`SyncPdpsApi`][permit.api.sync_api_client.SyncPdpsApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Policy Decision Points](https://api.permit.io/v2/redoc#tag/Policy-Decision-Points). + +::: permit.api.pdps.PdpsApi + +::: permit.api.sync_api_client.SyncPdpsApi diff --git a/docs/reference/api/projects.md b/docs/reference/api/projects.md new file mode 100644 index 0000000..1cde7fd --- /dev/null +++ b/docs/reference/api/projects.md @@ -0,0 +1,10 @@ +# Projects + +`permit.api.projects` is a [`ProjectsApi`][permit.api.projects.ProjectsApi] on the async client and a +[`SyncProjectsApi`][permit.api.sync_api_client.SyncProjectsApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Projects](https://api.permit.io/v2/redoc#tag/Projects). + +::: permit.api.projects.ProjectsApi + +::: permit.api.sync_api_client.SyncProjectsApi diff --git a/docs/reference/api/relationship-tuples.md b/docs/reference/api/relationship-tuples.md new file mode 100644 index 0000000..76afe21 --- /dev/null +++ b/docs/reference/api/relationship-tuples.md @@ -0,0 +1,10 @@ +# Relationship tuples + +`permit.api.relationship_tuples` is a [`RelationshipTuplesApi`][permit.api.relationship_tuples.RelationshipTuplesApi] on the async client and a +[`SyncRelationshipTuplesApi`][permit.api.sync_api_client.SyncRelationshipTuplesApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Relationship tuples](https://api.permit.io/v2/redoc#tag/Relationship-tuples). + +::: permit.api.relationship_tuples.RelationshipTuplesApi + +::: permit.api.sync_api_client.SyncRelationshipTuplesApi diff --git a/docs/reference/api/resource-action-groups.md b/docs/reference/api/resource-action-groups.md new file mode 100644 index 0000000..df45fed --- /dev/null +++ b/docs/reference/api/resource-action-groups.md @@ -0,0 +1,10 @@ +# Resource action groups + +`permit.api.action_groups` is a [`ResourceActionGroupsApi`][permit.api.resource_action_groups.ResourceActionGroupsApi] on the async client and a +[`SyncResourceActionGroupsApi`][permit.api.sync_api_client.SyncResourceActionGroupsApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Resource Action Groups](https://api.permit.io/v2/redoc#tag/Resource-Action-Groups). + +::: permit.api.resource_action_groups.ResourceActionGroupsApi + +::: permit.api.sync_api_client.SyncResourceActionGroupsApi diff --git a/docs/reference/api/resource-actions.md b/docs/reference/api/resource-actions.md new file mode 100644 index 0000000..88bb95a --- /dev/null +++ b/docs/reference/api/resource-actions.md @@ -0,0 +1,10 @@ +# Resource actions + +`permit.api.resource_actions` is a [`ResourceActionsApi`][permit.api.resource_actions.ResourceActionsApi] on the async client and a +[`SyncResourceActionsApi`][permit.api.sync_api_client.SyncResourceActionsApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Resource Actions](https://api.permit.io/v2/redoc#tag/Resource-Actions). + +::: permit.api.resource_actions.ResourceActionsApi + +::: permit.api.sync_api_client.SyncResourceActionsApi diff --git a/docs/reference/api/resource-attributes.md b/docs/reference/api/resource-attributes.md new file mode 100644 index 0000000..c08bde4 --- /dev/null +++ b/docs/reference/api/resource-attributes.md @@ -0,0 +1,10 @@ +# Resource attributes + +`permit.api.resource_attributes` is a [`ResourceAttributesApi`][permit.api.resource_attributes.ResourceAttributesApi] on the async client and a +[`SyncResourceAttributesApi`][permit.api.sync_api_client.SyncResourceAttributesApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Resource Attributes](https://api.permit.io/v2/redoc#tag/Resource-Attributes). + +::: permit.api.resource_attributes.ResourceAttributesApi + +::: permit.api.sync_api_client.SyncResourceAttributesApi diff --git a/docs/reference/api/resource-instances.md b/docs/reference/api/resource-instances.md new file mode 100644 index 0000000..f5ae887 --- /dev/null +++ b/docs/reference/api/resource-instances.md @@ -0,0 +1,10 @@ +# Resource instances + +`permit.api.resource_instances` is a [`ResourceInstancesApi`][permit.api.resource_instances.ResourceInstancesApi] on the async client and a +[`SyncResourceInstancesApi`][permit.api.sync_api_client.SyncResourceInstancesApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Resource Instances](https://api.permit.io/v2/redoc#tag/Resource-Instances). + +::: permit.api.resource_instances.ResourceInstancesApi + +::: permit.api.sync_api_client.SyncResourceInstancesApi diff --git a/docs/reference/api/resource-relations.md b/docs/reference/api/resource-relations.md new file mode 100644 index 0000000..5fd41e1 --- /dev/null +++ b/docs/reference/api/resource-relations.md @@ -0,0 +1,10 @@ +# Resource relations + +`permit.api.resource_relations` is a [`ResourceRelationsApi`][permit.api.resource_relations.ResourceRelationsApi] on the async client and a +[`SyncResourceRelationsApi`][permit.api.sync_api_client.SyncResourceRelationsApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Resource Relations](https://api.permit.io/v2/redoc#tag/Resource-Relations). + +::: permit.api.resource_relations.ResourceRelationsApi + +::: permit.api.sync_api_client.SyncResourceRelationsApi diff --git a/docs/reference/api/resource-roles.md b/docs/reference/api/resource-roles.md new file mode 100644 index 0000000..4da0ccb --- /dev/null +++ b/docs/reference/api/resource-roles.md @@ -0,0 +1,10 @@ +# Resource roles + +`permit.api.resource_roles` is a [`ResourceRolesApi`][permit.api.resource_roles.ResourceRolesApi] on the async client and a +[`SyncResourceRolesApi`][permit.api.sync_api_client.SyncResourceRolesApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Resource Roles](https://api.permit.io/v2/redoc#tag/Resource-Roles). + +::: permit.api.resource_roles.ResourceRolesApi + +::: permit.api.sync_api_client.SyncResourceRolesApi diff --git a/docs/reference/api/resources.md b/docs/reference/api/resources.md new file mode 100644 index 0000000..25a0d5b --- /dev/null +++ b/docs/reference/api/resources.md @@ -0,0 +1,10 @@ +# Resources + +`permit.api.resources` is a [`ResourcesApi`][permit.api.resources.ResourcesApi] on the async client and a +[`SyncResourcesApi`][permit.api.sync_api_client.SyncResourcesApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Resources](https://api.permit.io/v2/redoc#tag/Resources). + +::: permit.api.resources.ResourcesApi + +::: permit.api.sync_api_client.SyncResourcesApi diff --git a/docs/reference/api/role-assignments.md b/docs/reference/api/role-assignments.md new file mode 100644 index 0000000..bd301ef --- /dev/null +++ b/docs/reference/api/role-assignments.md @@ -0,0 +1,10 @@ +# Role assignments + +`permit.api.role_assignments` is a [`RoleAssignmentsApi`][permit.api.role_assignments.RoleAssignmentsApi] on the async client and a +[`SyncRoleAssignmentsApi`][permit.api.sync_api_client.SyncRoleAssignmentsApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Role Assignments](https://api.permit.io/v2/redoc#tag/Role-Assignments). + +::: permit.api.role_assignments.RoleAssignmentsApi + +::: permit.api.sync_api_client.SyncRoleAssignmentsApi diff --git a/docs/reference/api/roles.md b/docs/reference/api/roles.md new file mode 100644 index 0000000..d68ce3e --- /dev/null +++ b/docs/reference/api/roles.md @@ -0,0 +1,10 @@ +# Roles + +`permit.api.roles` is a [`RolesApi`][permit.api.roles.RolesApi] on the async client and a +[`SyncRolesApi`][permit.api.sync_api_client.SyncRolesApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Roles](https://api.permit.io/v2/redoc#tag/Roles). + +::: permit.api.roles.RolesApi + +::: permit.api.sync_api_client.SyncRolesApi diff --git a/docs/reference/api/tenants.md b/docs/reference/api/tenants.md new file mode 100644 index 0000000..00c50cd --- /dev/null +++ b/docs/reference/api/tenants.md @@ -0,0 +1,10 @@ +# Tenants + +`permit.api.tenants` is a [`TenantsApi`][permit.api.tenants.TenantsApi] on the async client and a +[`SyncTenantsApi`][permit.api.sync_api_client.SyncTenantsApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Tenants](https://api.permit.io/v2/redoc#tag/Tenants). + +::: permit.api.tenants.TenantsApi + +::: permit.api.sync_api_client.SyncTenantsApi diff --git a/docs/reference/api/user-invites.md b/docs/reference/api/user-invites.md new file mode 100644 index 0000000..75f658c --- /dev/null +++ b/docs/reference/api/user-invites.md @@ -0,0 +1,10 @@ +# User invites + +`permit.api.user_invites` is a [`UserInvitesApi`][permit.api.user_invites.UserInvitesApi] on the async client and a +[`SyncUserInvitesApi`][permit.api.sync_api_client.SyncUserInvitesApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[User Invites](https://api.permit.io/v2/redoc#tag/User-Invites). + +::: permit.api.user_invites.UserInvitesApi + +::: permit.api.sync_api_client.SyncUserInvitesApi diff --git a/docs/reference/api/users.md b/docs/reference/api/users.md new file mode 100644 index 0000000..f9ccc13 --- /dev/null +++ b/docs/reference/api/users.md @@ -0,0 +1,10 @@ +# Users + +`permit.api.users` is a [`UsersApi`][permit.api.users.UsersApi] on the async client and a +[`SyncUsersApi`][permit.api.sync_api_client.SyncUsersApi] on the blocking client. The REST API +reference documents the endpoints behind it under +[Users](https://api.permit.io/v2/redoc#tag/Users). + +::: permit.api.users.UsersApi + +::: permit.api.sync_api_client.SyncUsersApi diff --git a/docs/reference/config.md b/docs/reference/config.md new file mode 100644 index 0000000..669d1d3 --- /dev/null +++ b/docs/reference/config.md @@ -0,0 +1,18 @@ +# Configuration + +A client takes its configuration as keyword arguments, `Permit(token="", +pdp="http://localhost:7766")`, or as a `PermitConfig`, `Permit(PermitConfig(token=...))`. +The keyword arguments are the fields of `PermitConfig`. For where to run the PDP and which +API key to use, see the [Python quickstart on docs.permit.io](https://docs.permit.io/sdk/python/quickstart-python/). + +::: permit.config.PermitConfig + +::: permit.config.LoggerConfig + +::: permit.config.MultiTenancyConfig + +::: permit.api.context.ApiContext + +::: permit.api.context.ApiKeyAccessLevel + +::: permit.api.context.ApiContextLevel diff --git a/docs/reference/elements.md b/docs/reference/elements.md new file mode 100644 index 0000000..53ff061 --- /dev/null +++ b/docs/reference/elements.md @@ -0,0 +1,14 @@ +# Elements + +`permit.elements` logs users into Permit Elements, the embeddable UI components. It is an +`ElementsApi` on the async client and a `SyncElementsApi` on the blocking client. For +embedding Elements, see the +[Elements overview on docs.permit.io](https://docs.permit.io/embeddable-uis/overview/). + +::: permit.api.elements.ElementsApi + +::: permit.api.elements.SyncElementsApi + +::: permit.api.elements.UserLoginAsResponse + +::: permit.api.elements.EmbeddedLoginRequestOutput diff --git a/docs/reference/enforcement.md b/docs/reference/enforcement.md new file mode 100644 index 0000000..57a310d --- /dev/null +++ b/docs/reference/enforcement.md @@ -0,0 +1,30 @@ +# Enforcement types + +The types that `check()`, `bulk_check()`, `authorized_users()`, `get_user_permissions()`, +`get_user_tenants()` and `filter_objects()` take and return, on both clients. For how to +model and enforce permissions, see +[Check permissions on docs.permit.io](https://docs.permit.io/how-to/enforce-permissions/check/). + +::: permit.enforcement.enforcer.User + +::: permit.enforcement.enforcer.Resource + +::: permit.enforcement.enforcer.Action + +::: permit.utils.context.Context + +::: permit.enforcement.enforcer.CheckQuery + +::: permit.enforcement.interfaces.UserInput + options: + inherited_members: true + +::: permit.enforcement.interfaces.ResourceInput + +::: permit.enforcement.interfaces.AssignedRole + +::: permit.enforcement.interfaces.AuthorizedUsersResult + +::: permit.enforcement.interfaces.AuthorizedUserAssignment + +::: permit.enforcement.interfaces.TenantDetails diff --git a/docs/reference/exceptions.md b/docs/reference/exceptions.md new file mode 100644 index 0000000..93bd98d --- /dev/null +++ b/docs/reference/exceptions.md @@ -0,0 +1,26 @@ +# Exceptions + +The SDK's exceptions are `PermitError` subclasses. A call to the Permit REST API that the API +answers with an error status raises a `PermitApiError` or one of its subclasses. An +authorization query that cannot reach the PDP, or that the PDP answers with an error status, +raises a `PermitConnectionError`. + +::: permit.exceptions.PermitError + +::: permit.exceptions.PermitApiError + +::: permit.exceptions.PermitApiDetailedError + +::: permit.exceptions.PermitValidationError + +::: permit.exceptions.PermitAlreadyExistsError + +::: permit.exceptions.PermitNotFoundError + +::: permit.exceptions.PermitConnectionError + +::: permit.exceptions.PermitContextError + +::: permit.exceptions.PermitContextChangeError + +::: permit.exceptions.PermitException diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 0000000..c939af7 --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,26 @@ +# Reference + +Every public class and method of permit, generated from its docstrings and type annotations. +For guides and concepts, see the [Python SDK docs on docs.permit.io](https://docs.permit.io/sdk/python/quickstart-python/); +for the endpoints behind `permit.api`, the [REST API reference](https://api.permit.io/scalar). + +- [Async client](permit.md) and [blocking client](sync.md): `permit.Permit` and + `permit.sync.Permit`, the authorization checks, and the `api`, `pdp_api` and `elements` + attributes. [Async or blocking client](../clients.md) says which to choose. +- [Configuration](config.md): `PermitConfig`, the options a client takes. +- [Exceptions](exceptions.md): what the SDK raises. +- [Enforcement types](enforcement.md): the user and resource types `check()` and the other + authorization queries take, and the results they return. +- [REST API](api/index.md): `permit.api`, one page per API. +- [PDP API](pdp-api.md) and [Elements](elements.md): `permit.pdp_api` and `permit.elements`. +- [Models](models.md): the models the methods take and return. + +## Reading the signatures + +- A method with the `async` label is a coroutine function: `await` its call. The blocking + client's methods have the same parameters and return the result directly. +- `ModelInput[X]` is a model `X` or a dict with its fields, such as + `{"key": "user"}` for a `UserCreate`. The dict is validated into `X` at runtime. + `ModelListInput[X]` is a sequence of either. +- A method with the `deprecated` label still works and issues a `DeprecationWarning`. Its + docstring ends with what to use instead and the release that removes it. diff --git a/docs/reference/models.md b/docs/reference/models.md new file mode 100644 index 0000000..2d48776 --- /dev/null +++ b/docs/reference/models.md @@ -0,0 +1,105 @@ +# Models + +The models that the SDK's methods take and return, from `permit.api.models`, with their +fields. The package exports each one at the top level too: `from permit import UserCreate`. +A method that takes a model also takes a dict with its fields; see +[Reading the signatures](index.md#reading-the-signatures). + +The rest of `permit.api.models`, about 240 more models and enums generated from the Permit +REST API's OpenAPI document, are the types of these models' fields or belong to endpoints the +SDK has no method for. The [REST API reference](https://api.permit.io/scalar) documents every +one of them, with each field's constraints. + +::: permit.api.models + options: + show_root_heading: false + show_root_toc_entry: false + heading_level: 3 + separate_signature: false + show_bases: false + show_labels: false + members: + - APIKeyRead + - BulkRoleAssignmentReport + - BulkRoleUnAssignmentReport + - ConditionSetCreate + - ConditionSetRead + - ConditionSetRuleCreate + - ConditionSetRuleRead + - ConditionSetRuleRemove + - ConditionSetUpdate + - DerivedRoleRuleCreate + - DerivedRoleRuleDelete + - DerivedRoleRuleRead + - ElementsUserInviteApprove + - ElementsUserInviteCreate + - ElementsUserInviteRead + - EnvironmentCopy + - EnvironmentCreate + - EnvironmentRead + - EnvironmentStats + - EnvironmentUpdate + - ErrorDetails + - GroupAddRole + - GroupAssignment + - GroupCreate + - GroupRead + - GroupReadSchema + - HTTPValidationError + - PaginatedResultElementsUserInviteRead + - PaginatedResultGroupReadSchema + - PaginatedResultRelationRead + - PaginatedResultRelationshipTupleDetailedRead + - PaginatedResultResourceInstanceDetailedRead + - PaginatedResultRoleAssignmentDetailedRead + - PaginatedResultUserRead + - PDPDataRefreshResponse + - PermitBackendSchemasSchemaDerivedRoleRuleDerivationSettings + - ProjectCreate + - ProjectRead + - ProjectUpdate + - RelationCreate + - RelationRead + - RelationshipTupleCreate + - RelationshipTupleCreateBulkOperationResult + - RelationshipTupleDelete + - RelationshipTupleDeleteBulkOperationResult + - RelationshipTupleRead + - ResourceActionCreate + - ResourceActionGroupCreate + - ResourceActionGroupRead + - ResourceActionGroupUpdate + - ResourceActionRead + - ResourceActionUpdate + - ResourceAttributeCreate + - ResourceAttributeRead + - ResourceAttributeUpdate + - ResourceCreate + - ResourceInstanceCreate + - ResourceInstanceCreateBulkOperationResult + - ResourceInstanceDeleteBulkOperationResult + - ResourceInstanceRead + - ResourceInstanceUpdate + - ResourceRead + - ResourceReplace + - ResourceRoleCreate + - ResourceRoleRead + - ResourceRoleUpdate + - ResourceUpdate + - RoleAssignmentCreate + - RoleAssignmentRead + - RoleAssignmentRemove + - RoleCreate + - RoleRead + - RoleUpdate + - TenantCreate + - TenantCreateBulkOperationResult + - TenantDeleteBulkOperationResult + - TenantRead + - TenantUpdate + - UserCreate + - UserCreateBulkOperationResult + - UserDeleteBulkOperationResult + - UserRead + - UserReplaceBulkOperationResult + - UserUpdate diff --git a/docs/reference/pdp-api.md b/docs/reference/pdp-api.md new file mode 100644 index 0000000..63763d8 --- /dev/null +++ b/docs/reference/pdp-api.md @@ -0,0 +1,16 @@ +# PDP API + +`permit.pdp_api` calls the APIs that the PDP serves itself, such as the role assignments it +has synced. Only the container PDP serves them. On the async client it is a +`PermitPdpApiClient`; on the blocking client, a `SyncPDPApi`. For running a container PDP, +see the [PDP overview on docs.permit.io](https://docs.permit.io/concepts/pdp/overview/). + +::: permit.pdp_api.pdp_api_client.PermitPdpApiClient + +::: permit.pdp_api.role_assignments.RoleAssignmentsApi + +::: permit.pdp_api.pdp_api_client.SyncPDPApi + +::: permit.pdp_api.pdp_api_client.SyncRoleAssignmentsApi + +::: permit.pdp_api.models.RoleAssignment diff --git a/docs/reference/permit.md b/docs/reference/permit.md new file mode 100644 index 0000000..fc864dd --- /dev/null +++ b/docs/reference/permit.md @@ -0,0 +1,9 @@ +# Async client + +`permit.Permit` is the client for code that runs on an asyncio event loop: every method that +talks to the PDP or the Permit API is a coroutine function. Its blocking twin is +[`permit.sync.Permit`](sync.md); [Async or blocking client](../clients.md) compares them. + +::: permit.Permit + +::: permit.api.api_client.PermitApiClient diff --git a/docs/reference/sync.md b/docs/reference/sync.md new file mode 100644 index 0000000..60d4f52 --- /dev/null +++ b/docs/reference/sync.md @@ -0,0 +1,11 @@ +# Blocking client + +`permit.sync.Permit` is the client for code that does not run on an event loop. It has the +methods of the async client, [`permit.Permit`](permit.md), and returns their results instead +of coroutines. [Async or blocking client](../clients.md) compares them. + +::: permit.sync.Permit + options: + inherited_members: true + +::: permit.api.sync_api_client.SyncPermitApiClient diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..9bd84b4 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,120 @@ +# The API reference site, https://permitio.github.io/permit-python/, built with +# Zensical, which reads this MkDocs-format file. CONTRIBUTING.md ("The API reference +# site") says how to build and preview it and how to add a page. +site_name: permit-python API reference +site_url: https://permitio.github.io/permit-python/ +site_description: >- + The API reference of permit, the Permit.io Python SDK: every public class, method and + model, with the types a type checker sees. +repo_url: https://github.com/permitio/permit-python +repo_name: permitio/permit-python +# The pages are generated from docstrings, so an edit link would point at the wrong file. +edit_uri: "" +copyright: Copyright © Permit.io +# Where the build writes the site, which the deploy workflow publishes. Gitignored. +site_dir: site +# Rebuild when these change, not only docs/: the reference pages come from the SDK's +# source, through the Griffe extension, and two pages include the Markdown files. +watch: + - permit + - scripts/docs_griffe_extension.py + - README.md + - MIGRATION.md + +theme: + name: material + features: + - content.code.copy + - navigation.footer + - navigation.indexes + - navigation.sections + - search.highlight + +nav: + - Home: index.md + - Async or blocking client: clients.md + - Upgrading to 3.0: migration.md + - Reference: + - reference/index.md + - Async client: reference/permit.md + - Blocking client: reference/sync.md + - Configuration: reference/config.md + - Exceptions: reference/exceptions.md + - Enforcement types: reference/enforcement.md + - REST API (permit.api): + - reference/api/index.md + - Condition set rules: reference/api/condition-set-rules.md + - Condition sets: reference/api/condition-sets.md + - Environments: reference/api/environments.md + - Groups: reference/api/groups.md + - PDPs: reference/api/pdps.md + - Projects: reference/api/projects.md + - Relationship tuples: reference/api/relationship-tuples.md + - Resource action groups: reference/api/resource-action-groups.md + - Resource actions: reference/api/resource-actions.md + - Resource attributes: reference/api/resource-attributes.md + - Resource instances: reference/api/resource-instances.md + - Resource relations: reference/api/resource-relations.md + - Resource roles: reference/api/resource-roles.md + - Resources: reference/api/resources.md + - Role assignments: reference/api/role-assignments.md + - Roles: reference/api/roles.md + - Tenants: reference/api/tenants.md + - User invites: reference/api/user-invites.md + - Users: reference/api/users.md + - Deprecated methods: reference/api/deprecated.md + - PDP API (permit.pdp_api): reference/pdp-api.md + - Elements (permit.elements): reference/elements.md + - Models: reference/models.md + # Guides stay on docs.permit.io; every page links there from the navigation. + - Guides (docs.permit.io): https://docs.permit.io/sdk/python/quickstart-python/ + +markdown_extensions: + - pymdownx.highlight + # Docstrings carry bare URLs, such as the one in Permit.wait_for_sync()'s See Also + # section; this makes them links. + - pymdownx.magiclink + # The home page and the migration guide include README.md and MIGRATION.md, so the + # prose lives in one place. check_paths fails the build when an included file is gone. + - pymdownx.snippets: + base_path: ["."] + check_paths: true + # Docstring examples are fenced code blocks, which superfences also renders inside + # lists and admonitions. + - pymdownx.superfences + - toc: + permalink: true + toc_depth: 3 + +plugins: + - search + - mkdocstrings: + handlers: + python: + paths: ["."] + options: + docstring_style: google + extensions: + - scripts/docs_griffe_extension.py + # Private names, and the `class Config:` block of each pydantic model. + filters: ["!^_", "!^Config$"] + heading_level: 2 + members_order: source + merge_init_into_class: true + separate_signature: true + # A model field or an exception attribute is worth listing without one. + show_if_no_docstring: true + show_root_full_path: true + show_root_heading: true + show_signature_annotations: true + show_source: false + show_symbol_type_heading: true + show_symbol_type_toc: true + signature_crossrefs: true + +# Zensical checks links and anchors by default; spelled out because the CI build +# depends on it. +validation: + links: + not_found: warn + anchors: warn diff --git a/pyproject.toml b/pyproject.toml index 693b2f2..c4715c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,7 +101,8 @@ dev = [ "packaging==26.3", # The API reference site's Griffe extension, scripts/docs_griffe_extension.py, imports # it, and so do its tests in tests/test_docs_griffe_extension.py, which run with the - # offline suite; mypy checks both. + # offline suite; mypy checks both. Pinned in the docs group too, at the same version, + # which uv lock enforces: two different exact pins of one package do not resolve. "griffelib==2.3.0", "pre-commit==4.6.2", # 9.x rather than 8.x: the old 8.3.0 floor is affected by CVE-2025-71176 @@ -131,6 +132,18 @@ dev = [ # CVE-2026-21860, CVE-2026-27199). "werkzeug==3.1.8", ] +# The API reference site (mkdocs.yml; CONTRIBUTING.md, "The API reference site"): +# `uv run --locked --group docs zensical build --strict --clean`. Exact pins, like the dev +# group, so a local build and CI render the same site with the same warnings. +docs = [ + # Also in the dev group, at the same version: see there. + "griffelib==2.3.0", + "mkdocstrings==1.0.6", + "mkdocstrings-python==2.0.9", + # The Markdown extensions mkdocs.yml enables come from this package. + "pymdown-extensions==12.1", + "zensical==0.0.65", +] # The SDK supports both pydantic majors (permit/utils/pydantic_version.py), and # CI runs the suite once per major. Each lane is a group so both resolutions # live in uv.lock: `uv sync --group pydantic-v1` / `--group pydantic-v2`. diff --git a/tests/test_docs_griffe_extension.py b/tests/test_docs_griffe_extension.py index 174cf44..3c4d912 100644 --- a/tests/test_docs_griffe_extension.py +++ b/tests/test_docs_griffe_extension.py @@ -2,7 +2,8 @@ Griffe reads the SDK the way the site's build does, statically and with the extension, and these tests check what the site would show: blocking signatures for the blocking classes, -taken from the stub by full path, deprecation labels and pydantic field descriptions. +taken from the stub by full path, deprecation labels, pydantic field descriptions, and a +models page that lists exactly the models the SDK's methods take and return. """ from collections.abc import Iterator @@ -13,6 +14,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1] EXTENSION = REPO_ROOT / "scripts" / "docs_griffe_extension.py" +MODELS_PAGE = REPO_ROOT / "docs" / "reference" / "models.md" @pytest.fixture(scope="module") @@ -151,6 +153,60 @@ def test_pydantic_fields_are_documented_from_their_field_call( assert role.members["v1compat_settings"].docstring is None +def public_signature_models(package: griffe.Module) -> set[str]: + """The permit.api.models names in the signatures of the SDK's public methods.""" + found = set() + for module in walk_modules(package): + if module.path == "permit.api.models": + continue + for cls in module.classes.values(): + if cls.is_alias: + continue + for name, member in cls.members.items(): + if name.startswith("_"): + continue + if isinstance(member, griffe.Function): + annotations = [p.annotation for p in member.parameters] + [member.returns] + elif isinstance(member, griffe.Attribute) and "property" in member.labels: + annotations = [member.annotation] + else: + continue + for annotation in annotations: + if not isinstance(annotation, griffe.Expr): + continue + for part in annotation.iterate(flat=True): + if isinstance(part, griffe.ExprName): + module_path, _, model = part.canonical_path.rpartition(".") + if module_path == "permit.api.models": + found.add(model) + return found + + +def models_on_page() -> list[str]: + options = MODELS_PAGE.read_text().split(" members:\n", 1)[1] + return [ + line.removeprefix(" - ") + for line in options.splitlines() + if line.startswith(" - ") + ] + + +def test_the_models_page_lists_the_models_of_public_signatures( + permit_package: griffe.Module, +) -> None: + listed = models_on_page() + expected = public_signature_models(permit_package) + + assert "RoleRead" in expected + assert "UserCreate" in expected + missing = sorted(expected - set(listed)) + extra = sorted(set(listed) - expected) + page = MODELS_PAGE.relative_to(REPO_ROOT) + assert not missing, f"Add these models to the members list in {page}: {missing}" + assert not extra, f"No public method takes or returns these; remove them: {extra}" + assert listed == sorted(listed, key=str.lower), "Keep the members list alphabetical." + + def test_a_deprecation_message_it_cannot_read_fails_the_load() -> None: package = { "__init__.py": "", diff --git a/uv.lock b/uv.lock index 334a7db..40989b3 100644 --- a/uv.lock +++ b/uv.lock @@ -285,6 +285,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -294,6 +303,15 @@ 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 = "deepmerge" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/6e/5cb3548b4d3112fea529375e55e6f3cdc52b8054e3a66f203b1f888ba885/deepmerge-3.0.1.tar.gz", hash = "sha256:35b39a4cb92cf328d6eca61cbbf65f68a37c2ceb3085f0f853cbb2e52a59fc23", size = 22328, upload-time = "2026-09-01T14:09:44.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/91/600003aaad107e27553fbc9cbfc57e96fa37e0223d2ef9d7d3a0e8d8d070/deepmerge-3.0.1-py3-none-any.whl", hash = "sha256:35c96f6a68fcf90719a5b31d9f8042ecef6c00fb56836660d33455d0f5cfda65", size = 14909, upload-time = "2026-09-01T14:09:43.364Z" }, +] + [[package]] name = "distlib" version = "0.4.3" @@ -467,6 +485,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + [[package]] name = "griffelib" version = "2.3.0" @@ -503,6 +533,18 @@ 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 = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "librt" version = "0.15.0" @@ -646,6 +688,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] +[[package]] +name = "markdown" +version = "3.10.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, +] + +[[package]] +name = "markdown" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", + "python_full_version == '3.13.*'", + "python_full_version >= '3.11' and python_full_version < '3.13'", +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/4f/700155c8c20d9e655dd0732b5fc3c7614f291b9148da271d7388e50bf774/markdown-3.11.tar.gz", hash = "sha256:180224db6aed87ba9ce1f2781ebcd5826253de8ff637112090e24b84502bbf9f", size = 485623, upload-time = "2026-09-25T13:46:23.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/1e/32971905a7ab47f8b66866ed949fa48b104ba1c4a6fa57794c4f2c4b2cb8/markdown-3.11-py3-none-any.whl", hash = "sha256:cd6c89e7eb308c8b332ed673215a52d208a43f8bacc030b1419376129408719e", size = 111296, upload-time = "2026-09-25T13:46:22.163Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -731,6 +800,102 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, + { name = "markdown", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, + { name = "markdown", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, + { name = "markdown", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/71/f85bdf13355073ae15a7375f09879375a830553552e58c1c4b7e0bbc5c8b/mkdocstrings-1.0.6.tar.gz", hash = "sha256:a0b8c2bdd29a6416c80d717aa369bbf7831946bd9f23c2a66db1b1dbe7693dbd", size = 100649, upload-time = "2026-07-11T19:38:05.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/5b/4c1902e8bdd5c4db63284e9d101dece4038d4025d6d88850ffe0a1578980/mkdocstrings-1.0.6-py3-none-any.whl", hash = "sha256:2703708697487d1b6d6d7b412e176fa436edf120c1bf81dc9e126b12d00893c7", size = 35787, upload-time = "2026-07-11T19:38:04.417Z" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, + { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/11/640067d1ad713ea99c233e53e57a4c300d9885f904a61371d7ef90dea549/mkdocstrings_python-2.0.9.tar.gz", hash = "sha256:ae945637dc0618c6beedbee169f10c0234b77f322c53db3fc9fb4c29b3013038", size = 203006, upload-time = "2026-09-22T13:43:27.712Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/0f/f7f926cfa06317bbdbd3cf0ff82f9e3a91767352eb01519c81499fddf575/mkdocstrings_python-2.0.9-py3-none-any.whl", hash = "sha256:c0233eff3f84d78110df50541918e7ec2bcff8e1614faddea34290a90321a416", size = 105698, upload-time = "2026-09-22T13:43:26.345Z" }, +] + [[package]] name = "multidict" version = "6.8.0" @@ -1044,6 +1209,13 @@ dev = [ { name = "uv" }, { name = "werkzeug" }, ] +docs = [ + { name = "griffelib" }, + { name = "mkdocstrings" }, + { name = "mkdocstrings-python" }, + { name = "pymdown-extensions" }, + { name = "zensical" }, +] pydantic-v1 = [ { name = "pydantic", version = "1.10.26", source = { registry = "https://pypi.org/simple" } }, ] @@ -1078,6 +1250,13 @@ dev = [ { name = "uv", specifier = "==0.12.17" }, { name = "werkzeug", specifier = "==3.1.8" }, ] +docs = [ + { name = "griffelib", specifier = "==2.3.0" }, + { name = "mkdocstrings", specifier = "==1.0.6" }, + { name = "mkdocstrings-python", specifier = "==2.0.9" }, + { name = "pymdown-extensions", specifier = "==12.1" }, + { name = "zensical", specifier = "==0.0.65" }, +] pydantic-v1 = [{ name = "pydantic", specifier = "<2" }] pydantic-v2 = [{ name = "pydantic", specifier = ">=2" }] @@ -1461,6 +1640,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, + { name = "markdown", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/94/858e0163cb4d83d6d8234018a01792c749a56daee41794cac7d6c46661e8/pymdown_extensions-12.1.tar.gz", hash = "sha256:fdb8f47f5d7fd069d10ef2a7d8908e613d7865ff0419d544a0ffd3984e884f11", size = 868245, upload-time = "2026-09-23T00:08:42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d1/98313da89960a604402266a115311b510254900ff1295ea426403f9423cc/pymdown_extensions-12.1-py3-none-any.whl", hash = "sha256:4a254b771acfcddc6c110a7f4590d818f1f849e2d5aacf69b4b07c8dd2a9700d", size = 277069, upload-time = "2026-09-23T00:08:40.069Z" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -1505,6 +1698,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/df/0bdf90b84c6a586a9fd2b509523a3ab26b1cc1b1dba2fb62a32e4411ea9e/pytest_httpserver-1.1.5-py3-none-any.whl", hash = "sha256:ee83feb587ab652c0c6729598db2820e9048233bac8df756818b7845a1621d0a", size = 23330, upload-time = "2026-02-14T13:27:22.119Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-discovery" version = "1.6.0" @@ -1581,6 +1786,18 @@ wheels = [ { 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 = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + [[package]] name = "ruff" version = "0.16.8" @@ -1606,6 +1823,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -1740,6 +1966,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/7c/b75957b57ef372d84628b1099c4a530b3c361878cc6c54a8dfc5071d371a/virtualenv-21.7.10-py3-none-any.whl", hash = "sha256:d7ac9669ba19e675ffadbb97fe8e887ef92fb1743fe9a0032be62b947657327a", size = 5324900, upload-time = "2026-09-15T23:36:03.751Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "werkzeug" version = "3.1.8" @@ -1910,3 +2168,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" }, { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, ] + +[[package]] +name = "zensical" +version = "0.0.65" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "jinja2" }, + { name = "markdown", version = "3.10.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, + { name = "markdown", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' or (extra == 'group-6-permit-pydantic-v1' and extra == 'group-6-permit-pydantic-v2')" }, + { name = "pathspec" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/c6/df2df4ae707e45318e7699b7a9341e9130a75c73b51536a9d319ab74de7f/zensical-0.0.65.tar.gz", hash = "sha256:35620265949eb426ebb4339bd091bed9a05648f28d52e61a79922ee1b109d9ab", size = 4228815, upload-time = "2026-09-24T18:07:43.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/18/2cff20ec259ab7577ef17e51c67b63b0989e39b1a469ff4b8ea310f71805/zensical-0.0.65-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:62c3a61be738d147d27c499bd84ee2b9afe5c0b97ca072aad6de28e05122e9ee", size = 15334565, upload-time = "2026-09-24T18:07:17.099Z" }, + { url = "https://files.pythonhosted.org/packages/23/a7/3583b987894dbded915869c7df35016a0e256a7a48627ede009270a8549d/zensical-0.0.65-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:36ccd6f3e2c7e69d35015d4f4c4503146777a1c53d6bd9fbe7adbdc4cbd5a001", size = 15017811, upload-time = "2026-09-24T18:07:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/77/7d/40912425a7a77ed929119768526209eb06792d3e0bd200bfd73c89b0ad6d/zensical-0.0.65-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab497d82ed535a04008f8d12e10f028ce8769f1bac34b19281fa5b99712e34e9", size = 15307106, upload-time = "2026-09-24T18:07:24.232Z" }, + { url = "https://files.pythonhosted.org/packages/05/e3/e65a583f0c7e849ce3298b7463fc4a607a3c242433e62b9bd14364272ddc/zensical-0.0.65-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:187f94c4cdfabad0afcec092af6d5944104efe48c6595ae057d54e3213892068", size = 15373933, upload-time = "2026-09-24T18:07:26.934Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0e/55f64f4ac0d80fdb31a278a3b9434cf84742bfeff81ae9d121f1a35b3351/zensical-0.0.65-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64ff25ec94c16853ab0d9bd3cc4a0a569d1381a116a316bd10daab41ec26b3ff", size = 15646714, upload-time = "2026-09-24T18:07:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fb/7e33ed0bafd8d2803b1bb66a540923a6020ac5dc0f4deee835215a5f3838/zensical-0.0.65-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:24f3d65ebdadc58519b0c72065777ed4767e9b4935a21f471798cdfe96fa948f", size = 15483203, upload-time = "2026-09-24T18:07:32.431Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0f/7b1b9eb2f06ef5aba4efb5060a7289e2082ead11ed372ef10acdcfa5dad4/zensical-0.0.65-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:db9a1b36d08540072e3eb59841b0d6485de51d8c866fb6af8c4e5b771c6b681b", size = 15880927, upload-time = "2026-09-24T18:07:35.258Z" }, + { url = "https://files.pythonhosted.org/packages/57/a3/2a2cd8c4ca853b05c1a5ed1dacf6dc2b20a7de331d55acb1158735795ee5/zensical-0.0.65-cp310-abi3-win_amd64.whl", hash = "sha256:976fefefd5219811db680cd0afc40ec9b14be49da026e31c65c8a4a45e0021a9", size = 15844306, upload-time = "2026-09-24T18:07:38.118Z" }, + { url = "https://files.pythonhosted.org/packages/d7/28/ac8372a5c37eaa0deaef544d5119424fbc7947f8fee18359997adb1d8aac/zensical-0.0.65-cp310-abi3-win_arm64.whl", hash = "sha256:d4b11445ff93a4117e74125d9919516c7d37f63caf9472dd985b39a8871ba649", size = 15434392, upload-time = "2026-09-24T18:07:40.853Z" }, +] From 13ce82d6adcb5a03eb6a6428fac3a4a49eaaa03d Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 03:32:01 +0300 Subject: [PATCH 08/23] Document the API reference site CONTRIBUTING.md says how to build and preview the site, what fails the build and why the build runs with --clean, how to add a page, an API class or a model, and that only a release or a manual run deploys it. README.md links to the site. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 50 +++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 3 +++ 2 files changed, 53 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83cc928..1a62bac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -466,6 +466,56 @@ uv run python .github/scripts/api_coverage.py snapshot pdp /tmp/pdp-openapi.json Commit the snapshot together with the allowlist entries for whatever it adds. +## The API reference site + + is the SDK's API reference, generated from its +docstrings and type annotations by [Zensical](https://zensical.org/) and mkdocstrings. +`mkdocs.yml` configures it, the pages are under `docs/`, and the tools are exact pins in the +`docs` dependency group. Guides stay on docs.permit.io, which every page links to. + +Build it the way CI does, into `site/` (gitignored): + +```sh +uv run --locked --group docs zensical build --strict --clean +``` + +- `--strict` fails the build on a broken link to a page, a missing anchor, a cross-reference + that resolves to nothing, or a `:::` line that names no object. +- Griffe's docstring warnings, such as an `Args:` entry for a parameter the function does not + have, do not fail it: the build prints them as `griffe: :: ` and still + ends with "No issues found". The `docs` job in `.github/workflows/test.yml` fails on them + too, so fix every one. +- `--clean` empties Zensical's page cache (`.cache/`, gitignored). Without it, a build renders + only the pages whose sources changed, and does not print the warnings of the pages it + skips. + +To preview the site while editing, run +`uv run --locked --group docs zensical serve` and open . It rebuilds on +every change to `docs/`, the SDK, the Griffe extension, `README.md` or `MIGRATION.md`. + +Docstrings are Google style, and their examples are fenced code blocks (```` ```python ````), +which render as code. `scripts/docs_griffe_extension.py` makes the pages show what a type +checker sees: the blocking classes come from `permit/_sync_types.pyi`, a method decorated as +deprecated gets a `deprecated` label and its decorator's message, and a pydantic field's +`Field(description=...)` becomes its docstring. `tests/test_docs_griffe_extension.py`, part +of the offline suite, checks it. + +- **A page.** Write it under `docs/` and add it to `nav` in `mkdocs.yml`. A line + `::: permit.module.Name` renders that object. The home page and "Upgrading to 3.0" include + `README.md` and `MIGRATION.md`: edit those files, and link from them with absolute URLs, + which work on GitHub, PyPI and the site alike. +- **An API class.** Copy a page under `docs/reference/api/`: it documents the async class, + then its blocking twin from `permit.api.sync_api_client`. Add the page to `nav` and to the + table in `docs/reference/api/index.md`. +- **A model.** `docs/reference/models.md` lists the models of `permit.api.models` that a + public method takes or returns. When a method starts or stops using one, + `tests/test_docs_griffe_extension.py` fails and names it; add it to, or remove it from, + the alphabetical `members` list on that page. + +Pull requests build the site but never deploy it. Publishing a GitHub release deploys it to +GitHub Pages, and so does starting the deploy workflow by hand (Run workflow). The site has +one version, the latest release's. + ## Building ```sh diff --git a/README.md b/README.md index b23bdc2..4fe3784 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ pip install permit [Read the documentation at Permit.io website](https://docs.permit.io/sdk/python/quickstart-python) +The [API reference](https://permitio.github.io/permit-python/) documents every public class, +method and model of the SDK. + ## Upgrading from 2.x permit 3.0.0 requires Python 3.10 or later and raises the minimum versions of its dependencies. From b0b093bbaf97029e14ed47f4b32e03fdf2516b85 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 03:43:18 +0300 Subject: [PATCH 09/23] Document the docs build gate and deploy workflow in CONTRIBUTING The site section now builds through .github/scripts/check_docs_build.py, as the docs job does, says what its exit codes mean, and names docs-deploy.yml, its prerelease skip and the v* tag rule of the github-pages environment. The CI scripts' tests section lists the gate's tests, as the Audit Script Tests job runs them. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 44 +++++++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a62bac..8c75f96 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -130,18 +130,20 @@ See [skills/tests/README.md](skills/tests/README.md). ### The CI scripts' tests -`.github/scripts` holds the dependency audit's report formatter, the schema drift check and -the API coverage report, with their tests, and the tests of the `CI` job, the job-list check -and the local actions' shellcheck (see [CI](#ci)). They need only pytest and the standard -library, and run with their own pytest config, which turns every warning into an error. -`test_ci_checks.py` also runs the bash of those three steps, read from `test.yml`, so it -needs bash, jq, [yq](https://github.com/mikefarah/yq) v4 and shellcheck on `PATH`, as -GitHub's runners have them. The command is the one the `Audit Script Tests` job runs: +`.github/scripts` holds the dependency audit's report formatter, the schema drift check, the +API coverage report and the docs build gate, with their tests, and the tests of the `CI` job, +the job-list check and the local actions' shellcheck (see [CI](#ci)). They need only pytest +and the standard library, and run with their own pytest config, which turns every warning +into an error. `test_ci_checks.py` also runs the bash of those three steps, read from +`test.yml`, so it needs bash, jq, [yq](https://github.com/mikefarah/yq) v4 and shellcheck on +`PATH`, as GitHub's runners have them; `test_check_docs_build.py` reads both workflows with +yq. The command is the one the `Audit Script Tests` job runs: ```sh uv run --only-dev pytest -c .github/scripts/pytest.ini \ .github/scripts/test_format_audit.py .github/scripts/test_check_schema_drift.py \ - .github/scripts/test_api_coverage.py .github/scripts/test_ci_checks.py + .github/scripts/test_api_coverage.py .github/scripts/test_ci_checks.py \ + .github/scripts/test_check_docs_build.py ``` ### End-to-end tests @@ -476,19 +478,28 @@ docstrings and type annotations by [Zensical](https://zensical.org/) and mkdocst Build it the way CI does, into `site/` (gitignored): ```sh -uv run --locked --group docs zensical build --strict --clean +uv run --locked --group docs python .github/scripts/check_docs_build.py ``` +That runs `zensical build --strict --clean` under the docs build gate, which the `docs` job +in `.github/workflows/test.yml` runs too, and which fails on any warning in the build log: + - `--strict` fails the build on a broken link to a page, a missing anchor, a cross-reference that resolves to nothing, or a `:::` line that names no object. - Griffe's docstring warnings, such as an `Args:` entry for a parameter the function does not - have, do not fail it: the build prints them as `griffe: :: ` and still - ends with "No issues found". The `docs` job in `.github/workflows/test.yml` fails on them - too, so fix every one. + have, do not fail the build: it prints them as `griffe: :: ` and still + ends with "No issues found". The gate fails on those lines, so fix every one. - `--clean` empties Zensical's page cache (`.cache/`, gitignored). Without it, a build renders only the pages whose sources changed, and does not print the warnings of the pages it skips. +The gate exits 0 when the build passed, 1 when it failed or logged a warning (it lists each +one at the end), and 2 when the build did not run to the end, so there is no result: +`zensical` could not be started, a signal stopped the build, or the build wrote no +`site/index.html`. An error from `uv run` itself, such as an out-of-date `uv.lock`, comes +before the gate starts, so no verdict follows it; CI installs the docs group in a step of its +own, which fails instead. + To preview the site while editing, run `uv run --locked --group docs zensical serve` and open . It rebuilds on every change to `docs/`, the SDK, the Griffe extension, `README.md` or `MIGRATION.md`. @@ -512,9 +523,12 @@ of the offline suite, checks it. `tests/test_docs_griffe_extension.py` fails and names it; add it to, or remove it from, the alphabetical `members` list on that page. -Pull requests build the site but never deploy it. Publishing a GitHub release deploys it to -GitHub Pages, and so does starting the deploy workflow by hand (Run workflow). The site has -one version, the latest release's. +Pull requests build the site but never deploy it. `.github/workflows/docs-deploy.yml` +(Deploy Docs) builds it through the same gate and deploys it to GitHub Pages when a release +is published, except a prerelease, and when started by hand with Run workflow. The site has +one version, the latest release's. The `github-pages` environment accepts deploys from `main` +and from `v*` tags only, so a release tagged `X.Y.Z` without the `v` publishes to PyPI but +cannot deploy the site; run Deploy Docs from `main` instead. ## Building From a6b1780d1366cb580565b2b59c8f0eeeb8db65c0 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 03:43:33 +0300 Subject: [PATCH 10/23] Correct the docs build gate's note on uv errors uv run --locked exits 1, not 2, when uv.lock is out of date, so a uv error does not always read as "did not run". Say that such an error comes before the gate and prints no verdict, and that CI installs the docs group in its own step, which is where such an error fails. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check_docs_build.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/scripts/check_docs_build.py b/.github/scripts/check_docs_build.py index c0c426f..b5ddbe1 100755 --- a/.github/scripts/check_docs_build.py +++ b/.github/scripts/check_docs_build.py @@ -13,8 +13,10 @@ uv run --locked --group docs python .github/scripts/check_docs_build.py -When uv.lock is out of date or the docs group cannot be installed, uv stops -before the gate starts, with exit status 2: a build that did not run, as below. +An error from uv itself, such as an out-of-date uv.lock (exit 1) or a group +that does not exist (exit 2), comes before the gate starts, and no verdict +follows it. CI installs the docs group in a step of its own (uv sync --locked +--group docs), so there such an error fails that step, not the build step. The default command passes --clean: Zensical caches rendered pages in .cache/ and does not render an unchanged page again, so without it a second build would From a2f53a41f04f41259eeef3b1aedfeb4afa61dd19 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 03:43:49 +0300 Subject: [PATCH 11/23] Point the docs config comments at the docs build gate The docs group's comment gives the gate's command, the one CI runs, and mkdocs.yml says what else has to change with site_dir. Co-Authored-By: Claude Opus 5.5 --- mkdocs.yml | 5 ++++- pyproject.toml | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 9bd84b4..4921946 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,7 +11,10 @@ repo_name: permitio/permit-python # The pages are generated from docstrings, so an edit link would point at the wrong file. edit_uri: "" copyright: Copyright © Permit.io -# Where the build writes the site, which the deploy workflow publishes. Gitignored. +# Where the build writes the site, which the deploy workflow publishes. Gitignored. The +# docs build gate (.github/scripts/check_docs_build.py) checks that the build wrote +# site/index.html, and docs-deploy.yml uploads site/. To change it, pass the new directory +# to the gate as --site-dir in test.yml and docs-deploy.yml, and change the upload path. site_dir: site # Rebuild when these change, not only docs/: the reference pages come from the SDK's # source, through the Griffe extension, and two pages include the Markdown files. diff --git a/pyproject.toml b/pyproject.toml index c4715c1..f8696c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -132,9 +132,10 @@ dev = [ # CVE-2026-21860, CVE-2026-27199). "werkzeug==3.1.8", ] -# The API reference site (mkdocs.yml; CONTRIBUTING.md, "The API reference site"): -# `uv run --locked --group docs zensical build --strict --clean`. Exact pins, like the dev -# group, so a local build and CI render the same site with the same warnings. +# The API reference site (mkdocs.yml; CONTRIBUTING.md, "The API reference site"), built +# through the docs build gate: +# `uv run --locked --group docs python .github/scripts/check_docs_build.py`. Exact pins, like +# the dev group, so a local build and CI render the same site with the same warnings. docs = [ # Also in the dev group, at the same version: see there. "griffelib==2.3.0", From 631fb62ca2b8e22edf41e3cd809fe447b28ed2ed Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 03:44:03 +0300 Subject: [PATCH 12/23] Group pymdown-extensions with the other docs tools for Dependabot The docs group pins it directly, and a new release of it can change how the site's pages render, so its bumps belong in the docs-tools PR. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 18abf6c..dc16622 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -40,12 +40,13 @@ updates: # runtime floor bumps grouped below, so they get a PR of their own. lint-tools: patterns: ["ruff", "mypy", "typos"] - # The docs group's site builder and the API reference plugin and parser - # (PER-16775). Zensical is alpha, so any release can change what the docs - # job's gate sees; a bump that breaks the site build gets a PR of its own - # rather than holding up the runtime floor bumps. + # The docs group's site builder, the API reference plugin and parser, and + # the Markdown extensions mkdocs.yml enables (PER-16775). Zensical is + # alpha, so any release can change what the docs job's gate sees; a bump + # that breaks the site build gets a PR of its own rather than holding up + # the runtime floor bumps. docs-tools: - patterns: ["zensical", "mkdocstrings*", "griffe*"] + patterns: ["zensical", "mkdocstrings*", "griffe*", "pymdown-extensions"] minor-and-patch: update-types: ["minor", "patch"] ignore: From 35f794517db49dfd97a00727555a19e15ab2142d Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 03:44:15 +0300 Subject: [PATCH 13/23] Say the site documents the models the methods use, not every model The models page lists the models that public methods take or return and links to the REST API reference for the rest, so "every model" in the README line and the site description was wrong. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- mkdocs.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4fe3784..c06e970 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ pip install permit [Read the documentation at Permit.io website](https://docs.permit.io/sdk/python/quickstart-python) -The [API reference](https://permitio.github.io/permit-python/) documents every public class, -method and model of the SDK. +The [API reference](https://permitio.github.io/permit-python/) documents every public class +and method of the SDK, and the models they take and return. ## Upgrading from 2.x diff --git a/mkdocs.yml b/mkdocs.yml index 4921946..1e97e6c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -4,8 +4,8 @@ site_name: permit-python API reference site_url: https://permitio.github.io/permit-python/ site_description: >- - The API reference of permit, the Permit.io Python SDK: every public class, method and - model, with the types a type checker sees. + The API reference of permit, the Permit.io Python SDK: every public class and method, + with the types a type checker sees, and the models they take and return. repo_url: https://github.com/permitio/permit-python repo_name: permitio/permit-python # The pages are generated from docstrings, so an edit link would point at the wrong file. From b4bc0e3e8bc0332c42e2e01d578e2a15afb9a102 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 04:25:02 +0300 Subject: [PATCH 14/23] Fail the docs gate on a warning from any logger Zensical sets up no logging handler, so Python printed a record logged by any logger other than Griffe's and mkdocstrings' as its bare message. The gate could not tell that line from the rest of the log, and the build passed. The gate's default build now runs Zensical's command line with this script's interpreter under a root handler that prints each record of level WARNING and up as `LEVEL:logger:message`, which the gate already fails on. When zensical is not installed for that interpreter, the gate exits 2 (did not run) with the command to use. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check_docs_build.py | 70 +++++++++++++++-------- .github/scripts/test_check_docs_build.py | 73 ++++++++++++++++++++++-- CONTRIBUTING.md | 12 ++-- 3 files changed, 122 insertions(+), 33 deletions(-) diff --git a/.github/scripts/check_docs_build.py b/.github/scripts/check_docs_build.py index b5ddbe1..a3c53da 100755 --- a/.github/scripts/check_docs_build.py +++ b/.github/scripts/check_docs_build.py @@ -4,12 +4,14 @@ Runs the site build, streams its log, and reads every line of it. Zensical's --strict fails the build on Zensical's own diagnostics: a link to a page or an anchor that does not exist, an unresolved cross-reference or link reference. It -does not count what Griffe and mkdocstrings log while they read the SDK's -docstrings: Zensical sets up no logging handler, so Python prints those records -(level WARNING and up) as bare lines, each starting with the logger's package -name, and the build still exits 0. This script fails on those lines too. +does not count what Griffe, mkdocstrings, Markdown extensions or the Griffe +extension log while the build renders the pages, and the build still exits 0. +Zensical sets up no logging handler, so Python would print such a record as its +bare message. The default build therefore runs Zensical's command line under a +root handler that prints each record of level WARNING and up with its level and +logger (`WARNING::`), and this script fails on those lines. -Run it in the docs environment, where zensical is on PATH: +Run it in the docs environment, where zensical is installed: uv run --locked --group docs python .github/scripts/check_docs_build.py @@ -18,22 +20,25 @@ follows it. CI installs the docs group in a step of its own (uv sync --locked --group docs), so there such an error fails that step, not the build step. -The default command passes --clean: Zensical caches rendered pages in .cache/ -and does not render an unchanged page again, so without it a second build would -not repeat the Griffe warnings of the first. Zensical has no option for the -output directory; it writes to site_dir in mkdocs.yml, which --site-dir must name. +The default build is `zensical build --strict --clean`, run with this script's +interpreter. --clean matters: Zensical caches rendered pages in .cache/ and does +not render an unchanged page again, so without it a second build would not +repeat the Griffe warnings of the first. Zensical has no option for the output +directory; it writes to site_dir in mkdocs.yml, which --site-dir must name. A +command given after -- runs as given, without the logging handler. Contract (test.yml's docs job and docs-deploy.yml depend on it): * Exit 0: the build exited 0, its log holds no warning, and it wrote /index.html. * Exit 1: the build exited non-zero, or its log holds a warning: a Zensical - diagnostic (`Warning: ...` or `Error: ...`), a Griffe or mkdocstrings record - (`griffe: ...`, `mkdocstrings: ...`), a record printed with its level - (`WARNING ...`, as MkDocs prints them), or a Python warning - (`path:line: SomeWarning: ...`). -* Exit 2: the build did not run to the end, so there is no result: the command - could not be started, a signal stopped it, or it exited 0 without writing + diagnostic (`Warning: ...` or `Error: ...`), a record printed with its level + (`WARNING:...`, as the default build prints them, or `WARNING - ...`, as + MkDocs does), a Griffe or mkdocstrings record printed without one (`griffe: ...`, + `mkdocstrings: ...`), or a Python warning (`path:line: SomeWarning: ...`). +* Exit 2: the build did not run to the end, so there is no result: zensical is + not installed for this interpreter (default build only), the command could + not be started, a signal stopped it, or it exited 0 without writing /index.html (one that is still the file it was before the build does not count). Any error in this script is also exit 2, never a pass. * The build's stdout and stderr are streamed to stdout as they arrive. The @@ -46,6 +51,7 @@ from __future__ import annotations import argparse +import importlib.util import re import shlex import subprocess @@ -53,7 +59,15 @@ import traceback from pathlib import Path -DEFAULT_COMMAND = "zensical build --strict --clean" +ZENSICAL_ARGUMENTS = ("build", "--strict", "--clean") +# Zensical's command line, as its `zensical` script runs it, under a root logging handler that +# prints each record with its level and logger, so that LOGGED_WARNINGS can find every one. +ZENSICAL_UNDER_A_LOGGING_HANDLER = ( + "import logging, sys\n" + "logging.basicConfig(level=logging.WARNING, format='%(levelname)s:%(name)s:%(message)s')\n" + "from zensical.main import cli\n" + "sys.exit(cli(prog_name='zensical'))\n" +) DEFAULT_SITE_DIR = Path("site") RUN_IN_DOCS_ENVIRONMENT = "uv run --locked --group docs python .github/scripts/check_docs_build.py" @@ -64,12 +78,13 @@ # The first line of the box Zensical draws under a diagnostic: `╭─[ index.md:3:5 ]`. ZENSICAL_LOCATION = re.compile(r"^╭─\[\s*(?P[^\]]+?)\s*\]$") LOGGED_WARNINGS = ( - # mkdocstrings' logger adapters, which Griffe's loggers go through too, start - # every message with the package name of the logger. - re.compile(r"^(?:griffe|mkdocstrings|mkdocstrings_handlers|mkdocs_autorefs): "), - # A record printed with its level, as MkDocs (`WARNING - ...`) and - # logging.basicConfig (`WARNING:griffe:...`) print them. + # A record printed with its level, as the default build's handler + # (`WARNING:mkdocs.plugins.griffe:griffe: ...`) and MkDocs (`WARNING - ...`) print them. re.compile(r"^(?:WARNING|ERROR|CRITICAL)\b"), + # The same records of Griffe and mkdocstrings from a build with no logging handler, such + # as a command given after --: their logger adapters start each message with the package + # name of the logger. + re.compile(r"^(?:griffe|mkdocstrings|mkdocstrings_handlers|mkdocs_autorefs): "), # A warning from the warnings module: `path:line: SomeWarning: message`. re.compile(r"^\S.*:\d+: [A-Z]\w*Warning: "), ) @@ -181,12 +196,21 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument( "command", nargs=argparse.REMAINDER, - help=f"the build command, after -- (default: {DEFAULT_COMMAND})", + help=f"the build command, after -- (default: zensical {shlex.join(ZENSICAL_ARGUMENTS)})", ) args = parser.parse_args(argv) command: list[str] = args.command[1:] if args.command[:1] == ["--"] else args.command try: - return gate(command or shlex.split(DEFAULT_COMMAND), args.site_dir) + if not command: + if importlib.util.find_spec("zensical") is None: + print( + f"{PREFIX} the build did not run: zensical is not installed for " + f"{sys.executable}." + ) + print(f" Run the gate in the docs environment: {RUN_IN_DOCS_ENVIRONMENT}") + return 2 + command = [sys.executable, "-c", ZENSICAL_UNDER_A_LOGGING_HANDLER, *ZENSICAL_ARGUMENTS] + return gate(command, args.site_dir) # A gate that broke has no verdict: exit 1 would read as a docs problem with # none listed, and exit 0 as a clean build. except Exception: # noqa: BLE001 - mapped to exit 2 with its traceback diff --git a/.github/scripts/test_check_docs_build.py b/.github/scripts/test_check_docs_build.py index bd89879..f7e97a8 100644 --- a/.github/scripts/test_check_docs_build.py +++ b/.github/scripts/test_check_docs_build.py @@ -5,8 +5,9 @@ the build exits 0; it exits 2, never 0 or 1, when the build did not run to the end; and it streams the build's log as it arrives. No Zensical: each test runs a fake build command that prints a planted log in the format Zensical 0.0.65 -prints, and writes the site or not. The last tests read both workflows with yq -(mikefarah v4) and check that they build the site the same way, through the gate. +prints, and writes the site or not; the default build's tests put a fake zensical +package on PYTHONPATH. The last tests read both workflows with yq (mikefarah v4) +and check that they build the site the same way, through the gate. Run with: uv run --only-dev pytest -c .github/scripts/pytest.ini .github/scripts/test_check_docs_build.py @@ -291,11 +292,14 @@ def test_exit_2_when_the_command_cannot_start(tmp_path: Path) -> None: assert "the build did not run: could not start" in verdict(completed) -def test_exit_2_when_the_default_command_cannot_start(tmp_path: Path) -> None: +def test_exit_2_when_zensical_is_not_installed(tmp_path: Path) -> None: + # -S leaves out site-packages, where the docs group installs zensical. The gate is stdlib + # only, so it runs without them. + environment = {key: value for key, value in os.environ.items() if key != "PYTHONPATH"} completed = subprocess.run( # noqa: S603 - runs the script under test with this interpreter - [sys.executable, str(SCRIPT)], + [sys.executable, "-S", str(SCRIPT)], cwd=tmp_path, - env={"PATH": str(tmp_path)}, + env=environment, capture_output=True, text=True, check=False, @@ -303,7 +307,7 @@ def test_exit_2_when_the_default_command_cannot_start(tmp_path: Path) -> None: ) assert completed.returncode == 2 report = verdict(completed) - assert "could not start 'zensical'" in report + assert "the build did not run: zensical is not installed for" in report assert "Run the gate in the docs environment: uv run --locked --group docs python" in report @@ -333,6 +337,63 @@ def broken(log: list[str]) -> list[str]: assert "ValueError" in captured.err +# --- the default build ------------------------------------------------------------ + + +def fake_zensical(tmp_path: Path, logged: str) -> Path: + """A zensical package whose command line logs `logged` and writes the site. + + `logged` is the body of a function of a logger, run as the build renders the pages. The + package goes on PYTHONPATH, ahead of a real zensical in site-packages. + """ + package = tmp_path / "fake_packages" / "zensical" + package.mkdir(parents=True) + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "main.py").write_text( + "import logging\n" + "import sys\n" + "from pathlib import Path\n" + "\n" + "def cli(prog_name):\n" + " assert prog_name == 'zensical', prog_name\n" + " assert sys.argv[1:] == ['build', '--strict', '--clean'], sys.argv\n" + " print('Build started', flush=True)\n" + " logger = logging.getLogger('some_extension')\n" + f" {logged}\n" + " Path('site').mkdir()\n" + " Path('site', 'index.html').write_text('', encoding='utf-8')\n", + encoding="utf-8", + ) + return package.parent + + +def run_default_build(tmp_path: Path, logged: str) -> subprocess.CompletedProcess[str]: + packages = fake_zensical(tmp_path, logged) + return subprocess.run( # noqa: S603 - runs the script under test with this interpreter + [sys.executable, str(SCRIPT)], + cwd=tmp_path, + env={**os.environ, "PYTHONPATH": str(packages)}, + capture_output=True, + text=True, + check=False, + timeout=60, + ) + + +def test_the_default_build_runs_zensical_build_strict_clean(tmp_path: Path) -> None: + completed = run_default_build(tmp_path, "logger.info('rendered a page')") + assert completed.returncode == 0, completed.stdout + completed.stderr + assert "rendered a page" not in completed.stdout + + +@pytest.mark.parametrize("level", ["warning", "error"]) +def test_the_default_build_fails_on_a_record_of_any_logger(tmp_path: Path, level: str) -> None: + # Without a handler, Python would print the bare message, which no pattern can match. + completed = run_default_build(tmp_path, f"logger.{level}('planted record')") + assert completed.returncode == 1, completed.stdout + completed.stderr + assert f" {level.upper()}:some_extension:planted record\n" in verdict(completed) + + # --- streaming ------------------------------------------------------------------ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c75f96..2f7783e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -486,16 +486,20 @@ in `.github/workflows/test.yml` runs too, and which fails on any warning in the - `--strict` fails the build on a broken link to a page, a missing anchor, a cross-reference that resolves to nothing, or a `:::` line that names no object. -- Griffe's docstring warnings, such as an `Args:` entry for a parameter the function does not - have, do not fail the build: it prints them as `griffe: :: ` and still - ends with "No issues found". The gate fails on those lines, so fix every one. +- What the build logs while it renders the pages does not fail it: Griffe's docstring + warnings, such as an `Args:` entry for a parameter the function does not have, and any + warning from mkdocstrings, a Markdown extension or the Griffe extension. The build still + ends with "No issues found". The gate runs Zensical under a logging handler that prints each + of those records with its level, as + `WARNING:mkdocs.plugins.griffe:griffe: :: `, and fails on those lines, + so fix every one. - `--clean` empties Zensical's page cache (`.cache/`, gitignored). Without it, a build renders only the pages whose sources changed, and does not print the warnings of the pages it skips. The gate exits 0 when the build passed, 1 when it failed or logged a warning (it lists each one at the end), and 2 when the build did not run to the end, so there is no result: -`zensical` could not be started, a signal stopped the build, or the build wrote no +`zensical` is not installed, a signal stopped the build, or the build wrote no `site/index.html`. An error from `uv run` itself, such as an out-of-date `uv.lock`, comes before the gate starts, so no verdict follows it; CI installs the docs group in a step of its own, which fails instead. From 928ac13b96cb6b816849a82cfe93038e5f50f504 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 04:26:42 +0300 Subject: [PATCH 15/23] List the error that stopped the docs build in the gate's verdict When a plugin or the Griffe extension raised, the gate exited 1 but its verdict listed only knock-on lines, such as links to the page that failed. The exception was only in the middle of the log. The gate now reads each Python traceback in the log and lists the exception it ends with first, before the warnings. That is Zensical's `RuntimeError: Python error: ` line for a plugin error. A traceback in a build that exited 0 also fails the gate. Zensical's own "Aborted because --strict flag is set" is left out, since the verdict lists the diagnostics that caused it. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check_docs_build.py | 47 +++++++++++++++++-- .github/scripts/test_check_docs_build.py | 60 ++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/.github/scripts/check_docs_build.py b/.github/scripts/check_docs_build.py index a3c53da..30efa72 100755 --- a/.github/scripts/check_docs_build.py +++ b/.github/scripts/check_docs_build.py @@ -35,14 +35,17 @@ diagnostic (`Warning: ...` or `Error: ...`), a record printed with its level (`WARNING:...`, as the default build prints them, or `WARNING - ...`, as MkDocs does), a Griffe or mkdocstrings record printed without one (`griffe: ...`, - `mkdocstrings: ...`), or a Python warning (`path:line: SomeWarning: ...`). + `mkdocstrings: ...`), a Python warning (`path:line: SomeWarning: ...`), or a + Python traceback. * Exit 2: the build did not run to the end, so there is no result: zensical is not installed for this interpreter (default build only), the command could not be started, a signal stopped it, or it exited 0 without writing /index.html (one that is still the file it was before the build does not count). Any error in this script is also exit 2, never a pass. * The build's stdout and stderr are streamed to stdout as they arrive. The - verdict follows on stdout, with one line per warning, a Zensical diagnostic + verdict follows on stdout. It lists the exception each traceback ends with + first, since a crash also leaves knock-on warnings, such as a page that links + to the page that failed. Then one line per warning, a Zensical diagnostic prefixed with the place it points at. Stdlib only. @@ -89,6 +92,11 @@ re.compile(r"^\S.*:\d+: [A-Z]\w*Warning: "), ) +TRACEBACK = "Traceback (most recent call last):" +# How Zensical ends the build when --strict stops it on its diagnostics, which the +# verdict lists already. +STRICT_ABORT = "RuntimeError: Aborted because --strict flag is set" + PREFIX = "Docs build gate:" @@ -114,6 +122,34 @@ def find_warnings(log: list[str]) -> list[str]: return warnings +def find_errors(log: list[str]) -> list[str]: + """Return the exception each Python traceback in a build log ends with. + + A traceback runs from its `Traceback (most recent call last):` line through its indented + lines to the first line that is not indented: the exception. When a plugin raises, Zensical + prints its own traceback, which ends with `RuntimeError: Python error: `, + then the plugin's, which ends at a blank line with no exception line of its own. + + Args: + log: The build's output, one line per item, colour codes included. + + Returns: + Each exception line without its colour codes, in log order, except Zensical's own + line for a build that --strict stopped. + """ + errors: list[str] = [] + in_traceback = False + for raw in log: + line = ANSI_ESCAPE.sub("", raw).rstrip() + if line == TRACEBACK: + in_traceback = True + elif in_traceback and not line[:1].isspace(): + in_traceback = False + if line and line != STRICT_ABORT: + errors.append(line) + return errors + + def file_version(path: Path) -> tuple[int, int] | None: """Return the inode and modification time of a file, or None if there is none. @@ -163,11 +199,16 @@ def gate(command: list[str], site_dir: Path) -> int: if returncode < 0: print(f"{PREFIX} the build did not run to the end: signal {-returncode} stopped it.") return 2 + errors = find_errors(log) warnings = find_warnings(log) - if returncode != 0 or warnings: + if returncode != 0 or errors or warnings: print(f"{PREFIX} failed.") if returncode != 0: print(f" The build exited with status {returncode}.") + if errors: + print(f" The build raised {len(errors)} Python error(s):") + for error in errors: + print(f" {error}") if warnings: print(f" The build log has {len(warnings)} warning or error line(s):") for warning in warnings: diff --git a/.github/scripts/test_check_docs_build.py b/.github/scripts/test_check_docs_build.py index f7e97a8..a37c64e 100644 --- a/.github/scripts/test_check_docs_build.py +++ b/.github/scripts/test_check_docs_build.py @@ -63,6 +63,25 @@ def zensical_diagnostic(message: str, where: str) -> str: " sys.exit(cli())\n" "RuntimeError: Aborted because --strict flag is set\n" ) +# What Zensical 0.0.65 prints when the Griffe extension raises: its own traceback, which ends +# with the extension's exception, then the extension's, which ends at a blank line. +PLUGIN_ERROR = "RuntimeError: Python error: ValueError: Cannot read the deprecation message 'M'" +PLUGIN_CRASH = ( + "Traceback (most recent call last):\n" + ' File "/venv/bin/zensical", line 10, in \n' + " sys.exit(cli())\n" + " ^^^^^\n" + ' File "/venv/lib/zensical/main.py", line 81, in execute_build\n' + " build(os.path.abspath(config_file), kwargs)\n" + f"{PLUGIN_ERROR}\n" + "Traceback (most recent call last):\n" + ' File "/venv/lib/zensical/markdown/render.py", line 103, in render\n' + " content = md.convert(content)\n" + " ^^^^^^^^^^^^^^^^^^^\n" + ' File "/repo/scripts/docs_griffe_extension.py", line 78, in _evaluate_message\n' + " raise ValueError(msg)\n" + "\n" +) def fake_build( @@ -192,6 +211,47 @@ def test_a_strict_abort_lists_its_diagnostics_and_the_exit_status(tmp_path: Path assert "The build exited with status 1." in report assert " api.md:3:14: Warning: anchor does not exist\n" in report assert " index.md:3:5: Warning: page does not exist\n" in report + assert "Aborted because --strict" not in report + assert "Python error" not in report + + +def test_the_error_that_stopped_the_build_is_listed_first(tmp_path: Path) -> None: + log = ( + "Build started\n" + + zensical_diagnostic("page does not exist", "reference/index.md:7:18") + + "1 issue found\n" + + PLUGIN_CRASH + ) + completed = run_gate(tmp_path, fake_build(tmp_path, log, exit_code=1, site=None)) + assert completed.returncode == 1 + report = verdict(completed) + assert f" The build raised 1 Python error(s):\n {PLUGIN_ERROR}\n" in report + assert report.index(PLUGIN_ERROR) < report.index("reference/index.md:7:18: Warning") + + +def test_a_traceback_fails_a_build_that_exited_0(tmp_path: Path) -> None: + log = ( + "Build started\n" + "Traceback (most recent call last):\n" + ' File "/venv/lib/plugin.py", line 1, in render\n' + "KeyError: 'page'\n" + "\n" + "During handling of the above exception, another exception occurred:\n" + "\n" + "Traceback (most recent call last):\n" + ' File "/venv/lib/plugin.py", line 3, in render\n' + "OSError: [Errno 2] No such file or directory: 'page.md'\n" + "Build finished in 0.3s\n" + ) + completed = run_gate(tmp_path, fake_build(tmp_path, log)) + assert completed.returncode == 1 + report = verdict(completed) + assert "The build raised 2 Python error(s):" in report + assert " KeyError: 'page'\n" in report + assert " OSError: [Errno 2] No such file or directory: 'page.md'\n" in report + assert "During handling" not in report + assert "Build finished" not in report + assert "exited with status" not in report @pytest.mark.parametrize( From 3f8fe3827e702bb70b91dc2386a9f7977fa9ed5c Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 04:29:46 +0300 Subject: [PATCH 16/23] Check every link in the built docs site, not only those in docs/ Zensical checks the links of the Markdown pages under docs/ before it renders them. It does not see the links that rendering adds: those in docstrings, and in README.md and MIGRATION.md, which the home page and the migration guide include. A broken link or anchor there passed the gate. After a build that passed, the gate now reads every page of the built site (except 404.html, which the server returns for any missing URL) and fails with exit 1 on each relative href or src that reaches no page or file of the site, or whose #fragment is no id on the page it reaches. Each broken link is listed once per page, with the built page that has it. Links with a scheme or host and absolute paths are not checked. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/check_docs_build.py | 150 ++++++++++++++++++++--- .github/scripts/test_check_docs_build.py | 83 ++++++++++++- .github/workflows/test.yml | 12 +- CONTRIBUTING.md | 10 +- 4 files changed, 228 insertions(+), 27 deletions(-) diff --git a/.github/scripts/check_docs_build.py b/.github/scripts/check_docs_build.py index 30efa72..b6d42e5 100755 --- a/.github/scripts/check_docs_build.py +++ b/.github/scripts/check_docs_build.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Build the API reference site and fail on any warning in the build log. +"""Build the API reference site; fail on any warning in its log or broken link in it. Runs the site build, streams its log, and reads every line of it. Zensical's --strict fails the build on Zensical's own diagnostics: a link to a page or an @@ -11,6 +11,13 @@ root handler that prints each record of level WARNING and up with its level and logger (`WARNING::`), and this script fails on those lines. +Zensical checks the links of the Markdown pages under docs/ before it renders +them, so it does not see the links that rendering adds: those in docstrings, +and in README.md and MIGRATION.md, which the home page and the migration guide +include. After a build that passed, this script reads every page of the built +site and fails on a relative link that reaches nothing: a page or file that is +not there, or a #fragment that is no id on the page it reaches. + Run it in the docs environment, where zensical is installed: uv run --locked --group docs python .github/scripts/check_docs_build.py @@ -29,14 +36,18 @@ Contract (test.yml's docs job and docs-deploy.yml depend on it): -* Exit 0: the build exited 0, its log holds no warning, and it wrote - /index.html. +* Exit 0: the build exited 0, its log holds no warning, it wrote + /index.html, and every relative link in the site reaches its page + and anchor. * Exit 1: the build exited non-zero, or its log holds a warning: a Zensical diagnostic (`Warning: ...` or `Error: ...`), a record printed with its level (`WARNING:...`, as the default build prints them, or `WARNING - ...`, as MkDocs does), a Griffe or mkdocstrings record printed without one (`griffe: ...`, `mkdocstrings: ...`), a Python warning (`path:line: SomeWarning: ...`), or a - Python traceback. + Python traceback. Or the build passed, but a page of the site it wrote has a + broken relative link (`href` or `src`). Links with a scheme or host, and + absolute paths, are not checked, nor is 404.html, which the server returns + for any missing URL, so its links are absolute. * Exit 2: the build did not run to the end, so there is no result: zensical is not installed for this interpreter (default build only), the command could not be started, a signal stopped it, or it exited 0 without writing @@ -46,7 +57,8 @@ verdict follows on stdout. It lists the exception each traceback ends with first, since a crash also leaves knock-on warnings, such as a page that links to the page that failed. Then one line per warning, a Zensical diagnostic - prefixed with the place it points at. + prefixed with the place it points at; or one line per broken link, prefixed + with the built page that has it. Stdlib only. """ @@ -55,12 +67,15 @@ import argparse import importlib.util +import posixpath import re import shlex import subprocess import sys import traceback +from html.parser import HTMLParser from pathlib import Path +from urllib.parse import unquote, urlsplit ZENSICAL_ARGUMENTS = ("build", "--strict", "--clean") # Zensical's command line, as its `zensical` script runs it, under a root logging handler that @@ -150,6 +165,86 @@ def find_errors(log: list[str]) -> list[str]: return errors +class PageLinks(HTMLParser): + """The ids of a built page, and what its links and sources point at.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.ids: set[str] = set() + self.links: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + """Record the element's id, or an ``, and its href or src.""" + for name, value in attrs: + if value is None: + continue + if name == "id" or (tag == "a" and name == "name"): + self.ids.add(value) + elif name in {"href", "src"}: + self.links.append(value) + + +# The server returns this page for any URL that has no page, so its links are absolute. +NOT_FOUND_PAGE = "404.html" + + +def link_problem(site_dir: Path, pages: dict[str, PageLinks], page: str, link: str) -> str | None: + """Return why a link on a built page reaches nothing, or None if it does or is not checked. + + Args: + site_dir: The built site. + pages: Every HTML page of the site, by its path in it. + page: The path in the site of the page that has the link. + link: The link, as the page has it. + + Returns: + What is missing, or None for a link that reaches its page or file, and its id, or that + has a scheme or host or is an absolute path. + """ + parts = urlsplit(link) + if parts.scheme or parts.netloc or parts.path.startswith("/"): + return None + target = page + if parts.path: + target = posixpath.normpath(posixpath.join(posixpath.dirname(page), unquote(parts.path))) + if parts.path.endswith("/") or (site_dir / target).is_dir(): + target = posixpath.normpath(posixpath.join(target, "index.html")) + if target == ".." or target.startswith("../"): + return "it points outside the site" + if not (site_dir / target).is_file(): + return f"{target} does not exist" + fragment = unquote(parts.fragment) + if fragment and target in pages and fragment not in pages[target].ids: + return f"{target} has no id {fragment!r}" + return None + + +def find_broken_links(site_dir: Path) -> list[str]: + """Return a line for each relative link of the built site that reaches nothing. + + Args: + site_dir: The built site. + + Returns: + `: : ` for each link, once per page, in page order. + """ + pages: dict[str, PageLinks] = {} + for path in sorted(site_dir.rglob("*.html")): + parser = PageLinks() + parser.feed(path.read_text(encoding="utf-8", errors="replace")) + parser.close() + pages[path.relative_to(site_dir).as_posix()] = parser + broken: list[str] = [] + for page, parser in pages.items(): + if page == NOT_FOUND_PAGE: + continue + for link in dict.fromkeys(parser.links): + problem = link_problem(site_dir, pages, page, link) + if problem is not None: + broken.append(f"{page}: {link}: {problem}") + return broken + + def file_version(path: Path) -> tuple[int, int] | None: """Return the inode and modification time of a file, or None if there is none. @@ -175,6 +270,24 @@ def stream(process: subprocess.Popen[str]) -> list[str]: return log +def print_failure(returncode: int, problems: dict[str, list[str]]) -> None: + """Print the verdict of a failed build. + + Args: + returncode: The build's exit status, printed unless it is 0. + problems: A heading with a `{}` for the count -> the lines under it. A heading with + no lines is left out. + """ + print(f"{PREFIX} failed.") + if returncode != 0: + print(f" The build exited with status {returncode}.") + for heading, lines in problems.items(): + if lines: + print(f" {heading.format(len(lines))}:") + for line in lines: + print(f" {line}") + + def gate(command: list[str], site_dir: Path) -> int: """Run the build, stream its log, print the verdict and return the exit status.""" index = site_dir / "index.html" @@ -202,17 +315,13 @@ def gate(command: list[str], site_dir: Path) -> int: errors = find_errors(log) warnings = find_warnings(log) if returncode != 0 or errors or warnings: - print(f"{PREFIX} failed.") - if returncode != 0: - print(f" The build exited with status {returncode}.") - if errors: - print(f" The build raised {len(errors)} Python error(s):") - for error in errors: - print(f" {error}") - if warnings: - print(f" The build log has {len(warnings)} warning or error line(s):") - for warning in warnings: - print(f" {warning}") + print_failure( + returncode, + { + "The build raised {} Python error(s)": errors, + "The build log has {} warning or error line(s)": warnings, + }, + ) return 1 written = file_version(index) if written is None or written == before: @@ -221,7 +330,14 @@ def gate(command: list[str], site_dir: Path) -> int: f"{index}. Check that site_dir in mkdocs.yml is {site_dir}." ) return 2 - print(f"{PREFIX} passed. The build wrote {site_dir} and logged no warning.") + broken = find_broken_links(site_dir) + if broken: + print_failure(returncode, {"The site has {} broken link(s)": broken}) + return 1 + print( + f"{PREFIX} passed. The build wrote {site_dir} and logged no warning, " + "and every relative link in it reaches its page and anchor." + ) return 0 diff --git a/.github/scripts/test_check_docs_build.py b/.github/scripts/test_check_docs_build.py index a37c64e..0825202 100644 --- a/.github/scripts/test_check_docs_build.py +++ b/.github/scripts/test_check_docs_build.py @@ -91,16 +91,21 @@ def fake_build( exit_code: int = 0, site: str | None = "site", out: str = "", + pages: dict[str, str] | None = None, ) -> list[str]: """Return a build command that prints a planted log and exits with `exit_code`. It prints `out` to stdout and `log` to stderr, as Zensical splits its output, - and writes `site`/index.html unless `site` is None. + and writes `pages` (path in the site -> content; by default an index.html with + no links) under `site`, unless `site` is None. """ script = tmp_path / "fake_build.py" writes_site = ( - f"Path({site!r}).mkdir(parents=True, exist_ok=True)\n" - f"Path({site!r}, 'index.html').write_text('', encoding='utf-8')\n" + "".join( + f"Path({site!r}, {name!r}).parent.mkdir(parents=True, exist_ok=True)\n" + f"Path({site!r}, {name!r}).write_text({content!r}, encoding='utf-8')\n" + for name, content in (pages or {"index.html": ""}).items() + ) if site is not None else "" ) @@ -309,6 +314,78 @@ def test_output_that_is_not_utf8_is_still_read(tmp_path: Path) -> None: assert " griffe: permit/x.py:1: Bad\n" in verdict(completed) +# --- exit 1: a broken link in the site ------------------------------------------ + +# A site in which every relative link reaches its page or file and its id. +LINKED_SITE = { + "index.html": ( + '' + '' + 'Guide Setup' + ' Setup Café' + ' Top Top Home' + ' Search Guides' + ' Absolute Mail' + ' ' + ), + "guide/index.html": ( + '

Setup

Café

' + ' Home Top Legacy' + ' ' + ), + "assets/site.css": "", + "assets/site.js": "", + "assets/logo.png": "", + # Not checked: the theme's skip link has no target here, and relative links would + # resolve against whatever URL the page is returned for. + "404.html": 'Skip Nope', +} + + +def test_a_site_whose_links_all_reach_their_target_passes(tmp_path: Path) -> None: + completed = run_gate(tmp_path, fake_build(tmp_path, pages=LINKED_SITE)) + assert completed.returncode == 0, completed.stdout + assert "every relative link in it reaches its page and anchor" in verdict(completed) + + +@pytest.mark.parametrize( + ("element", "problem"), + [ + ('x', "nope/: nope/index.html does not exist"), + ('x', "CONTRIBUTING.md: CONTRIBUTING.md does not exist"), + ('x', "guide/#nope: guide/index.html has no id 'nope'"), + ('x', "#nope: index.html has no id 'nope'"), + ('x', "../outside/: it points outside the site"), + ('', "assets/gone.png: assets/gone.png does not exist"), + ], + ids=["page", "file", "anchor on another page", "anchor on the page", "outside", "source"], +) +def test_a_broken_link_fails_a_build_that_passed( + tmp_path: Path, element: str, problem: str +) -> None: + pages = {**LINKED_SITE, "index.html": f'{element}'} + completed = run_gate(tmp_path, fake_build(tmp_path, pages=pages)) + assert completed.returncode == 1 + report = verdict(completed) + assert f" The site has 1 broken link(s):\n index.html: {problem}\n" in report + + +def test_a_broken_link_is_listed_once_per_page(tmp_path: Path) -> None: + broken = 'x' + pages = { + **LINKED_SITE, + "index.html": f"{broken}{broken}", + "guide/index.html": f'x{broken}', + } + completed = run_gate(tmp_path, fake_build(tmp_path, pages=pages)) + assert completed.returncode == 1 + report = verdict(completed) + assert " The site has 3 broken link(s):\n" in report + assert report.count(" index.html: #nope: index.html has no id 'nope'\n") == 1 + assert " guide/index.html: ../#nope: index.html has no id 'nope'\n" in report + assert " guide/index.html: #nope: guide/index.html has no id 'nope'\n" in report + + # --- exit 2: the build did not run to the end ----------------------------------- diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ca93b89..739e14a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -771,11 +771,13 @@ jobs: # The API reference site (PER-16775), built from mkdocs.yml with Zensical. The # build runs under .github/scripts/check_docs_build.py, which fails the job when - # the build fails or logs any warning: a broken link or anchor, and the Griffe - # and mkdocstrings warnings about docstrings, which Zensical's --strict does not - # count. docs-deploy.yml builds the site with the same two steps on release and - # publishes it to GitHub Pages (test_check_docs_build.py keeps the steps the - # same); this job only builds it. + # the build fails or logs any warning, including the Griffe and mkdocstrings + # warnings about docstrings, which Zensical's --strict does not count, or when a + # link or anchor is broken: Zensical checks those of docs/, and the gate those of + # the built site, which has the docstrings' and the included README.md's and + # MIGRATION.md's too. docs-deploy.yml builds the site with the same two steps on + # release and publishes it to GitHub Pages (test_check_docs_build.py keeps the + # steps the same); this job only builds it. docs: name: Docs runs-on: ubuntu-24.04 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f7783e..9b89985 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -486,6 +486,12 @@ in `.github/workflows/test.yml` runs too, and which fails on any warning in the - `--strict` fails the build on a broken link to a page, a missing anchor, a cross-reference that resolves to nothing, or a `:::` line that names no object. +- `--strict` checks only the links of the pages under `docs/`, before they are rendered. The + links in docstrings, and in `README.md` and `MIGRATION.md`, which the home page and + "Upgrading to 3.0" include, come later, so the gate reads the built site: each relative + link must reach a page or file of the site, and its `#anchor` an id on that page. It lists + each broken one with the built page that has it, such as + `reference/api/roles/index.html: ../../nope/: reference/nope/index.html does not exist`. - What the build logs while it renders the pages does not fail it: Griffe's docstring warnings, such as an `Args:` entry for a parameter the function does not have, and any warning from mkdocstrings, a Markdown extension or the Griffe extension. The build still @@ -497,8 +503,8 @@ in `.github/workflows/test.yml` runs too, and which fails on any warning in the only the pages whose sources changed, and does not print the warnings of the pages it skips. -The gate exits 0 when the build passed, 1 when it failed or logged a warning (it lists each -one at the end), and 2 when the build did not run to the end, so there is no result: +The gate exits 0 when the build passed, 1 when it failed, logged a warning or left a broken +link (it lists each one at the end), and 2 when the build did not run to the end, so there is no result: `zensical` is not installed, a signal stopped the build, or the build wrote no `site/index.html`. An error from `uv run` itself, such as an out-of-date `uv.lock`, comes before the gate starts, so no verdict follows it; CI installs the docs group in a step of its From 0b9c1f809c81ca901ccdb1d738977491f462fedb Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 04:33:16 +0300 Subject: [PATCH 17/23] Show wait_for_sync() as returning a context manager in the docs Permit.wait_for_sync() is a generator function decorated with contextlib.contextmanager. The site showed its annotation, Generator[Self, None, None], on the async and the blocking client pages, but calling it returns a context manager: a type checker sees contextlib._GeneratorContextManager[Self, None, None]. The Griffe extension now shows the return type of a @contextmanager function as contextlib.AbstractContextManager, that class's public base, of the type the generator yields. It parses the docstring first, so a Yields item without a type still names the yielded type. A return annotation that does not name the yielded type fails the build. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 3 +- scripts/docs_griffe_extension.py | 42 +++++++++++++++++++++- tests/test_docs_griffe_extension.py | 56 +++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b89985..130da48 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -517,7 +517,8 @@ every change to `docs/`, the SDK, the Griffe extension, `README.md` or `MIGRATIO Docstrings are Google style, and their examples are fenced code blocks (```` ```python ````), which render as code. `scripts/docs_griffe_extension.py` makes the pages show what a type checker sees: the blocking classes come from `permit/_sync_types.pyi`, a method decorated as -deprecated gets a `deprecated` label and its decorator's message, and a pydantic field's +deprecated gets a `deprecated` label and its decorator's message, a `@contextmanager` method +returns an `AbstractContextManager` of what it yields, and a pydantic field's `Field(description=...)` becomes its docstring. `tests/test_docs_griffe_extension.py`, part of the offline suite, checks it. diff --git a/scripts/docs_griffe_extension.py b/scripts/docs_griffe_extension.py index 783e2fc..de786bc 100644 --- a/scripts/docs_griffe_extension.py +++ b/scripts/docs_griffe_extension.py @@ -17,6 +17,11 @@ decorator's message, which names the replacement and the release that removes it. The stub has no decorators, so each stub method takes the deprecation of the async method it is generated from. +- Context managers. A generator function decorated with ``contextlib.contextmanager``, such + as ``Permit.wait_for_sync()``, is annotated with the generator it is written as, but calling + it returns a context manager: a type checker sees ``contextlib._GeneratorContextManager``. + Its return type shows as ``contextlib.AbstractContextManager``, that class's public base, + of the type the generator yields. - pydantic v1 models. The ``description`` of a field's ``Field(...)`` becomes the field's docstring, and its default the field's value. @@ -41,6 +46,7 @@ } ) _PYDANTIC_FIELDS = frozenset({"pydantic.Field", "pydantic.v1.Field"}) +_CONTEXT_MANAGER_DECORATORS = frozenset({"contextlib.contextmanager"}) def _in_type_checking_branch(node: ast.AST) -> bool: @@ -107,6 +113,36 @@ def _mark_deprecated( obj.docstring.value = f"{obj.docstring.value}\n\n{notice}" +def _show_as_context_manager(func: griffe.Function) -> None: + """Show a ``@contextmanager`` generator function's return type as a context manager. + + Args: + func: The function, annotated ``-> Generator[Y, ...]`` or ``-> Iterator[Y]``. Its + return type becomes ``AbstractContextManager[Y]``. + + Raises: + ValueError: If the return annotation does not name what the generator yields. + """ + returns = func.returns + if not isinstance(returns, griffe.ExprSubscript): + msg = ( + f"Cannot read what {func.path} yields from its return annotation {returns!s}: " + "annotate it as Generator[...] or Iterator[...]." + ) + raise ValueError(msg) # noqa: TRY004 - a wrong annotation in the source, not a wrong type + if func.docstring is not None: + # Parse the docstring now, while the annotation still names the generator: an item of + # its Yields section that has no type takes the yielded type from it. The rendered page + # reads these parsed sections. + func.docstring.parsed # noqa: B018 - cached on first access + yielded = returns.slice + if isinstance(yielded, griffe.ExprTuple): + yielded = yielded.elements[0] + func.returns = griffe.ExprSubscript( + griffe.ExprName("AbstractContextManager", parent="contextlib"), yielded + ) + + def _document_pydantic_field( attr: griffe.Attribute, field: griffe.ExprCall, call: ast.Call ) -> None: @@ -195,8 +231,12 @@ def on_function_instance( agent: griffe.Visitor | griffe.Inspector, **kwargs: Any, ) -> None: - """Label a function its decorator marks as deprecated.""" + """Label a function its decorator marks as deprecated; show a context manager's type.""" self._read_deprecation(node, func, agent) + if any( + decorator.callable_path in _CONTEXT_MANAGER_DECORATORS for decorator in func.decorators + ): + _show_as_context_manager(func) def on_class_instance( self, diff --git a/tests/test_docs_griffe_extension.py b/tests/test_docs_griffe_extension.py index 3c4d912..0bf7c42 100644 --- a/tests/test_docs_griffe_extension.py +++ b/tests/test_docs_griffe_extension.py @@ -101,6 +101,62 @@ def test_names_bound_in_both_branches_show_the_type_checking_one( assert permit_package["api.models.EmailStr"].is_alias +@pytest.mark.parametrize("path", ["permit.Permit.wait_for_sync", "sync.Permit.wait_for_sync"]) +def test_a_context_manager_returns_what_type_checkers_see( + permit_package: griffe.Module, path: str +) -> None: + # The source annotates the generator, Generator[Self, None, None]; type checkers see + # contextlib._GeneratorContextManager[Self, None, None], whose public base this is. + method = permit_package[path] + assert str(method.returns) == "AbstractContextManager[Self]" + assert method.returns.canonical_path == "contextlib.AbstractContextManager" + # The Yields section still names what the generator yields. + yields = next( + section + for section in method.docstring.parsed + if section.kind is griffe.DocstringSectionKind.yields + ) + assert [str(item.annotation) for item in yields.value] == ["Self"] + + +@pytest.mark.parametrize( + ("annotation", "shown"), + [ + ("Iterator[int]", "AbstractContextManager[int]"), + ("Generator[str, None, None]", "AbstractContextManager[str]"), + ], +) +def test_a_context_manager_shows_the_type_it_yields(annotation: str, shown: str) -> None: + package = { + "__init__.py": "", + "api.py": ( + "from collections.abc import Generator, Iterator\n" + "from contextlib import contextmanager\n" + "@contextmanager\n" + f"def session() -> {annotation}:\n" + " yield 1\n" + ), + } + extensions = griffe.load_extensions(str(EXTENSION)) + with griffe.temporary_visited_package("contexts", package, extensions=extensions) as module: + assert str(module["api.session"].returns) == shown + + +def test_a_context_manager_it_cannot_read_fails_the_load() -> None: + package = { + "__init__.py": "", + "api.py": ( + "from contextlib import contextmanager\n@contextmanager\ndef session():\n yield\n" + ), + } + extensions = griffe.load_extensions(str(EXTENSION)) + with ( + pytest.raises(ValueError, match=r"Cannot read what contexts\.api\.session yields"), + griffe.temporary_visited_package("contexts", package, extensions=extensions), + ): + pass + + @pytest.mark.parametrize( ("path", "replacement"), [ From f889c178ff5acaccf1ffb115162e72cc7a81ada2 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 04:36:33 +0300 Subject: [PATCH 18/23] Read _typing.TYPE_CHECKING blocks in the docs Griffe extension The extension matched the test of an `if TYPE_CHECKING:` block by its text, as `TYPE_CHECKING` or `typing.TYPE_CHECKING`, so it skipped the `if _typing.TYPE_CHECKING:` blocks of permit/api/models.py and permit/__init__.py. The test of the EmailStr binding in models.py then passed without reaching the check that keeps a runtime import, and no test covered the check that only the `if` branch is read. The extension now takes any `if` whose test is the name TYPE_CHECKING or an attribute of that name. The site does not change: the bindings those blocks make are kept as before. New tests visit small packages for each case: the plain and `_typing.` spellings, an `elif`, a runtime import that stays, an import in the runtime branch that is not read, and an `if not TYPE_CHECKING:` that is not read. The EmailStr test now checks the import's target. Co-Authored-By: Claude Opus 5.5 --- scripts/docs_griffe_extension.py | 18 ++++++--- tests/test_docs_griffe_extension.py | 62 ++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/scripts/docs_griffe_extension.py b/scripts/docs_griffe_extension.py index de786bc..dbf6252 100644 --- a/scripts/docs_griffe_extension.py +++ b/scripts/docs_griffe_extension.py @@ -37,7 +37,6 @@ import griffe -_TYPE_CHECKING_TESTS = frozenset({"TYPE_CHECKING", "typing.TYPE_CHECKING"}) _DEPRECATION_DECORATORS = frozenset( { "permit.utils.deprecation.deprecated", @@ -49,14 +48,21 @@ _CONTEXT_MANAGER_DECORATORS = frozenset({"contextlib.contextmanager"}) +def _is_type_checking(test: ast.expr) -> bool: + """Whether an ``if`` tests ``TYPE_CHECKING``, by name or as ``.TYPE_CHECKING``. + + The SDK spells it ``TYPE_CHECKING``, and ``_typing.TYPE_CHECKING`` in the modules that + import ``typing`` as ``_typing`` to keep the name out of their star exports. + """ + if isinstance(test, ast.Name): + return test.id == "TYPE_CHECKING" + return isinstance(test, ast.Attribute) and test.attr == "TYPE_CHECKING" + + def _in_type_checking_branch(node: ast.AST) -> bool: """Whether ``node`` is a statement of the ``if`` branch of an ``if TYPE_CHECKING:``.""" parent = getattr(node, "parent", None) - return ( - isinstance(parent, ast.If) - and node in parent.body - and ast.unparse(parent.test) in _TYPE_CHECKING_TESTS - ) + return isinstance(parent, ast.If) and node in parent.body and _is_type_checking(parent.test) def _evaluate_message(argument: ast.expr, module_path: str) -> str: diff --git a/tests/test_docs_griffe_extension.py b/tests/test_docs_griffe_extension.py index 0bf7c42..648b6b8 100644 --- a/tests/test_docs_griffe_extension.py +++ b/tests/test_docs_griffe_extension.py @@ -97,8 +97,66 @@ def test_names_bound_in_both_branches_show_the_type_checking_one( model_input = permit_package["utils.model_input.ModelInput"] assert model_input.is_attribute assert str(model_input.value) == "_Model | dict[str, Any]" - # A runtime import is left alone: the models import pydantic per major. - assert permit_package["api.models.EmailStr"].is_alias + # A runtime import is left alone: the models import pydantic per major, and bind + # `EmailStr = str` under `if _typing.TYPE_CHECKING:`. + assert permit_package["api.models.EmailStr"].target_path == "pydantic.v1.EmailStr" + + +@pytest.mark.parametrize( + ("branches", "documented"), + [ + (("if TYPE_CHECKING:", " Value = int", "else:", " class Value: ..."), "int"), + (("if _typing.TYPE_CHECKING:", " Value = int", "else:", " class Value: ..."), "int"), + ( + ( + "if TYPE_CHECKING:", + " Value = int", + "elif sys.version_info < (3, 11):", + " class Value: ...", + "else:", + " class Value: ...", + ), + "int", + ), + # A runtime import stays. + ( + ( + "if TYPE_CHECKING:", + " Value = int", + "else:", + " from json import JSONDecoder as Value", + ), + "json.JSONDecoder", + ), + # What the runtime branch imports is not the type checker's binding. + ( + ( + "if TYPE_CHECKING:", + " from json import JSONDecoder as Value", + "else:", + " from json import JSONEncoder as Value", + " class Value: ...", + ), + "json.JSONDecoder", + ), + # The `if` branch of `if not TYPE_CHECKING:` is the runtime's. + (("if not TYPE_CHECKING:", " Value = int", "else:", " class Value: ..."), None), + ], + ids=["TYPE_CHECKING", "_typing.TYPE_CHECKING", "elif", "runtime import", "else import", "not"], +) +def test_the_type_checking_branch_binding_is_documented( + branches: tuple[str, ...], documented: str | None +) -> None: + imports = ("import sys", "import typing as _typing", "from typing import TYPE_CHECKING") + package = {"__init__.py": "", "api.py": "\n".join((*imports, *branches, ""))} + extensions = griffe.load_extensions(str(EXTENSION)) + with griffe.temporary_visited_package("bindings", package, extensions=extensions) as module: + value = module["api"].members["Value"] + if documented is None: + assert not value.is_alias + assert value.is_class + else: + assert value.target_path == documented @pytest.mark.parametrize("path", ["permit.Permit.wait_for_sync", "sync.Permit.wait_for_sync"]) From ed40b6e0863f1a00d649ca106934b03d15a62c01 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 04:37:12 +0300 Subject: [PATCH 19/23] Deploy the docs when a prerelease is changed to a release Deploy Docs ran on `release: published` and skipped prereleases with a job condition. Changing a prerelease to a release fires only the `released` activity, so such a release never deployed the site, and the site stayed on the previous release until someone ran Deploy Docs by hand. The workflow now runs on `released`, which fires when a release that is not a prerelease is published and when a prerelease is changed to a release, and never for a prerelease, so the job condition is gone. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs-deploy.yml | 9 +++++---- CONTRIBUTING.md | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index c445b6a..e7dac2a 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -4,8 +4,10 @@ name: Deploy Docs # https://permitio.github.io/permit-python/. It runs when a release is published # and when started by hand from the Actions tab, never on a push: the site # documents the released SDK, not main. A prerelease does not deploy, since a -# plain `pip install permit` does not install one; a manual run deploys whatever -# ref it is started on. +# plain `pip install permit` does not install one. The `released` activity is +# what does that: it fires when a release that is not a prerelease is published, +# and when a prerelease is changed to a release, which `published` does not fire +# for. A manual run deploys whatever ref it is started on. # # The build is the docs job's in test.yml, with the same install step and the same # gate (.github/scripts/check_docs_build.py; test_check_docs_build.py keeps the @@ -14,7 +16,7 @@ name: Deploy Docs # from v* tags only. on: release: - types: [published] + types: [released] workflow_dispatch: {} permissions: @@ -30,7 +32,6 @@ concurrency: jobs: build: name: Build - if: github.event_name != 'release' || !github.event.release.prerelease runs-on: ubuntu-24.04 # A build takes about a minute; the limit stops a hung one. timeout-minutes: 15 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 130da48..d3bcc08 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -536,10 +536,11 @@ of the offline suite, checks it. Pull requests build the site but never deploy it. `.github/workflows/docs-deploy.yml` (Deploy Docs) builds it through the same gate and deploys it to GitHub Pages when a release -is published, except a prerelease, and when started by hand with Run workflow. The site has -one version, the latest release's. The `github-pages` environment accepts deploys from `main` -and from `v*` tags only, so a release tagged `X.Y.Z` without the `v` publishes to PyPI but -cannot deploy the site; run Deploy Docs from `main` instead. +is published, or a prerelease is changed to a release (a prerelease itself does not deploy), +and when started by hand with Run workflow. The site has one version, the latest release's. +The `github-pages` environment accepts deploys from `main` and from `v*` tags only, so a +release tagged `X.Y.Z` without the `v` publishes to PyPI but cannot deploy the site; run +Deploy Docs from `main` instead. ## Building From 4f9b34777ccf5bf5892786855421f99e2d518e76 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 04:37:23 +0300 Subject: [PATCH 20/23] Say what configure-pages checks in the docs deploy workflow The comment said the step fails when Pages does not publish from GitHub Actions. configure-pages v6.0.0 with enablement off only reads the repository's Pages site and fails when there is none; it does not check the build type. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs-deploy.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index e7dac2a..2c398fd 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -74,8 +74,7 @@ jobs: fi exit "${gate_exit}" - # Fails before the upload when Pages is not enabled for this repository or - # does not publish from GitHub Actions. + # Fails before the upload when Pages is not enabled for this repository. - name: Check the Pages settings uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 From 6b7c5e2f84b606426158e3e70611dc93111a51a1 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 04:37:23 +0300 Subject: [PATCH 21/23] Note that the docs deploy does not wait for the PyPI upload Deploy Docs and the publish workflow run separately on a release. If the publish workflow's Security Gate or its upload fails, the site still deploys the release's API. CONTRIBUTING now says so. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d3bcc08..0e9267a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -540,7 +540,9 @@ is published, or a prerelease is changed to a release (a prerelease itself does and when started by hand with Run workflow. The site has one version, the latest release's. The `github-pages` environment accepts deploys from `main` and from `v*` tags only, so a release tagged `X.Y.Z` without the `v` publishes to PyPI but cannot deploy the site; run -Deploy Docs from `main` instead. +Deploy Docs from `main` instead. Deploy Docs does not wait for the PyPI upload +([Releasing](#releasing)): if the publish workflow fails, the site documents a version PyPI +does not have until the release is fixed. ## Building From 2094957f1d681780d960a8923e429f06b25da957 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 04:37:58 +0300 Subject: [PATCH 22/23] Check that CI and the docs deploy set up the same uv and Python The parity tests compared only the install and build steps of the docs job and the deploy's build job. A change to one workflow's Python version or uv version file would have let the deploy build with a different interpreter, and nothing would have failed. A new test reads the "Install uv" step of both jobs and checks that they use the same action, version-file and python-version, and that the deploy keeps the cache off. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/test_check_docs_build.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.github/scripts/test_check_docs_build.py b/.github/scripts/test_check_docs_build.py index 0825202..bcd0822 100644 --- a/.github/scripts/test_check_docs_build.py +++ b/.github/scripts/test_check_docs_build.py @@ -7,7 +7,8 @@ fake build command that prints a planted log in the format Zensical 0.0.65 prints, and writes the site or not; the default build's tests put a fake zensical package on PYTHONPATH. The last tests read both workflows with yq (mikefarah v4) -and check that they build the site the same way, through the gate. +and check that they build the site the same way, with the same uv and Python, +through the gate. Run with: uv run --only-dev pytest -c .github/scripts/pytest.ini .github/scripts/test_check_docs_build.py @@ -591,6 +592,20 @@ def test_ci_and_the_deploy_build_the_site_the_same_way(name: str) -> None: assert ci == deploy +def test_ci_and_the_deploy_build_the_site_with_the_same_uv_and_python() -> None: + ci = workflow_step("test.yml", "docs", "Install uv") + deploy = workflow_step("docs-deploy.yml", "build", "Install uv") + assert ci["uses"] == deploy["uses"] + ci_inputs = ci["with"] + deploy_inputs = deploy["with"] + assert isinstance(ci_inputs, dict) + assert isinstance(deploy_inputs, dict) + for name in ("version-file", "python-version"): + assert ci_inputs[name] == deploy_inputs[name], name + # The deploy publishes what it builds, so it restores no cache. + assert deploy_inputs["enable-cache"] is False + + def test_the_workflows_build_the_site_through_the_gate() -> None: step = workflow_step("test.yml", "docs", "Build the site") assert "uv run --no-sync python .github/scripts/check_docs_build.py\n" in str(step["run"]) From af2c42b47fa29ed19ea3d17514be8b6bb37e14c2 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 4 Oct 2026 04:46:42 +0300 Subject: [PATCH 23/23] Say in CONTRIBUTING that the docs gate also fails on broken links The site section's lead-in still said the gate fails on warnings in the build log only; it also fails on a broken link in the built site. The exit-code paragraph is rewrapped to the file's line length. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e9267a..76434ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -482,7 +482,8 @@ uv run --locked --group docs python .github/scripts/check_docs_build.py ``` That runs `zensical build --strict --clean` under the docs build gate, which the `docs` job -in `.github/workflows/test.yml` runs too, and which fails on any warning in the build log: +in `.github/workflows/test.yml` runs too, and which fails on any warning in the build log and +on any broken link in the site: - `--strict` fails the build on a broken link to a page, a missing anchor, a cross-reference that resolves to nothing, or a `:::` line that names no object. @@ -504,8 +505,8 @@ in `.github/workflows/test.yml` runs too, and which fails on any warning in the skips. The gate exits 0 when the build passed, 1 when it failed, logged a warning or left a broken -link (it lists each one at the end), and 2 when the build did not run to the end, so there is no result: -`zensical` is not installed, a signal stopped the build, or the build wrote no +link (it lists each one at the end), and 2 when the build did not run to the end, so there is +no result: `zensical` is not installed, a signal stopped the build, or the build wrote no `site/index.html`. An error from `uv run` itself, such as an out-of-date `uv.lock`, comes before the gate starts, so no verdict follows it; CI installs the docs group in a step of its own, which fails instead.