Skip to content

Add the API reference site, built on every PR and deployed to GitHub Pages on release - #152

Draft
zeevmoney wants to merge 25 commits into
per-16775/docstring-fixesfrom
per-16775/api-reference-site
Draft

zeevmoney wants to merge 25 commits into
per-16775/docstring-fixesfrom
per-16775/api-reference-site

Conversation

@zeevmoney

Copy link
Copy Markdown
Member

Linear issues

Stacked on #151 (docstring fixes), which is stacked on #150 and #144.

Why

permit-python has no API reference of its own; docs.permit.io carries the guides. This adds a reference-only site generated from the SDK's docstrings and type annotations. It shows the signatures a type checker sees, links to docs.permit.io for guides and to the REST API reference for full model schemas, builds on every PR, fails on any docs warning or broken link, and deploys only on release or by hand. It has one version until a second major exists.

What changed

Site (mkdocs.yml, docs/)

  • Built with Zensical 0.0.65 from an MkDocs-format mkdocs.yml (Material theme), with mkdocstrings 1.0.6, mkdocstrings-python 2.0.9 on griffelib 2.3.0, and pymdown-extensions 12.1 (highlight, magiclink, snippets, superfences). Each is pinned exactly in a new docs dependency group, and uv.lock is resolved under the repo's 7-day cooldown. griffelib is also pinned in dev, at the same version, for mypy and the offline tests.
  • Pages: Home (includes README.md), "Upgrading to 3.0" (includes MIGRATION.md), "Async or blocking client", and 29 reference pages: the two clients, configuration, exceptions, enforcement types, an index and one page per permit.api API (the async class, then its blocking twin), the deprecated methods, the PDP API, Elements, and Models. The models page lists the 84 models that public methods take or return, and links to https://api.permit.io/scalar for the rest. A navigation entry links every page to docs.permit.io.

Griffe extension (scripts/docs_griffe_extension.py)

  • Where a name is bound under if TYPE_CHECKING: (or <module>.TYPE_CHECKING) and rebound at runtime by a class or an assignment, the page shows the type checker's binding, matched by full path. So the blocking API classes, SyncElementsApi and SyncEnforcer come from permit/_sync_types.pyi, and the PDP SyncRoleAssignmentsApi gets the PDP stub class, not the REST one. Runtime imports are kept.
  • Labels functions and classes decorated with the SDK's deprecated or a PEP 702 deprecated, and appends the decorator's message. Stub methods take the deprecation of the async method they are generated from.
  • Shows the return type of a @contextmanager function as contextlib.AbstractContextManager[<yielded type>] (Permit.wait_for_sync()). Its Yields section still names the yielded type.
  • Uses a pydantic field's Field(description=...) as its docstring and its default as its value.
  • tests/test_docs_griffe_extension.py: 25 offline tests (0.3 s), including the PDP/REST full-path regression and a check that the models page lists exactly the models of public signatures.

