Migrate packaging, dependencies and CI to uv - #127
Conversation
The resolved dependency tree was clean, but the published `>=` floors let a consumer install versions carrying 34 known advisories. Because this package ships open ranges with no lockfile, the floor is the real exposure -- so the scan covers both the current resolution and the lowest versions the specs permit. Dependency fixes: - aiohttp >=3.14.3 (clears 32 advisories, incl. CVE-2026-69244, an out-of-bounds heap read in the HTTP response parser this client exercises on every call) - pydantic >=1.10.13 (CVE-2024-3772, EmailStr ReDoS; the SDK uses EmailStr) - werkzeug >=3.1.6, pytest >=9.0.3 - drop httpx: never imported, and the only path by which h11 (CVE-2025-43859, CRITICAL) and anyio entered the tree - drop zipp and aioresponses: both unused, and aioresponses 0.7.9 is incompatible with aiohttp 3.14.3 - python_requires >=3.10; the declared >=3.8 was already unachievable Gates: - Trivy over three trees (runtime ceiling, runtime floor, dev), sticky PR comment, blocking on fixable HIGH/CRITICAL only - release split into build -> scan -> publish, so publish is unreachable unless the scan passed - weekly cron posting the findings themselves to Slack, not just a verdict - Dependabot with cooldowns and versioning-strategy: increase - delete release.yml, which raced python-sdk-publish.yml on every release - existing workflows hardened: 48 zizmor findings (12 high) to zero Also fixes 10 minor SDK bugs with 33 offline regression tests. Nine major correctness bugs found along the way are tracked in PER-16174 rather than changed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…endent pytest_httpserver's `httpserver` fixture is session-scoped: the first test that requests it binds the one shared server for the entire run. The address override lived in test_rbac_e2e.py, so it only applied when that module happened to touch the fixture first. Adding tests/test_offline_regressions.py broke that assumption -- it sorts earlier, claimed the session server on a random port, and test_api_timeout and test_pdp_timeout then failed against their hardcoded localhost:9999 with "Cannot connect to host". Moving the fixture to conftest.py makes the address apply session-wide and removes the latent ordering dependency, which any future test using httpserver would otherwise have tripped over too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ations" This reverts commit 160f129.
get, get_by_key, update and delete all interpolate their argument straight into the path, and the backend validates it with validate_resource_instance_ident(instance_id, allow_uuids=True) -- a bare instance key is rejected with a 422, not accepted. The docstrings said "the key of the resource instance", which sends callers straight into that error. Wording matches what bulk_delete already documented correctly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bumps to 3.0.0 and fixes the nine major bugs tracked in PER-16174, so the
eight permanently-xfail tests can assert for real.
Sync client (permit/utils/sync.py, permit/sync.py):
- SyncClass is now idempotent. It was inherited, so a subclass re-wrapped
methods its base had already converted, giving async_to_sync(async_to_sync(f));
all 21 deprecated-facade methods raised "a coroutine was expected" before
issuing a request.
- Coroutine detection uses inspect.iscoroutinefunction and unwraps
functools/validate_arguments wrappers, instead of assuming every object whose
class is named "function" is async.
- permit.sync.Permit now overrides authorized_users, get_user_permissions and
filter_objects, which were inherited as `async def` over a synchronous
enforcer and returned un-awaitable coroutines.
Enforcement (permit/enforcement/):
- parse_obj_as is imported through the pydantic v1/v2 guard the rest of the
package uses; authorized_users() could not return at all under pydantic v2.
- bulk_check honours a per-check context and filter_objects forwards the
caller's context. It was silently dropped, so context-dependent ABAC
evaluated against {} and could return the wrong subset.
- UserInput accepts snake_case as well as the camelCase aliases; first_name
and last_name were silently discarded from every check.
Serialization (permit/api/base.py):
- dict and list bodies go through the encoder, so nested datetime/UUID/Enum
no longer dies inside aiohttp.
- exclude_none is dropped, so an explicitly-set None is transmitted as null
and an update can clear a field. exclude_unset still omits untouched fields.
Facts proxy (permit/api/tenants.py):
- tenants bulk operations addressed the PDP's users endpoint.
tests/endpoints/test_bulk_operations.py asserted that a tenant role assignment
outlives the user who owns it; deleting the user removes it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The un-xfailed tests all run against one shared environment and were fighting each other: fixed keys (admin, viewer on the built-in __tenant resource), a shared resource urn, assertions on global object counts, and teardown that called pytest.fail on a 404 so "already deleted by another test" turned a passing test red. Several also leaked every object they created. Each test now derives its keys from tests/utils.unique_key, asserts against its own objects rather than environment-wide counts, tears down in a finally via handle_cleanup_error, and polls with a bounded retry where it waits for a fact to reach the PDP. Verified by running twice in a row against a deliberately dirty local environment. test.yml starts the PDP as a step rather than a service container. A service container is created before the first step runs, so it could only be given the long-lived PROJECT_API_KEY while the tests authenticate with the per-run scratch environment key. The PDP rejected every decision with a 403, which is why the ReBAC and RBAC decision tests could never pass. That 403 also surfaced as "cannot connect to the PDP container": the enforcer read error bodies with response.json(), and the PDP sends auth rejections as plain text, so ContentTypeError -- an aiohttp.ClientError -- was caught by the connectivity handler and the real status was lost. Error bodies are now read without assuming JSON, and the message names the status and body. tests/test_abac_pdp.py's three cloud-PDP tests now skip with a reason instead of failing: as CI is configured they never reach the cloud PDP. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PDP reports 503 on /healthy until its horizon component finishes pulling config and a policy bundle. Waiting for it immediately after docker run made that bootstrap serial with the job; one leg was ready in 29s and the other still was not at 60s. The wait now happens after dependency installation, so the bootstrap overlaps with it, with a 180s ceiling. Changing an ABAC condition set makes the policy generator recompile the environment's rego and redistribute the bundle, which is much slower than the fact sync RBAC uses. test_abac_e2e timed out at 90s against the real cloud PDP; raised to 300s. The poll returns as soon as the rule lands, so a healthy run is no slower. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
setup.py used a bare find_packages(), which ships a TOP-LEVEL `tests` package into every consumer's site-packages where it shadows their own `tests` module. Verified against the published permit==2.8.3, which does exactly that. Now excluded, along with `harness`. permit.pdp_api never passed a timeout to its HTTP client, so the documented pdp_timeout was silently ignored on every permit.pdp_api.* call while the enforcer honoured it. It also duplicated ClientConfig and pagination_params verbatim from permit.api.base; it imports them now. Removed, none of which had a single caller in permit/, tests/ or harness/: set_if_not_none (enforcer), OpaResult and the JWT alias (interfaces), ApiKeyLevel (a self-declared deprecated alias of ApiKeyAccessLevel), LoginAsErrorMessages (never compared against or returned), and three unused TypeVars in the PDP base module. _model_dump was defined identically in both arms of the pydantic version split; hoisted to one definition. Its `mode` parameter stays and stays ignored on purpose -- it absorbs a v2-style argument that pydantic v1's .dict() would reject. Repo cruft: .isort.cfg (isort is not run; ruff's I rules are), uv.lock (a three-line stub declaring requires-python >=3.14, contradicting setup.py), the Makefile publish target (a second release path that bypasses the gated build -> scan -> publish workflow) and a .DEFAULT_GOAL pointing at a help target that did not exist. .gitignore's .DS_Store rule was inert because of an inline comment. Dependencies: dropped pytest-mock (no test uses it) and pytest-cov (coverage is never requested, including in CI). Corrected the werkzeug comment -- it is now a direct test import, not just a pytest_httpserver transitive. Also dropped two references to .trivyignore, which audit-deps.sh deliberately disables with --ignorefile /dev/null, so both were advertising a suppression mechanism that does not work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The condition sets and rule this test creates never reach the PDP's policy bundle, so the decision it waits for never becomes true. The PDP says so in the debug.abac payload the SDK already logs: ~90s of no_matching_usersets with "known usersets: ['rules']" (the empty-package placeholder), then one bundle carrying only the condition sets autogenerated by the resource and role creates ten seconds earlier, then nothing for the remaining 300s. The data channel stayed healthy throughout. The pipeline is event-driven with no polling fallback (the default scope is created with poll_updates=False and batching drains rather than waits), so this is a stall, not slowness, and no timeout makes it pass. Skipped rather than xfailed so it reports honestly instead of looking like coverage. Only the three decision assertions are skipped. Everything above them still runs against the real control plane -- condition set and rule create, type round-trip, paginated list, filtered list, permission-format assertion -- and so does the teardown, because pytest.Skipped derives from BaseException and escapes the test's except Exception. Ruled out as causes: resource_id passed as .hex (the generator keys on the resource key, never the id), inline check attributes (they win the object.union_n in the generated rego and the PDP echoed them back), and a missing setup step. No other test is exposed: condition_set_changes.py is the only policy synchronizer handler that generates rego, so RBAC and ReBAC decisions resolve against data.* on the fact channel, and this is the only test that touches condition sets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
resource_relations.list() declared List[RelationRead], but the route is declared response_model=PaginatedResult[RelationRead], so against current backend main the call raised "ValidationError: value is not a valid list" -- the method was unusable. It now returns PaginatedResultRelationRead; callers read .data. BREAKING, and in the 3.0.0 notes. (That change was written earlier and swept into the previous commit by a bare `git add -A`; this records what it actually is.) Two docstrings corrected against the backend, both of which sent callers into a confusing error: - resource_roles.assign_permissions/remove_permissions said permissions are <resourceKey:actionKey>. A resource role is scoped to its own resource, so each entry is a BARE action key. Passing the qualified form makes the server read the whole string as an action key and reject it with a 404 naming '<resource>:<resource>:<action>' -- a doubled prefix that reads like the SDK concatenated wrongly, when it is the server quoting what it was given. - role_assignments.list(resource_instance_key=...) takes a `resource_type:instance_key` ident or an instance uuid, never a bare key. Regression tests pin the exact wire strings on both pydantic majors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Every remaining CI failure was one cause: HTTP 429 on a cleanup call. Enabling the eight previously-xfail tests and giving each its own objects made the suite create and tear down far more than before, and teardown is where the burst lands -- one leg reported 3 failed and 2 teardown errors, the other 7 failed, all of them 429 on a delete. handle_cleanup_error now tolerates 429 alongside 404, for the same reason 404 is tolerated: neither leaves the test's assertions in doubt. A throttled delete leaks an object, and CI deletes the whole scratch environment afterwards, so it is reclaimed. Any other status still fails the test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The previous commit tolerated 429 during teardown. That was wrong in a way the next CI run made obvious: a tolerated DELETE leaves the object alive, so the assert-it-is-gone check that follows failed with "DID NOT RAISE PermitApiError". The tolerance manufactured a worse failure than the one it hid. 429 is no longer tolerated. It was also the wrong layer. The run after showed 429 arriving in test BODIES as well -- test_rebac_e2e, test_sync_client and test_user_invites_complete_e2e all failed mid-test -- so cleanup was never the whole problem. The suite runs against one environment on a shared cloud project and now creates and tears down considerably more than it used to, which exceeds the burst limit. The eight tests that were xfail until this branch had been swallowing these 429s all along. conftest wraps the SDK's five HTTP verbs for the test session only, retrying a 429 with exponential backoff so the call actually succeeds. The SDK is untouched: adding implicit retries to a published client would be a behaviour change callers did not ask for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Six attempts (~63s of backoff) still ran out on one teardown, leaving CI at 1 failed / 102 passed. Raised to nine, which caps a single call at roughly two minutes of waiting and exits the moment it succeeds. Also honours the server's Retry-After when it sends one, and adds jitter to the exponential fallback so concurrent callers do not retry in lockstep and re-trip the limit together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
bulk_check() reads each query's context with .get(), so a query without
one is valid at run time, but the TypedDict declared the key as required
and mypy rejected every bulk_check([{"user", "action", "resource"}]) call.
TypedDict comes from typing_extensions so NotRequired is honoured on 3.10.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
pyproject.toml now carries the PEP 621 metadata setup.py declared, built with uv_build; dev tools move to a PEP 735 group and both pydantic lanes become conflicting groups, so every CI lane installs from the committed uv.lock. setup.py, requirements*.txt, MANIFEST.in, pytest.ini and the Makefile are gone; contributor docs move to CONTRIBUTING.md. CI installs with uv sync --locked; the publish job stamps the version with uv version, builds with uv build --no-sources on a checksum-verified uv, and keeps its build -> scan -> publish gating and PyPI token auth. The audit compiles its three trees from pyproject.toml with --no-sources and fails if the dev group did not resolve. uv is pinned once, by [tool.uv] required-version, with a 7-day exclude-newer cooldown; Dependabot uses the uv ecosystem and a uv-lock hook stops drift. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The REST API client, the PDP API client and the enforcer sent "bearer <token>". The scheme is case-insensitive per RFC 7235, but "Bearer" is the canonical form every other Permit SDK sends, and at least one server once rejected the lowercase form with a 401. A facade-level offline test now reads the header each client actually puts on the wire. Co-authored-by: Suren <suren@cercli.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
On Python 3.14, pydantic 1.x before 1.10.25 and 2.x before 2.13 crash on
import permit ("unable to infer type for attribute"), so the pydantic
requirement is split by Python version and excludes those releases there.
pydantic 2.0 is excluded everywhere: its pydantic.v1.parse_obj_as rejects
the SDK's __root__ models, failing every parsed API response.
The typing-extensions and loguru floors could not import on current
Pythons (typing-extensions before 4.6 breaks on 3.12+, before 4.12 on
3.13+, 4.12-4.13 lose TypedDict keys on 3.14; loguru before 0.7.3 warns on
3.14), so they rise to 4.14.0 and 0.7.3. deprecation.py uses
inspect.iscoroutinefunction instead of the asyncio one 3.16 removes, and
the pydantic version parser accepts pre-releases such as 2.14.0b2, which
crashed the import.
A new compatibility CI job runs the offline suite on Python 3.10-3.14 at
both the lowest allowed and the newest dependency versions.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
permit now declares itself typed, and type checkers see what actually runs: the SDK models are typed as the pydantic.v1 models they are on both pydantic majors (TYPE_CHECKING import branches, pydantic.v1.mypy plugin), generated model defaults are keyword arguments so optional fields no longer read as required, API methods that accept dicts at runtime accept them in their annotations (typing-only ModelInput/ModelListInput, runtime validation unchanged), and the sync client is typed as synchronous through a generated stub (permit/_sync_types.pyi, with a drift test). The pre-3.14 pydantic floor rises to 1.10.18: 1.10.17 is the first release with the pydantic.v1 package, and 1.10.13-1.10.17 emit about 2,400 DeprecationWarnings on Python 3.13. A consumer fixture is type-checked with mypy --strict in the test suite on every CI leg, and the release and compatibility builds assert the wheel ships py.typed and the stub. Runtime behaviour is unchanged: a snapshot of every public name, signature, validate_arguments model and model field matches the previous commit on both pydantic majors. Co-authored-by: Tarcio Silva <luan.coc13@gmail.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
Under pydantic 2, permit validates emails with the pydantic.v1 copy that pydantic bundles. That copy is fixed for CVE-2024-3772 (ReDoS in email validation) only from pydantic 2.4.2, which bundles 1.10.13: 2.0.1 bundles 1.10.11, and 2.4.0 and 2.4.1 bundle 1.10.12. Below Python 3.14 the spec still allowed 2.0.1-2.4.1. The pre-3.14 requirement is now two lines. Python 3.10-3.12 allow pydantic 2 from 2.4.2. Python 3.13 allows it from 2.8.0, because 2.4.2-2.7.x pin a pydantic-core with no Python 3.13 wheels. The pydantic 1 floor (1.10.18) and the 3.14 line are unchanged. Nothing resolved the pydantic 2 floor before: lowest-direct over requirements.txt picks pydantic 1, so the floor CI legs and the audit's runtime-floor tree only ever saw 1.10.18, and Trivy treats 2.4.0 as fixed. A pydantic-v2-floor compatibility leg on every Python and a runtime-floor-pydantic-v2 audit tree now resolve lowest-direct with pydantic held to >=2, and every format_audit.py call reads the new tree. Every setup-uv step pins uv 0.12.18, so a uv release cannot change which floor is tested or scanned. The offline tests check, per Python, that no allowed pydantic is affected by the CVE and that each major is allowed from its floor up. Part of PER-16176. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The comment claimed py.typed and _sync_types.pyi ship only because package_data lists them. setuptools 69 and later include them by default; 68.2.2 does not. The project has no [build-system] table, so a build can still run with an older setuptools, which is what package_data guards against. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The docstrings in tests/test_fix_permissions.py and tests/test_fix_relations.py now state what the API does: how it reads a role's permission strings, which resource_instance filter values it rejects, and the paginated envelope the relations list returns. They no longer point at server source files. The Dependabot cooldown comment no longer names a policy kept outside this repository. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
PYDANTIC_CANDIDATES is now built by explicit loops instead of a triple-nested comprehension. The list is unchanged (931 entries). audit-deps.sh no longer runs mkdir -p on the output directory before writing the pydantic constraint file: compile_tree has already created it at that point. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
The API no longer sends pdp_config_id on every decision log, may leave out objects on a detailed log, and now returns logs from a GENERIC decision-log engine. AuditLogModel, DetailedAuditLogModel and LimitedPaginatedResultAuditLogModel rejected such payloads with a ValidationError (PER-14375). The affected classes now match what the pinned generator (datamodel-code-generator 0.33.0 with the Makefile flags) emits from the current public OpenAPI schema: pdp_config_id is Optional[UUID] on both audit-log models, Engine has GENERIC, the new GenericEngineDecisionLog is in both raw_data unions, and DetailedAuditLogModel.objects is optional. Only these classes change; a full regeneration would undo hand fixes elsewhere in the file (PER-16236). pdp_config_id changing to Optional[UUID] is a breaking change for code that reads it as a UUID, which is why it lands in 3.0.0. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
GenericEngineDecisionLog sits before DummyEngineModel in both raw_data unions, so its engine literal is the only thing that keeps an OPA or AVP log that fails its own shape from being read as a GENERIC log. No test checked that: widening the literal to str still passed. The new test parses such logs and expects DummyEngineModel. The GENERIC test for AuditLogModel now uses a list-item payload instead of a detailed one, so it no longer carries an objects key that the list model does not declare (PER-14375). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2
An exact [tool.uv] required-version fails every Dependabot update once Dependabot's bundled uv differs from it. required-version is now a floor (>=0.12.17), and the uv CI runs is pinned as uv==0.12.17 in the dev group: every setup-uv step reads it from uv.lock, so Dependabot bumps it like any other pin, after the same 7-day cooldown. The publish build job keeps its own exact uv version and checksum, so a Dependabot bump cannot change the tool that builds releases. 0.12.17 because uv 0.12.18 is still inside the exclude-newer cooldown; Dependabot will raise the pin once it is 7 days old. The uv_build bound and the uv-lock hook rev follow the pin. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DJGQswZ6RgfNM5AF8pxj6
uv_build moves by hand with the uv version and checksum the publish build job pins: that uv builds releases with its built-in backend only while the uv_build bound allows its version. A Dependabot PR raising the bound past it would make release builds download uv_build from PyPI instead, around the checksum-verified binary, without failing. audit-deps.sh now says why resolving the audit trees for Python 3.10 is enough: an advisory that affects the higher 3.13/3.14 pydantic floors almost always affects the lower 3.10 floor too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018DJGQswZ6RgfNM5AF8pxj6
EliMoshkovich
left a comment
There was a problem hiding this comment.
@zeevmoney I did a deep review and checked it locally. LGTM, approving. Nothing blocks the merge. Two small non-blocking notes are below.
Checked locally (uv 0.12.17, Python 3.11):
uv lock --checkpasses.uv build --no-sources: the wheel has exactly 49permit/files, includingpy.typedand_sync_types.pyi, and notests/harness. The sdist has README, MIGRATION.md, CONTRIBUTING.md and LICENSE.- The wheel's
Requires-Disthas the same ranges as main. The markers becomepython_full_version < '3.13'/== '3.13.*'/>= '3.14'. I checked the pre-release note: only a 3.14.0 alpha, beta or RC falls through, as the comment says. - Offline tests: 291 passed / 3 skipped on both lanes (pydantic 1.10.26 and 2.13.5). Migration skill tests: 86 passed on both. CI script tests: 107 passed.
uv version --frozen 3.0.1rc1rewrites only[project].version. It doesn't re-lock and doesn't create a.venv, so the release step is safe.- The CI logs confirm setup-uv reads
0.12.17fromuv.lock(version-file). - Dependabot compatibility: dependabot-core currently bundles
uv==0.12.18(uv/helpers/requirements.txt), so therequired-version = ">=0.12.17"floor won't break its updates. Making it a floor rather than an exact pin was the right call. - All CI checks are green, and the required check names are unchanged.
Non-blocking notes:
- The
uv-lockhookrevin.pre-commit-config.yamlhas to be kept equal to theuv==dev pin by hand. Dependabot bumps the dev pin but not the hook rev, so they will drift after the first uv bump (see inline). Nothing breaks while the hook stays at or above the floor. Worth making sure PER-16222's pre-commit Dependabot entry covers it. - FYI: uv_build now ships the sdist with a normalized
pyproject.toml(comments stripped) plus the original aspyproject.toml.orig. That's harmless and just a heads-up in case someone asks what the extra file in the sdist is.
Fixed in
I confirmed this with uv 0.12.18, the version the release job builds with. The sdist's Thanks for running it all locally. |
The uv-pre-commit hook installed its own uv at its rev, which Dependabot does not bump alongside the dev group's uv pin. A local hook runs the uv on PATH instead: under `uv run`, as in CI, that is the pinned uv. `--check` never writes, so a local uv too old to read [tool.uv] fails instead of re-locking with other settings. Also corrects the ruff and mypy pin comment: Dependabot bumps those pins but not the hook revs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main gained the 3.0.0 SDK fixes (#126) and the final uv migration (#127) after this branch was cut. The branch reformatted and strictly typed the pre-3.0.0 code, so the merge conflicted in most files. The tree is reset to main's tree here, so the tooling, formatting and typing changes can be re-applied on top of the 3.0.0 code in separate commits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In CI the PDP's /healthy has taken 63-154s to return 200. It stays 503 until the scratch environment's first policy bundle and data arrive, and until then the PDP restarts its policy service about once a minute. On main after #127 the pydantic-2 job ran past the 180s limit on three attempts, while the same job passed in every PR run. Wait up to 300s. The step still prints how long the PDP took. On failure, drop the PDP's once-a-second health-check lines before taking the tail of its log, so the policy and data fetches that explain a slow start are no longer cut off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Fix dependency CVEs and gate PRs, releases and a weekly scan The resolved dependency tree was clean, but the published `>=` floors let a consumer install versions carrying 34 known advisories. Because this package ships open ranges with no lockfile, the floor is the real exposure -- so the scan covers both the current resolution and the lowest versions the specs permit. Dependency fixes: - aiohttp >=3.14.3 (clears 32 advisories, incl. CVE-2026-69244, an out-of-bounds heap read in the HTTP response parser this client exercises on every call) - pydantic >=1.10.13 (CVE-2024-3772, EmailStr ReDoS; the SDK uses EmailStr) - werkzeug >=3.1.6, pytest >=9.0.3 - drop httpx: never imported, and the only path by which h11 (CVE-2025-43859, CRITICAL) and anyio entered the tree - drop zipp and aioresponses: both unused, and aioresponses 0.7.9 is incompatible with aiohttp 3.14.3 - python_requires >=3.10; the declared >=3.8 was already unachievable Gates: - Trivy over three trees (runtime ceiling, runtime floor, dev), sticky PR comment, blocking on fixable HIGH/CRITICAL only - release split into build -> scan -> publish, so publish is unreachable unless the scan passed - weekly cron posting the findings themselves to Slack, not just a verdict - Dependabot with cooldowns and versioning-strategy: increase - delete release.yml, which raced python-sdk-publish.yml on every release - existing workflows hardened: 48 zizmor findings (12 high) to zero Also fixes 10 minor SDK bugs with 33 offline regression tests. Nine major correctness bugs found along the way are tracked in PER-16174 rather than changed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Move httpserver_listen_address to conftest so the port is order-independent pytest_httpserver's `httpserver` fixture is session-scoped: the first test that requests it binds the one shared server for the entire run. The address override lived in test_rbac_e2e.py, so it only applied when that module happened to touch the fixture first. Adding tests/test_offline_regressions.py broke that assumption -- it sorts earlier, claimed the session server on a random port, and test_api_timeout and test_pdp_timeout then failed against their hardcoded localhost:9999 with "Cannot connect to host". Moving the fixture to conftest.py makes the address apply session-wide and removes the latent ordering dependency, which any future test using httpserver would otherwise have tripped over too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * TEMP: revert permit/ to origin/main to isolate test_bulk_operations Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Revert "TEMP: revert permit/ to origin/main to isolate test_bulk_operations" This reverts commit 160f129. * Document the resource instance ident format correctly get, get_by_key, update and delete all interpolate their argument straight into the path, and the backend validates it with validate_resource_instance_ident(instance_id, allow_uuids=True) -- a bare instance key is rejected with a 422, not accepted. The docstrings said "the key of the resource instance", which sends callers straight into that error. Wording matches what bulk_delete already documented correctly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Fix the major correctness bugs and enable the xfail tests for 3.0.0 Bumps to 3.0.0 and fixes the nine major bugs tracked in PER-16174, so the eight permanently-xfail tests can assert for real. Sync client (permit/utils/sync.py, permit/sync.py): - SyncClass is now idempotent. It was inherited, so a subclass re-wrapped methods its base had already converted, giving async_to_sync(async_to_sync(f)); all 21 deprecated-facade methods raised "a coroutine was expected" before issuing a request. - Coroutine detection uses inspect.iscoroutinefunction and unwraps functools/validate_arguments wrappers, instead of assuming every object whose class is named "function" is async. - permit.sync.Permit now overrides authorized_users, get_user_permissions and filter_objects, which were inherited as `async def` over a synchronous enforcer and returned un-awaitable coroutines. Enforcement (permit/enforcement/): - parse_obj_as is imported through the pydantic v1/v2 guard the rest of the package uses; authorized_users() could not return at all under pydantic v2. - bulk_check honours a per-check context and filter_objects forwards the caller's context. It was silently dropped, so context-dependent ABAC evaluated against {} and could return the wrong subset. - UserInput accepts snake_case as well as the camelCase aliases; first_name and last_name were silently discarded from every check. Serialization (permit/api/base.py): - dict and list bodies go through the encoder, so nested datetime/UUID/Enum no longer dies inside aiohttp. - exclude_none is dropped, so an explicitly-set None is transmitted as null and an update can clear a field. exclude_unset still omits untouched fields. Facts proxy (permit/api/tenants.py): - tenants bulk operations addressed the PDP's users endpoint. tests/endpoints/test_bulk_operations.py asserted that a tenant role assignment outlives the user who owns it; deleting the user removes it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Isolate the end-to-end tests and start the PDP with the env's own key The un-xfailed tests all run against one shared environment and were fighting each other: fixed keys (admin, viewer on the built-in __tenant resource), a shared resource urn, assertions on global object counts, and teardown that called pytest.fail on a 404 so "already deleted by another test" turned a passing test red. Several also leaked every object they created. Each test now derives its keys from tests/utils.unique_key, asserts against its own objects rather than environment-wide counts, tears down in a finally via handle_cleanup_error, and polls with a bounded retry where it waits for a fact to reach the PDP. Verified by running twice in a row against a deliberately dirty local environment. test.yml starts the PDP as a step rather than a service container. A service container is created before the first step runs, so it could only be given the long-lived PROJECT_API_KEY while the tests authenticate with the per-run scratch environment key. The PDP rejected every decision with a 403, which is why the ReBAC and RBAC decision tests could never pass. That 403 also surfaced as "cannot connect to the PDP container": the enforcer read error bodies with response.json(), and the PDP sends auth rejections as plain text, so ContentTypeError -- an aiohttp.ClientError -- was caught by the connectivity handler and the real status was lost. Error bodies are now read without assuming JSON, and the message names the status and body. tests/test_abac_pdp.py's three cloud-PDP tests now skip with a reason instead of failing: as CI is configured they never reach the cloud PDP. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Give the PDP time to warm up and ABAC policy time to propagate The PDP reports 503 on /healthy until its horizon component finishes pulling config and a policy bundle. Waiting for it immediately after docker run made that bootstrap serial with the job; one leg was ready in 29s and the other still was not at 60s. The wait now happens after dependency installation, so the bootstrap overlaps with it, with a 180s ceiling. Changing an ABAC condition set makes the policy generator recompile the environment's rego and redistribute the bundle, which is much slower than the fact sync RBAC uses. test_abac_e2e timed out at 90s against the real cloud PDP; raised to 300s. The poll returns as soon as the rule lands, so a healthy run is no slower. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Remove dead code and dead dependencies for 3.0.0 setup.py used a bare find_packages(), which ships a TOP-LEVEL `tests` package into every consumer's site-packages where it shadows their own `tests` module. Verified against the published permit==2.8.3, which does exactly that. Now excluded, along with `harness`. permit.pdp_api never passed a timeout to its HTTP client, so the documented pdp_timeout was silently ignored on every permit.pdp_api.* call while the enforcer honoured it. It also duplicated ClientConfig and pagination_params verbatim from permit.api.base; it imports them now. Removed, none of which had a single caller in permit/, tests/ or harness/: set_if_not_none (enforcer), OpaResult and the JWT alias (interfaces), ApiKeyLevel (a self-declared deprecated alias of ApiKeyAccessLevel), LoginAsErrorMessages (never compared against or returned), and three unused TypeVars in the PDP base module. _model_dump was defined identically in both arms of the pydantic version split; hoisted to one definition. Its `mode` parameter stays and stays ignored on purpose -- it absorbs a v2-style argument that pydantic v1's .dict() would reject. Repo cruft: .isort.cfg (isort is not run; ruff's I rules are), uv.lock (a three-line stub declaring requires-python >=3.14, contradicting setup.py), the Makefile publish target (a second release path that bypasses the gated build -> scan -> publish workflow) and a .DEFAULT_GOAL pointing at a help target that did not exist. .gitignore's .DS_Store rule was inert because of an inline comment. Dependencies: dropped pytest-mock (no test uses it) and pytest-cov (coverage is never requested, including in CI). Corrected the werkzeug comment -- it is now a direct test import, not just a pytest_httpserver transitive. Also dropped two references to .trivyignore, which audit-deps.sh deliberately disables with --ignorefile /dev/null, so both were advertising a suppression mechanism that does not work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2 * Skip only the ABAC decision assertions, with the evidence The condition sets and rule this test creates never reach the PDP's policy bundle, so the decision it waits for never becomes true. The PDP says so in the debug.abac payload the SDK already logs: ~90s of no_matching_usersets with "known usersets: ['rules']" (the empty-package placeholder), then one bundle carrying only the condition sets autogenerated by the resource and role creates ten seconds earlier, then nothing for the remaining 300s. The data channel stayed healthy throughout. The pipeline is event-driven with no polling fallback (the default scope is created with poll_updates=False and batching drains rather than waits), so this is a stall, not slowness, and no timeout makes it pass. Skipped rather than xfailed so it reports honestly instead of looking like coverage. Only the three decision assertions are skipped. Everything above them still runs against the real control plane -- condition set and rule create, type round-trip, paginated list, filtered list, permission-format assertion -- and so does the teardown, because pytest.Skipped derives from BaseException and escapes the test's except Exception. Ruled out as causes: resource_id passed as .hex (the generator keys on the resource key, never the id), inline check attributes (they win the object.union_n in the generated rego and the PDP echoed them back), and a missing setup step. No other test is exposed: condition_set_changes.py is the only policy synchronizer handler that generates rego, so RBAC and ReBAC decisions resolve against data.* on the fact channel, and this is the only test that touches condition sets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2 * Fix resource_relations.list() and document two backend contracts resource_relations.list() declared List[RelationRead], but the route is declared response_model=PaginatedResult[RelationRead], so against current backend main the call raised "ValidationError: value is not a valid list" -- the method was unusable. It now returns PaginatedResultRelationRead; callers read .data. BREAKING, and in the 3.0.0 notes. (That change was written earlier and swept into the previous commit by a bare `git add -A`; this records what it actually is.) Two docstrings corrected against the backend, both of which sent callers into a confusing error: - resource_roles.assign_permissions/remove_permissions said permissions are <resourceKey:actionKey>. A resource role is scoped to its own resource, so each entry is a BARE action key. Passing the qualified form makes the server read the whole string as an action key and reject it with a 404 naming '<resource>:<resource>:<action>' -- a doubled prefix that reads like the SDK concatenated wrongly, when it is the server quoting what it was given. - role_assignments.list(resource_instance_key=...) takes a `resource_type:instance_key` ident or an instance uuid, never a bare key. Regression tests pin the exact wire strings on both pydantic majors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2 * Tolerate rate limiting during test teardown Every remaining CI failure was one cause: HTTP 429 on a cleanup call. Enabling the eight previously-xfail tests and giving each its own objects made the suite create and tear down far more than before, and teardown is where the burst lands -- one leg reported 3 failed and 2 teardown errors, the other 7 failed, all of them 429 on a delete. handle_cleanup_error now tolerates 429 alongside 404, for the same reason 404 is tolerated: neither leaves the test's assertions in doubt. A throttled delete leaks an object, and CI deletes the whole scratch environment afterwards, so it is reclaimed. Any other status still fails the test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2 * Retry rate-limited requests instead of tolerating them The previous commit tolerated 429 during teardown. That was wrong in a way the next CI run made obvious: a tolerated DELETE leaves the object alive, so the assert-it-is-gone check that follows failed with "DID NOT RAISE PermitApiError". The tolerance manufactured a worse failure than the one it hid. 429 is no longer tolerated. It was also the wrong layer. The run after showed 429 arriving in test BODIES as well -- test_rebac_e2e, test_sync_client and test_user_invites_complete_e2e all failed mid-test -- so cleanup was never the whole problem. The suite runs against one environment on a shared cloud project and now creates and tears down considerably more than it used to, which exceeds the burst limit. The eight tests that were xfail until this branch had been swallowing these 429s all along. conftest wraps the SDK's five HTTP verbs for the test session only, retrying a 429 with exponential backoff so the call actually succeeds. The SDK is untouched: adding implicit retries to a published client would be a behaviour change callers did not ask for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2 * Make the rate-limit retry more patient Six attempts (~63s of backoff) still ran out on one teardown, leaving CI at 1 failed / 102 passed. Raised to nine, which caps a single call at roughly two minutes of waiting and exits the moment it succeeds. Also honours the server's Retry-After when it sends one, and adds jitter to the exponential fallback so concurrent callers do not retry in lockstep and re-trip the limit together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2 * Point the ABAC skip at PER-16209 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2 * Make CheckQuery.context optional for type checkers bulk_check() reads each query's context with .get(), so a query without one is valid at run time, but the TypedDict declared the key as required and mypy rejected every bulk_check([{"user", "action", "resource"}]) call. TypedDict comes from typing_extensions so NotRequired is honoured on 3.10. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2 * Migrate packaging, dependencies and CI to uv pyproject.toml now carries the PEP 621 metadata setup.py declared, built with uv_build; dev tools move to a PEP 735 group and both pydantic lanes become conflicting groups, so every CI lane installs from the committed uv.lock. setup.py, requirements*.txt, MANIFEST.in, pytest.ini and the Makefile are gone; contributor docs move to CONTRIBUTING.md. CI installs with uv sync --locked; the publish job stamps the version with uv version, builds with uv build --no-sources on a checksum-verified uv, and keeps its build -> scan -> publish gating and PyPI token auth. The audit compiles its three trees from pyproject.toml with --no-sources and fails if the dev group did not resolve. uv is pinned once, by [tool.uv] required-version, with a 7-day exclude-newer cooldown; Dependabot uses the uv ecosystem and a uv-lock hook stops drift. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2 * Adopt strict ruff and mypy, reformat and fix the codebase ruff 0.16.7 with select = ["ALL"] minus justified ignores, line length 100 and Google docstrings; mypy 2.3.1 strict over permit/, tests/ and .github/scripts on both pydantic majors, with TYPE_CHECKING branches so the v1 models type-check as v1 under pydantic 2. ruff, mypy and typos run as local pre-commit hooks from uv.lock (uv run --locked), external hooks are SHA-pinned, pytest runs strict with warnings as errors, and Dependabot covers pre-commit with lint tools grouped apart from runtime floors. No public API or behaviour change; runtime-visible aliases, bare-dict fields and the star-import surface are kept identical. Three bugs the stricter checks exposed are fixed with regression tests: decimal_encoder crashed on NaN/Infinity, a pre-release pydantic version crashed import permit, and import permit raised under -W error because PermitConnectionError subclasses the deprecated PermitException. py.typed is deliberately not shipped yet (PER-16231). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6b4ERDYYZ8NRTv1zJYxx2 * Adopt strict ruff, mypy and typos configuration - ruff 0.16.8 with `select = ["ALL"]`, line length 100, Google docstring convention and docstring-code-format. Every ignore is justified in pyproject.toml. The generated permit/api/models.py and the migration skill's sample apps stay out of lint and format (force-exclude), and the migration scanner is held to Python 3.8 syntax. - mypy 2.3.1 `strict`, plus warn_unreachable and extra error codes, over every Python file but the generated models and the sample apps, with the pydantic.v1 mypy plugin on both pydantic majors. - typos 1.50.2 checks spelling. - pytest runs with `strict = true`. - ruff, ruff-format, mypy and typos are `repo: local` pre-commit hooks running `uv run --locked`, so uv.lock is the only source of their versions. pre-commit-hooks v6.0.0 is pinned by SHA and adds check-shebang-scripts-are-executable. - CI type-checks once more under pydantic 1. - Dependabot gets a pre-commit ecosystem entry, and ruff, mypy and typos a group of their own in the uv entry. The code is reformatted and fixed in the following commits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Find the facade test's call sites without assuming layout The deprecation-warning test expected each warning on the line after its helper's `def`, which stops being true once the formatter wraps the helper's signature. Read the line of the helper's one statement from its syntax tree instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Reformat with ruff format at line length 100 Mechanical: `ruff format` with the configuration from the previous commit. The sync stub generator lays out permit/_sync_types.pyi the way ruff format does at a given line length, so its LINE_LENGTH moves to 100 and the stub is regenerated; the result is what ruff format produces. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let the sync stub generator copy whole-module imports The generator only resolved names brought in with `from module import`. An annotation such as `builtins.list[str]`, which a class that defines a `list` method needs, refers to a module imported whole with `import builtins`; the generator now emits that import in the stub, in the order ruff's isort rules use. The committed stub does not change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Apply ruff's safe fixes Mechanical: `ruff check --fix` (safe fixes only), then `ruff format`, and the sync stub regenerated from the fixed classes. Most of it is PEP 585 and 604 annotations, docstring layout, else-after-return and sorted imports. Three rewrites would have changed runtime objects, so those sites keep their spelling with a noqa that says why: - `Context` and `AuthorizedUsersDict` are public aliases, so they stay `typing.Dict` generics rather than becoming builtin ones. - `UserInput.attributes`, `ResourceInput.attributes` and `ResourceInput.context` stay `typing.Dict`: pydantic v1 validates a `typing.Dict` value into a copy but keeps the caller's object for a bare `dict`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep IncEx a typing generic The safe fixes rewrote the `IncEx` alias in permit/api/encoders.py with builtin generics (`set[int]`, `dict[str, Any]`), which changes the runtime object the alias names. Restore the `typing` generics it had, with a noqa, as for `Context` and `AuthorizedUsersDict`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Type and document the SDK for strict ruff and mypy The SDK now passes `ruff check` and strict mypy under both pydantic majors. Most of it follows the approach of the original PR-128 commit: - absolute imports, return and parameter annotations, `ParamSpec` on `handle_client_error`, `@overload` on `delete()`, and Google docstrings on the public API (the `Sync*` runtime classes included); - `TModel` is no longer bound to `BaseModel`, since list endpoints parse into `list[Model]`, and the unused `TData` is removed; - equivalent rewrites the rules ask for: HTTPStatus constants, messages assigned before `raise`, `input` renamed where it shadowed the builtin. Runtime-visible spellings are kept, with a suppression that says why: the `User`, `Resource` and `_UserSyncInput` aliases, the bare-`dict` pydantic fields, `PermitConnectionError`'s deprecated base, and the positional signatures of four `list()` methods (PLR0917). `UserInput.attributes`, `ResourceInput.attributes` and `ResourceInput.context` become `dict[Any, Any] | None`, which pydantic v1 validates exactly like the `Optional[Dict]` they were: into a copy of the caller's dict. A new test fails if they ever become a bare `dict`, which keeps the caller's object and lets the tenant the SDK adds leak into it. Tooling that goes with it: - PLC0414 is off: `import X as X` is the explicit re-export strict mypy needs, and the SDK uses it for the blocking classes in permit/_sync_types.pyi. - The stub's copied docstrings are allowed (PYI021). - The stub generator accepts a docstring in the `Sync*` runtime classes, and the stub is regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Create test_envs' environments in the project it checks The org-level environment test created each environment under `project`, the variable its project loop left behind, which is unbound when the loop does not run and otherwise names whichever project came last. The assertions that follow check `projects[0]`. Create the environments in `projects[0]` too. mypy reports the old line as possibly undefined. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Stop test_envs' cleanup from hiding the real failure The project-level environment test kept the context's project ID and the project it then looked up in one variable, `project`. When the lookup failed, the `finally` block ran `cleanup(permit, project.key)` on the ID string and raised AttributeError, which replaced the lookup's own error. Look the project up before the `try`, under its own name: until it is known nothing has been created, so there is nothing to clean up. mypy reports the reused variable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Type the tests for strict ruff and mypy The tests now pass `ruff check` and strict mypy under both pydantic majors, mostly following the approach of the original PR-128 commit: return and parameter annotations, absolute imports, typed helpers (`find_by_key` is generic over keyed models), `is not None` asserts where an optional field is read, and `tests/endpoints/__init__.py` so those modules are part of the tests package. A few rewrites the rules ask for: - try/except blocks that only checked an expected error become `pytest.raises`; `test_error_response` used to pass when no error was raised at all; - `pytest.warns` calls name the warning they expect; - loop-bound lambdas become `functools.partial`, and loop variables that shadowed an outer name are renamed. The dict-input `type: ignore`s are gone: `ModelInput` lets type checkers accept dicts. What stays suppressed, and why: - `tests.test_fix_sync` declares its own `SyncClass` classes, whose methods mypy reads as coroutines (a module override for comparison-overlap and unused-coroutine); - validate_arguments' `raw_function` and a test decorator's `__wrapped__` are set at runtime and absent from the types; - `tomli` exists only on Python 3.10; - S603 is off in the tests, which run the interpreter under test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Type and document the repository scripts The CI scripts in .github/scripts, their tests and scripts/generate_sync_stubs.py now pass `ruff check` and strict mypy. Mostly annotations, docstrings and messages assigned before `raise`, following the approach of the original PR-128 commit, plus: - format_audit.py and check_schema_drift.py are executable, as their shebangs say (check-shebang-scripts-are-executable); - the schema download's success path moves to the retry loop's `else`, with a new test that a download which succeeds at once is not repeated (the existing retry test cannot tell); - the stub generator's `resolve` and `class_lines` hand their import bookkeeping to two helpers, and the stub it writes is unchanged; - the tests patch `urllib.request` and `time` directly rather than through the script's module, the same objects. Suppressed with a reason: C901 on functions that are one linear pass (the drift check's model parser, format_audit's `render` and `main`), PLR0917 on `Finding`, S603 where a script runs a fixed command, S310 on the http(s)-only schema download, and PERF203 on its retry loop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Type and document the migration skill The migration scanner and its tests now pass `ruff check` and strict mypy. The scanner's output is unchanged: it reports the same findings as before on both sample apps, on Python 3.8 and 3.9. - The scanner gets docstrings, its long messages are split into adjacent literals (the strings are unchanged; its syntax tree was compared), and one loop becomes a comprehension. - The tests get return annotations and a renamed loop variable. Sample code the scanner reads keeps its layout, with an E501 noqa, since the tests assert on its line numbers. - changes.md: "unparseable" -> "unparsable" (typos). Per-file ignores for the scanner, justified in pyproject.toml: FA100, since it runs on Python 3.8 and keeps its annotations as written rather than add a __future__ import; C901, PLR0911 and PLR0912, since each check walks its cases in one function; PLR2004 for version components and argument counts. The tests suppress S102 where they run the guide's snippets, and call-arg where construct() builds partial models. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fail clearly on non-finite Decimals in request bodies decimal_encoder compared a Decimal's exponent with 0 to choose between int and float. For NaN, sNaN and Infinity the exponent is a string, so the comparison raised an unrelated TypeError ("'>=' not supported between instances of 'str' and 'int"). JSON has no NaN or Infinity, and encoding them as floats would send the API an invalid body. decimal_encoder now raises TypeError naming the value instead. The exception type is unchanged, and finite values encode as before. The new tests fail without the fix for NaN, -NaN, sNaN, Infinity and -Infinity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Import the SDK without a DeprecationWarning PermitConnectionError subclasses the deprecated PermitException on purpose, so that `except PermitException` keeps catching connection errors. typing_extensions' @deprecated warns on every subclass, so `import permit` issued a DeprecationWarning from permit's own code, and raised under `-W error::DeprecationWarning`. The subclass is now defined with that one warning ignored. Code that instantiates or subclasses PermitException still gets the warning, and instantiating PermitConnectionError never did. The new import test fails without the fix, on both pydantic majors. It allows the warning that `import permit` issues on pydantic 1 on purpose. The comment on the compatibility job's narrow -W filter now gives that warning as its reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Fail the tests on any warning The SDK's pytest configuration, and the migration skill's and the CI scripts' configs, now turn every warning into an error, and all three run with pytest's `strict` mode. The one exception is the warning `import permit` issues on pydantic 1 on purpose, which tests/test_fix_pydantic1_deprecation.py checks in a fresh interpreter. The offline suite passes with this on both pydantic majors, and on each compatibility leg (Python 3.10 to 3.14, at the lowest and the newest versions the requirements allow). The compatibility job's narrow -W filter for asyncio.iscoroutinefunction is now covered by the config and is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Wait up to 300s for the PDP and keep its startup in the failure log In CI the PDP's /healthy has taken 63-154s to return 200. It stays 503 until the scratch environment's first policy bundle and data arrive, and until then the PDP restarts its policy service about once a minute. On main after #127 the pydantic-2 job ran past the 180s limit on three attempts, while the same job passed in every PR run. Wait up to 300s. The step still prints how long the PDP took. On failure, drop the PDP's once-a-second health-check lines before taking the tail of its log, so the policy and data fetches that explain a slow start are no longer cut off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep ModelListInput's runtime annotation as typing.List Ruff's UP006 fix changed ModelListInput[X] at runtime from typing.List[X] to list[X]. The two do not compare equal, so get_type_hints() on the bulk methods' undecorated functions returned a different annotation than in 3.0.0. Validation was unaffected. Return typing.List[X] again, and restore the regression test's assertion that the annotation equals List[UserCreate]. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Bound each PDP health probe to 5s and keep the first horizon failure The wait loop's curl had no timeout, so a PDP that accepted the connection and never answered could hold the step indefinitely. Each probe now gives up after 5s. On failure, the PDP log filter dropped every "Health check failed: horizon" line, including the one that says why the PDP never became healthy. Keep the first such line; the later repeats and the GET /health requests are still dropped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Bound the PDP wait by elapsed time rather than by tries With each probe allowed 5s, 300 tries could take far longer than the 300s the error message reports. The loop now stops once 300s have passed, whatever the probes took. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Invite with a role of the invited resource in the invites e2e test The invites target a resource instance, and the API now refuses to approve an invite whose role belongs to another resource (PER-15743). The test gave them a tenant role; it now creates a role on the invited resource and deletes it before the resource. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Regenerate ApproveMessage, whose field the API renamed to detail The live spec renamed ApproveMessage's only field from message to detail, so the schema drift check failed on it. No SDK method returns this model. The class is the generator's output, copied unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Linear issue
PER-16221. Based on
main(permit 3.0.0). The tooling upgrade, PER-16222 (#128), is stacked on this PR.Why
setup.pyandrequirements*.txt.What changed
Packaging
pyproject.tomlholds the[project]metadata (PEP 621) and builds withuv_build.requirements.txt, including the three per-Python pydantic lines and their comments.uv.lockis committed..python-versionis3.11, matching CI.setup.py,requirements.txt,requirements-dev.txt,MANIFEST.in,pytest.iniand theMakefile.Dependencies
devgroup, pinned exactly.pydantic-v1andpydantic-v2are conflicting groups, so both lanes are locked inuv.lock.tomliis added for Python 3.10 only, because the tests now readpyproject.tomlandtomllibstarts at 3.11.uv version
uv==0.12.17in thedevgroup. Every setup-uv step reads it fromuv.lock, and Dependabot updates it like any other pin.[tool.uv] required-version = ">=0.12.17"is a floor, not an exact pin, so Dependabot's own uv can still run.exclude-newer = "7 days":uv lockignores packages published in the last week.CONTRIBUTING.mdexplains how to lock a security fix inside that week.CI
uv sync --lockedand check that the installed pydantic major matches the lane. Job names are unchanged, so required checks still match.pyproject.tomlon Python 3.10–3.14, and checks the wheel and sdist for type information.pyproject.toml, fails if thedevgroup didn't resolve, and still skipsskills/tests/fixtures.uv versionfrom the validated tag and builds withuv build. Build → scan → publish and the PyPI token are unchanged.Dependabot and hooks
uvecosystem, with the same cooldowns, groups andversioning-strategy: increase.!=exclusions, as in deps: update pydantic requirement from !=2.0.*,!=2.1.*,!=2.2.*,!=2.3.*,!=2.4.0,!=2.4.1,>=1.10.18 to !=2.0.0.dev,!=2.1.0.dev,!=2.2.0.dev,!=2.3.0.dev,!=2.4.0,!=2.4.1,>=2.13.5 #130). The audit still scans pydantic.uv_build, which is bumped by hand together with the release job's uv. Otherwise a bump could make releases build with a uv_build downloaded from PyPI instead of the checksum-verified one.uv-lockpre-commit hook runsuv lock --check, and fails whenpyproject.tomlanduv.lockdisagree.uv run(as in CI) is the pinned uv, so there is no second uv version to keep in step.[tool.uv]fails instead of re-locking.Developer docs and scripts
CONTRIBUTING.md: setup, tests on both pydantic lanes, building, model regeneration, the schema-drift check and releasing.scripts/generate_models.sh, with the same pinned command. The schema-drift test now checks that script.uv run python scripts/generate_sync_stubs.py.[tool.pytest].skills/testsand.github/scriptskeep their ownpytest.ini.What changes in the published package
permit/files as main's build, includingpy.typedand_sync_types.pyi, and notestspackage.Requires-Distallows the same versions as main on every final Python release, 3.8 to 3.16.pyproject.toml.Apache-2.0),Author-emailincludes the name, and there are Project-URL entries.MIGRATION.mdandCONTRIBUTING.md, and no longer shipstests/,setup.pyorrequirements.txt.Architectural changes
No architectural change.
How it was tested
uv lock --checkpasses with uv 0.12.17 and 0.12.18.uv buildwith either version shipspy.typedand_sync_types.pyi.v3.0.0and3.0.1rc1, and rejects malformed tags without touchingpyproject.toml.scripts/generate_models.shfails the flag-parity test.uv-lockhook, run with uv 0.12.16, 0.12.17, 0.12.21 and 0.9.5 on PATH, passes an in-sync lock with 0.12.17 and 0.12.21. It fails a stale lock and every uv below the floor, and never modifiesuv.lock.Manual test plan
uv self update 0.12.17) and runuv sync. Expect pydantic 2.x.uv sync --group pydantic-v1, thenuv run python -c "import pydantic; print(pydantic.VERSION)". Expect 1.10.x.uv run pytest -m "not e2e" -q. Expect 291 passed, 3 skipped.uv buildand check that the wheel holds onlypermit/(withpy.typedand_sync_types.pyi) and dist-info.Blast radius and isolation
devgroup. An older uv fails therequired-versioncheck, and theuv-lockhook catches lock drift.Scope and size
uv.lockand tests.Deferred to PER-16222
pre-commitentry.🤖 Generated with Claude Code