diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0f4b1b35..dc16622f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -40,6 +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, 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*", "pymdown-extensions"] minor-and-patch: update-types: ["minor", "patch"] ignore: diff --git a/.github/scripts/check_docs_build.py b/.github/scripts/check_docs_build.py new file mode 100755 index 00000000..b6d42e5f --- /dev/null +++ b/.github/scripts/check_docs_build.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +"""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 +anchor that does not exist, an unresolved cross-reference or link reference. It +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. + +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 + +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 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, 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. 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 + /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. 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; or one line per broken link, prefixed + with the built page that has it. + +Stdlib only. +""" + +from __future__ import annotations + +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 +# 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" + +# 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 = ( + # 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: "), +) + +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:" + + +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 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 + + +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. + + 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 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" + 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 + errors = find_errors(log) + warnings = find_warnings(log) + if returncode != 0 or errors or warnings: + 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: + 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 + 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 + + +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: zensical {shlex.join(ZENSICAL_ARGUMENTS)})", + ) + args = parser.parse_args(argv) + command: list[str] = args.command[1:] if args.command[:1] == ["--"] else args.command + try: + 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 + 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/pytest.ini b/.github/scripts/pytest.ini index 13ef6052..70628dff 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 new file mode 100644 index 00000000..bcd0822e --- /dev/null +++ b/.github/scripts/test_check_docs_build.py @@ -0,0 +1,611 @@ +"""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; 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, 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 +""" + +from __future__ import annotations + +import json +import os +import shutil +import signal +import subprocess +import sys +import time +from pathlib import Path + +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)) + +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" +) +# 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( + tmp_path: Path, + log: str = CLEAN_LOG, + *, + 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 `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 = ( + "".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 "" + ) + 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 + 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( + "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 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 ----------------------------------- + + +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_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, "-S", str(SCRIPT)], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + check=False, + timeout=60, + ) + assert completed.returncode == 2 + report = verdict(completed) + 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 + + +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 + + +# --- 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 ------------------------------------------------------------------ + + +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 + + +# --- 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_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"]) diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml new file mode 100644 index 00000000..2c398fd2 --- /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. 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 +# 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: [released] + 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 + 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. + - 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 ff6becbb..739e14a5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -769,6 +769,60 @@ 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, 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 + # 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 @@ -1016,17 +1070,19 @@ 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 (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 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 @@ -1188,6 +1244,7 @@ jobs: - comment - compatibility - dependency-review + - docs - migration-skill - pre-commit - pytest @@ -1201,7 +1258,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." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83cc9287..76434ae9 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 @@ -466,6 +468,83 @@ 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 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 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. +- `--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 + 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, 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 +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`. + +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, 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. + +- **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. `.github/workflows/docs-deploy.yml` +(Deploy Docs) builds it through the same gate and deploys it to GitHub Pages when a release +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. 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 ```sh diff --git a/MIGRATION.md b/MIGRATION.md index 680776c2..405a3038 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: diff --git a/README.md b/README.md index b23bdc22..c06e9705 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 +and method of the SDK, and the models they take and return. + ## Upgrading from 2.x permit 3.0.0 requires Python 3.10 or later and raises the minimum versions of its dependencies. diff --git a/docs/clients.md b/docs/clients.md new file mode 100644 index 00000000..0f44d386 --- /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 00000000..612c7a5e --- /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 00000000..0fe9b551 --- /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 00000000..bbd115e9 --- /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 00000000..459ce6fe --- /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 00000000..79ff2325 --- /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 00000000..f5378a69 --- /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 00000000..f6d72c91 --- /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 00000000..05682670 --- /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 00000000..68034e94 --- /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 00000000..1cde7fd6 --- /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 00000000..76afe21d --- /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 00000000..df45fedd --- /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 00000000..88bb95a8 --- /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 00000000..c08bde4a --- /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 00000000..f5ae8877 --- /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 00000000..5fd41e18 --- /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 00000000..4da0ccbd --- /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 00000000..25a0d5bc --- /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 00000000..bd301efa --- /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 00000000..d68ce3e4 --- /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 00000000..00c50cdf --- /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 00000000..75f658c3 --- /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 00000000..f9ccc13f --- /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 00000000..669d1d39 --- /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 00000000..53ff0618 --- /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 00000000..57a310d7 --- /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 00000000..93bd98df --- /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 00000000..c939af7c --- /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 00000000..2d487760 --- /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 00000000..63763d80 --- /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 00000000..fc864dd0 --- /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 00000000..60d4f52f --- /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 00000000..1e97e6c6 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,123 @@ +# 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 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. +edit_uri: "" +copyright: Copyright © Permit.io +# 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. +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 246fea78..f8696c17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,11 @@ 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. 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 # (insecure temporary directory handling), fixed in 9.0.3. Caught by this @@ -127,6 +132,19 @@ dev = [ # CVE-2026-21860, CVE-2026-27199). "werkzeug==3.1.8", ] +# 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", + "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`. @@ -296,6 +314,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 00000000..dbf6252b --- /dev/null +++ b/scripts/docs_griffe_extension.py @@ -0,0 +1,304 @@ +"""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. +- 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. + +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 + +_DEPRECATION_DECORATORS = frozenset( + { + "permit.utils.deprecation.deprecated", + "typing_extensions.deprecated", + "warnings.deprecated", + } +) +_PYDANTIC_FIELDS = frozenset({"pydantic.Field", "pydantic.v1.Field"}) +_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 _is_type_checking(parent.test) + + +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 _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: + """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; 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, + *, + 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 00000000..648b6b83 --- /dev/null +++ b/tests/test_docs_griffe_extension.py @@ -0,0 +1,341 @@ +"""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, pydantic field descriptions, and a +models page that lists exactly the models the SDK's methods take and return. +""" + +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" +MODELS_PAGE = REPO_ROOT / "docs" / "reference" / "models.md" + + +@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, 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"]) +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"), + [ + ("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 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": "", + "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 4f058c51..40989b33 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" }, @@ -284,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" @@ -293,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" @@ -329,7 +348,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 +485,27 @@ 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" +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" @@ -493,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" @@ -636,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" @@ -721,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" @@ -1021,6 +1196,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "griffelib" }, { name = "mypy" }, { name = "packaging" }, { name = "pre-commit" }, @@ -1033,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" } }, ] @@ -1054,6 +1237,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" }, @@ -1066,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" }] @@ -1256,7 +1447,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 +1496,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'" }, @@ -1447,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" @@ -1491,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" @@ -1567,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" @@ -1592,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" @@ -1726,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" @@ -1896,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" }, +]