Build gate (.github/scripts/check_docs_build.py, stdlib only)

  • Runs zensical build --strict --clean with its own interpreter, under a root logging handler, so every record of level WARNING and up prints as LEVEL:logger:message. It streams the build log as it runs.
  • Exit 1 when the build fails, or its log has a Zensical diagnostic, a logged warning or error, a Python warning or a traceback (the verdict lists each traceback's exception first). Also exit 1 when a relative href or src in the built site reaches no page, file or anchor (404.html and absolute links are skipped). That covers links in docstrings and in the included README.md and MIGRATION.md, which Zensical's own check does not see.
  • Exit 2 when the build did not run to the end: zensical is not installed for the interpreter, the command could not start, a signal stopped it, or it wrote no new site/index.html.
  • .github/scripts/test_check_docs_build.py: 45 tests, run in the Audit Script Tests job.

CI and deploy

  • test.yml: a new docs job (15-minute timeout, contents: read, SHA-pinned actions, persist-credentials: false) builds the site through the gate. ci.needs includes it, and EXPECTED_JOBS is 11.
  • .github/workflows/docs-deploy.yml (Deploy Docs) runs on release: released and workflow_dispatch. Its build job uses the same uv setup, install step and build step as CI (tests keep them identical), with no cache, then actions/configure-pages v6.0.0 and actions/upload-pages-artifact v5.0.0. Its deploy job has only pages: write and id-token: write, runs in environment: github-pages and uses actions/deploy-pages v5.0.1. The workflow sets concurrency: { group: pages, cancel-in-progress: false }, top-level permissions: contents: read and 15-minute timeouts. Each action is pinned to its release commit SHA.
  • Dependabot: a docs-tools group (zensical, mkdocstrings*, griffe*, pymdown-extensions), listed before minor-and-patch.
  • python-sdk-publish.yml is not changed.

Docs

  • CONTRIBUTING.md: a new section, "The API reference site". It covers building and previewing the site, the gate and its exit codes, adding a page, an API class or a model, and when and how the site deploys.
  • README.md: one sentence linking to the site.
  • MIGRATION.md: the two links to the migration skill are now absolute GitHub URLs, so they work on the site and on PyPI.

Behaviour changes

  • No change to SDK code or docstrings.
  • CI needs the new docs job, so a PR fails CI when the site build fails, logs a warning, or has a broken link or anchor.
  • Publishing a release that is not a prerelease, or changing a prerelease to a release, deploys the site to GitHub Pages. A prerelease does not deploy. Deploy Docs can also be run by hand.
  • Dependabot opens docs tool bumps in their own group.

How it was tested

  • Local build through the gate: exit 0, no warnings, 34 HTML pages in about 4.3 s, about 5,800 relative links checked.
  • The gate on the real build, with planted faults:
    • an Args: entry for a missing parameter: exit 1, listing the 2 Griffe lines;
    • a broken link in a docstring and a wrong anchor in MIGRATION.md: exit 1, each listed with its page;
    • a broken link in docs/: exit 1 (Zensical diagnostic);
    • a warning from a plain logging logger in the extension: exit 1;
    • an unreadable deprecation message, and a missing snippet: exit 1, each listing the exception first;
    • site_dir pointed at another directory: exit 2;
    • zensical not importable: exit 2.
  • Checks on the built HTML:
    • On all 20 permit.api pages, the blocking class has methods, none labelled async and none with Coroutine or Awaitable types, and the async class's methods are labelled async.
    • The PDP SyncRoleAssignmentsApi shows only list(), with resource_instance_key.
    • RoleRead.name shows "The name of the role", and no Field( call appears on any page.
    • DeprecatedApi.get_user, SyncDeprecatedApi.get_user, TenantsApi.add_user, SyncTenantsApi.add_user and PermitException carry the deprecated label and message.
    • No raw ``` fence is left, and the examples are highlighted.
    • wait_for_sync() returns AbstractContextManager[Self] on both client pages, and its See Also URL is a link.
    • The same checks fail on a site built with the TYPE_CHECKING replacement turned off (21 misses).
  • Mutation checks: 35 deliberate breaks of the gate, the extension and the workflows each made at least one test fail.
  • uv run pre-commit run --all-files: pass (ruff, mypy, typos, uv lock --check).
  • mypy, pydantic 2 and pydantic 1 lanes: no issues in 127 files.
  • Offline suite (pytest -m "not e2e"): 1943 passed, 3 skipped, in each of the pydantic 2 and pydantic 1 lanes.
  • .github/scripts tests (including test_ci_checks.py and test_check_docs_build.py): 318 passed. Migration skill tests: 86 passed, 1 skipped.
  • actionlint: clean. zizmor 1.30.1, offline and online: no findings.
  • Not yet run on GitHub: the docs job and Deploy Docs.

Owner actions before merge

  • Prereleases: Deploy Docs runs on released, so a prerelease does not deploy and a prerelease later changed to a release does. To deploy prereleases as well, change it to types: [published].
  • The github-pages environment accepts deploys from main and v* tags only, so tag the release v3.1.0, as 3.0.0 was tagged v3.0.0. A tag without the v publishes to PyPI but cannot deploy the site.
  • Today's ruleset lists six job checks, not Docs, so the docs build blocks merges only once the ruleset requires CI alone (the owner action in Add an aggregate CI check and run every PR job from test.yml #150).
  • After merge: run Deploy Docs from main (or wait for the next release) for the first deploy. Check that configure-pages works with pages: read and that the environment protection lets deploy-pages through.

🤖 Generated with Claude Code

zeevmoney and others added 25 commits October 4, 2026 03:16
check_docs_build.py runs the API reference site build, streams its log
and reads every line. Zensical's --strict fails on broken links and
anchors but not on the Griffe and mkdocstrings warnings about
docstrings, which it prints as bare lines while the build exits 0; the
gate fails on those too. It exits 1 on a failed build or any warning
line, and 2 when the build did not run to the end: the command could
not start, a signal stopped it, or it wrote no index.html. Its tests
run in the Audit Script Tests job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docs job installs the locked docs group and builds the site through
check_docs_build.py, so a pull request fails CI when the site build
fails, logs a docstring warning or has a broken link. CI now needs 11
jobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
docs-deploy.yml runs when a release is published, except a prerelease,
and when started by hand; never on a push. Its build job installs the
docs group and builds the site through check_docs_build.py with the
same two steps as the docs job in test.yml, which the gate's tests
check, then uploads it. Only the deploy job holds pages: write and
id-token: write, in the github-pages environment. The Pages actions are
pinned to the commits of their release tags, and the build uses no
cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zensical, mkdocstrings and Griffe updates go to a docs-tools group, as
the lint tools do, so a release that breaks the site build does not
hold up the runtime floor bumps in minor-and-patch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API reference site (PER-16775) renders the SDK with mkdocstrings, which
reads it through Griffe. scripts/docs_griffe_extension.py adjusts what
Griffe reads:

- A name bound in both branches of `if TYPE_CHECKING: ... else: ...` is
  documented as the `if` branch binds it. Each blocking API class is then
  the stub class from permit/_sync_types.pyi, with blocking signatures,
  matched by the full path of the name: permit.pdp_api's
  SyncRoleAssignmentsApi is the stub's SyncPdpRoleAssignmentsApi, not the
  REST API's SyncRoleAssignmentsApi.
- A function or class decorated with permit.utils.deprecation.deprecated
  or a PEP 702 deprecated gets a `deprecated` label, and its docstring
  ends with the decorator's message. Stub methods take the deprecation of
  the async method they are generated from.
- A pydantic field's Field(description=...) becomes its docstring, and
  its default becomes its value.

griffelib joins the dev group, so mypy checks the extension and its
offline tests run with the suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API reference site includes MIGRATION.md as a page, where a link
relative to the repository root points at a page that does not exist,
as it does in the sdist, which ships MIGRATION.md but not skills/. An
absolute GitHub URL, as README.md uses for the same folder, works in all
three places.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The site that https://permitio.github.io/permit-python/ will serve
(PER-16775), built with Zensical 0.0.65 from mkdocs.yml, so that MkDocs
with Material stays a fallback while Zensical is in alpha:

- Home is README.md and "Upgrading to 3.0" is MIGRATION.md, both
  included rather than copied. A short page says which client to use.
- The reference: the two clients, configuration, exceptions, the
  enforcement types, one page per permit.api API with its async class
  and then its blocking twin, the PDP API, Elements, the deprecated flat
  methods, and a models page.
- The models page lists only the 84 models of permit.api.models that a
  public method takes or returns, with their fields, and links to the
  REST API reference for the rest. A test fails when a method starts or
  stops using one, and names it.
- Every page links to docs.permit.io for guides, from the navigation.

The docs dependency group pins the tools exactly. `uv run --locked
--group docs zensical build --strict --clean` builds the site into site/
with no warnings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CONTRIBUTING.md says how to build and preview the site, what fails the
build and why the build runs with --clean, how to add a page, an API
class or a model, and that only a release or a manual run deploys it.
README.md links to the site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The site section now builds through .github/scripts/check_docs_build.py,
as the docs job does, says what its exit codes mean, and names
docs-deploy.yml, its prerelease skip and the v* tag rule of the
github-pages environment. The CI scripts' tests section lists the gate's
tests, as the Audit Script Tests job runs them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
uv run --locked exits 1, not 2, when uv.lock is out of date, so a uv
error does not always read as "did not run". Say that such an error
comes before the gate and prints no verdict, and that CI installs the
docs group in its own step, which is where such an error fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docs group's comment gives the gate's command, the one CI runs, and
mkdocs.yml says what else has to change with site_dir.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docs group pins it directly, and a new release of it can change how
the site's pages render, so its bumps belong in the docs-tools PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The models page lists the models that public methods take or return and
links to the REST API reference for the rest, so "every model" in the
README line and the site description was wrong.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Zensical sets up no logging handler, so Python printed a record logged
by any logger other than Griffe's and mkdocstrings' as its bare message.
The gate could not tell that line from the rest of the log, and the
build passed.

The gate's default build now runs Zensical's command line with this
script's interpreter under a root handler that prints each record of
level WARNING and up as `LEVEL:logger:message`, which the gate already
fails on. When zensical is not installed for that interpreter, the gate
exits 2 (did not run) with the command to use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a plugin or the Griffe extension raised, the gate exited 1 but its
verdict listed only knock-on lines, such as links to the page that
failed. The exception was only in the middle of the log.

The gate now reads each Python traceback in the log and lists the
exception it ends with first, before the warnings. That is Zensical's
`RuntimeError: Python error: <exception>` line for a plugin error. A
traceback in a build that exited 0 also fails the gate. Zensical's own
"Aborted because --strict flag is set" is left out, since the verdict
lists the diagnostics that caused it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Zensical checks the links of the Markdown pages under docs/ before it
renders them. It does not see the links that rendering adds: those in
docstrings, and in README.md and MIGRATION.md, which the home page and
the migration guide include. A broken link or anchor there passed the
gate.

After a build that passed, the gate now reads every page of the built
site (except 404.html, which the server returns for any missing URL)
and fails with exit 1 on each relative href or src that reaches no
page or file of the site, or whose #fragment is no id on the page it
reaches. Each broken link is listed once per page, with the built page
that has it. Links with a scheme or host and absolute paths are not
checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Permit.wait_for_sync() is a generator function decorated with
contextlib.contextmanager. The site showed its annotation,
Generator[Self, None, None], on the async and the blocking client
pages, but calling it returns a context manager: a type checker sees
contextlib._GeneratorContextManager[Self, None, None].

The Griffe extension now shows the return type of a @contextmanager
function as contextlib.AbstractContextManager, that class's public
base, of the type the generator yields. It parses the docstring first,
so a Yields item without a type still names the yielded type. A return
annotation that does not name the yielded type fails the build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The extension matched the test of an `if TYPE_CHECKING:` block by its
text, as `TYPE_CHECKING` or `typing.TYPE_CHECKING`, so it skipped the
`if _typing.TYPE_CHECKING:` blocks of permit/api/models.py and
permit/__init__.py. The test of the EmailStr binding in models.py then
passed without reaching the check that keeps a runtime import, and no
test covered the check that only the `if` branch is read.

The extension now takes any `if` whose test is the name TYPE_CHECKING
or an attribute of that name. The site does not change: the bindings
those blocks make are kept as before. New tests visit small packages
for each case: the plain and `_typing.` spellings, an `elif`, a runtime
import that stays, an import in the runtime branch that is not read,
and an `if not TYPE_CHECKING:` that is not read. The EmailStr test now
checks the import's target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deploy Docs ran on `release: published` and skipped prereleases with a
job condition. Changing a prerelease to a release fires only the
`released` activity, so such a release never deployed the site, and
the site stayed on the previous release until someone ran Deploy Docs
by hand.

The workflow now runs on `released`, which fires when a release that
is not a prerelease is published and when a prerelease is changed to a
release, and never for a prerelease, so the job condition is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comment said the step fails when Pages does not publish from
GitHub Actions. configure-pages v6.0.0 with enablement off only reads
the repository's Pages site and fails when there is none; it does not
check the build type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deploy Docs and the publish workflow run separately on a release. If
the publish workflow's Security Gate or its upload fails, the site
still deploys the release's API. CONTRIBUTING now says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The parity tests compared only the install and build steps of the docs
job and the deploy's build job. A change to one workflow's Python
version or uv version file would have let the deploy build with a
different interpreter, and nothing would have failed.

A new test reads the "Install uv" step of both jobs and checks that
they use the same action, version-file and python-version, and that
the deploy keeps the cache off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The site section's lead-in still said the gate fails on warnings in
the build log only; it also fails on a broken link in the built site.
The exit-code paragraph is rewrapped to the file's line length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Oct 4, 2026

Copy link
Copy Markdown

PER-16775

@github-actions

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown

Dependency Security Audit

Scanned: pyproject.toml dependencies + dev group, resolved at Python 3.10 (the current resolution, and the lowest versions the published specs permit under each pydantic major)

✅ No known vulnerabilities found.

Both the resolved dependency set and the lowest versions the published specs permit are clean at HIGH and CRITICAL.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant