The project is managed with uv. CI runs the uv version pinned
as uv==… in the dev group of pyproject.toml (Dependabot keeps it current); install that
version (uv self update <version>) before starting, so uv.lock comes out the same as in
CI. [tool.uv] required-version is only a floor: a uv older than it refuses to run here.
A uv too old to parse the [tool.uv] table warns and ignores it, including that floor and
the 7-day exclude-newer cooldown, which produces a different uv.lock.
uv sync # .venv with the SDK and the dev tools, exactly as locked in uv.lock
uv run pre-commit install # lint, format, type-check and uv.lock checks on every commituv sync installs the SDK from this checkout in editable mode, so the tests and scripts
import the working tree's permit. .python-version selects Python 3.11, the version the
end-to-end CI job runs on. The SDK itself supports Python 3.10 and later.
The ruff, mypy and typos hooks run through uv run --locked, which syncs .venv to uv.lock
before running the tool, so the versions in uv.lock are the only ones in play; the hooks fail
if uv.lock is out of date with pyproject.toml. That sync uses the default groups, so a commit
also switches a .venv synced with --group pydantic-v1 back to pydantic 2.x. The same checks
by hand:
uv run ruff check # lint (the rule set is `select = ["ALL"]` minus justified ignores)
uv run ruff format # format
uv run mypy # strict type check of every Python file but the generated models
uv run typos # spellingThe SDK is type-checked against both pydantic majors, because it imports pydantic differently per major. CI runs mypy once more under pydantic 1; do the same locally when touching a pydantic import:
uv run --group pydantic-v1 mypy- Runtime requirements are
[project].dependenciesinpyproject.toml. They are open ranges, and the comments there say why each floor and exclusion is what it is.tests/test_offline_regressions.pyandskills/testscheck those floors. - Dev tools are exact pins in the
devdependency group, whichuv syncinstalls by default. - After changing either, run
uv lockand commituv.lockwith the change. Theuv-lockpre-commit hook fails while the two disagree, and CI installs withuv sync --locked, which refuses a stale lock. uv lockleaves out releases less than 7 days old (exclude-newerin[tool.uv]), but the dependency audit does not, so it can fail on an advisory whose fixuv lockstill filters out. To take that fix now, addexclude-newer-package = { <package> = false }under[tool.uv], runuv lockand commit both files. Passing--exclude-newer-packagetouv lockon the command line is not enough:uv.lockrecords the options it was locked with, souv lock --checkanduv sync --lockedthen reject it. Once the release is 7 days old, remove the entry and runuv lockagain.
Every test that needs credentials, the Permit API or a PDP is marked e2e. The rest run
against local mock servers and need no PDP, API key or network access:
uv run pytest -m "not e2e"Any warning fails the test that raised it (filterwarnings in [tool.pytest]), except the
one import permit issues on pydantic 1 on purpose. The migration skill's and the CI
scripts' tests do the same with their own configs.
The SDK supports pydantic 1 and 2, and CI runs the suite once per major. Each major is a
dependency group, and both resolutions are in uv.lock:
uv run --group pydantic-v1 pytest -m "not e2e" # pydantic 1.x
uv run --group pydantic-v2 pytest -m "not e2e" # pydantic 2.xuv run syncs .venv to the groups it is given before running the command, so pass the
group every time: a plain uv run switches back to the default resolution (pydantic 2.x).
uv run --python 3.14 --group pydantic-v2 pytest -m "not e2e"--python rebuilds .venv with that interpreter, and the next uv run without it
rebuilds .venv with the .python-version one.
CI's compatibility job runs the offline tests on Python 3.10 to 3.14, both at the lowest
versions the runtime requirements allow and at the newest.
tests/test_typing_surface.py runs mypy on tests/type_check/consumer.py the way a user's
project sees an installed permit, and fails while permit/_sync_types.pyi is out of date
(see Regenerating the sync stubs). The mypy pre-commit
hook type-checks the SDK itself, strictly and with the pydantic plugin (see Setup).
skills/tests checks MIGRATION.md and the permit-python-3-migration skill against each
other and against the SDK. It runs apart from the SDK's suite, with its own pytest config:
uv run python -m pytest -c skills/tests/pytest.ini skills/tests.github/scripts holds the dependency audit's report formatter and the schema drift check,
with their tests. They need only pytest and the standard library, and run with their own
pytest config, which turns every warning into an error. The command is the one the
Audit Script Tests job runs:
uv run --only-dev pytest -c .github/scripts/pytest.ini \
.github/scripts/test_format_audit.py .github/scripts/test_check_schema_drift.pyThe tests marked e2e talk to a real Permit environment through a running PDP. uv run pytest with no arguments runs the whole suite (testpaths is tests/). CI
(.github/workflows/test.yml) creates a scratch environment per run, starts a PDP container
for it, and sets:
PDP_API_KEY: the scratch environment's API key. Every e2e test fails without it.PDP_URL=http://localhost:7766: the PDP. This is also the default when unset.API_TIER=prod: sends the SDK's API calls tohttps://api.permit.io.ORG_PDP_API_KEYandPROJECT_PDP_API_KEY: the same key, read bytests/endpoints/test_envs.py.
Without API_TIER=prod (or an explicit PDP_CONTROL_PLANE), tests/conftest.py sends API
calls to http://localhost:8000. To reproduce CI locally with an environment-level API key:
docker run -d --name permit-pdp -p 7766:7000 -e PDP_API_KEY="$PDP_API_KEY" \
permitio/pdp-v2:latest
PDP_URL=http://localhost:7766 API_TIER=prod \
ORG_PDP_API_KEY="$PDP_API_KEY" PROJECT_PDP_API_KEY="$PDP_API_KEY" \
uv run pytest -s --cache-clear tests/The suite creates and deletes objects in that environment, so use a throwaway one.
The blocking client, permit.sync.Permit, wraps the async classes at runtime, which type
checkers cannot follow. permit/_sync_types.pyi declares the blocking signatures for them
and is generated from the async classes. After changing an async API class, regenerate it:
uv run python scripts/generate_sync_stubs.pypermit/api/models.py is generated from the Permit OpenAPI spec, then hand-edited at the top
so the same models work under both pydantic majors. Regenerating overwrites that edit, so it
has to be restored by hand.
-
Regenerate:
bash scripts/generate_models.sh
The script runs the generator through
uvx, pinned to 0.33.0, the release that produced the current file. Its--exclude-newerdate freezes the generator's dependencies and formatters, so an unchanged spec regenerates the same models, and those dependencies do not install on Python 3.14, hence--python 3.11. The comments in the script explain the generator flags, such as--use-default-kwarg. -
Restore the compatibility header. The generator writes a single import line such as:
from pydantic import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr
Replace it with the header the committed file has, keeping exactly the names the generator imported in every branch:
import typing as _typing # Private, or permit/__init__.py's `from permit.api.models import *` would export it. from ..utils.pydantic_version import PYDANTIC_VERSION as _PYDANTIC_VERSION if _typing.TYPE_CHECKING: # The v1 API is what runs under either pydantic major, so type-check against it. from pydantic.v1 import AnyUrl, BaseModel, Extra, Field, conint, constr # pydantic.v1 declares EmailStr as a str subclass, so a type checker would reject # a plain str for an email field. At runtime these fields take and hold a plain # str; pydantic 2 types its own EmailStr as str for the same reason. EmailStr = str elif _PYDANTIC_VERSION < (2, 0): from pydantic import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr else: from pydantic.v1 import AnyUrl, BaseModel, EmailStr, Extra, Field, conint, constr
Without it, the v1-style models do not load under pydantic 2. Keep
PYDANTIC_VERSIONimported under the private_PYDANTIC_VERSIONalias. -
Re-apply the hand fixes: the entries in
.github/scripts/schema_drift_allowlist.jsonwhose reason says "by hand". -
Do not run
ruff formaton it:permit/api/models.pyis excluded from ruff and typos inpyproject.tomland keeps the generator's formatting, so the diff shows only API changes. -
Run the schema drift check, the offline tests under both pydantic majors (see above) and
uv run pre-commit run --all-files.
.github/scripts/check_schema_drift.py runs the same generator with the same flags (a unit
test keeps it equal to scripts/generate_models.sh) and compares the result with
permit/api/models.py by structure: classes, fields, types, required or optional, defaults,
aliases, Config.extra and enum members. A difference that makes the SDK send what the API
rejects, or reject what it returns, fails the check. A class or optional field the SDK lacks
is only reported. That includes a class deleted from permit/api/models.py: the schema has
classes no SDK method uses, and a class the SDK does not have cannot change what it sends or
parses. Known differences are listed in .github/scripts/schema_drift_allowlist.json with a
one-line reason each, and an entry that no longer matches fails the check until it is
removed.
uv run python .github/scripts/check_schema_drift.py --models permit/api/models.py \
--allowlist .github/scripts/schema_drift_allowlist.jsonIt exits 0 (no new failing drift and no stale entry), 1 (new failing drift or a stale entry)
or 2 (the comparison did not run). .github/workflows/schema-drift.yml runs it weekly, on
manual dispatch and on pull requests that change permit/api/models.py,
.github/scripts/check_schema_drift.py, .github/scripts/schema_drift_allowlist.json or the
workflow itself.
uv build # sdist and wheel into dist/dist/ is the only build output; uv's build backend leaves no build/ or *.egg-info
directory behind.
Releasing is done by publishing a GitHub release, which runs
.github/workflows/python-sdk-publish.yml (build, then security scan, then PyPI). The release
tag sets the version.