Skip to content

Migrate packaging, dependencies and CI to uv - #127

Merged
zeevmoney merged 115 commits into
mainfrom
per-16221/uv-migration
Sep 29, 2026
Merged

zeevmoney merged 115 commits into
mainfrom
per-16221/uv-migration

Conversation

@zeevmoney

@zeevmoney zeevmoney commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Linear issue

PER-16221. Based on main (permit 3.0.0). The tooling upgrade, PER-16222 (#128), is stacked on this PR.

Why

  • The SDK is packaged with setup.py and requirements*.txt.
  • CI installs with plain pip and there is no lockfile, so every CI run can resolve a different dependency tree.
  • This PR moves packaging, dependencies and CI to uv. It changes no SDK code and upgrades no tools.

What changed

Packaging

  • pyproject.toml holds the [project] metadata (PEP 621) and builds with uv_build.
  • The runtime requirements are copied line for line from main's requirements.txt, including the three per-Python pydantic lines and their comments.
  • uv.lock is committed.
  • .python-version is 3.11, matching CI.
  • Deleted: setup.py, requirements.txt, requirements-dev.txt, MANIFEST.in, pytest.ini and the Makefile.

Dependencies

uv version

  • CI runs the uv pinned as uv==0.12.17 in the dev group. Every setup-uv step reads it from uv.lock, and Dependabot updates it like any other pin.
  • The release build job pins its own uv (0.12.18) by version and checksum, so a Dependabot update can't change the tool that builds releases.
  • [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 lock ignores packages published in the last week. CONTRIBUTING.md explains how to lock a security fix inside that week.
  • The pin is 0.12.17 because 0.12.18 is still inside that 7-day window. Dependabot will raise it.

CI

  • The test jobs install with uv sync --locked and check that the installed pydantic major matches the lane. Job names are unchanged, so required checks still match.
  • The compatibility job builds its floor and newest trees from pyproject.toml on Python 3.10–3.14, and checks the wheel and sdist for type information.
  • The dependency audit compiles its four trees from pyproject.toml, fails if the dev group didn't resolve, and still skips skills/tests/fixtures.
  • The publish workflow sets the version with uv version from the validated tag and builds with uv build. Build → scan → publish and the PyPI token are unchanged.
  • pre-commit and schema drift run through uv.

Dependabot and hooks

  • Dependabot uses the uv ecosystem, with the same cooldowns, groups and versioning-strategy: increase.
  • It ignores pydantic: it can't update the per-Python lines correctly (it skips one and rewrites the != 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.
  • It ignores 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.
  • A local uv-lock pre-commit hook runs uv lock --check, and fails when pyproject.toml and uv.lock disagree.
    • It uses the uv on PATH, which under uv run (as in CI) is the pinned uv, so there is no second uv version to keep in step.
    • It never writes, so a uv too old to read [tool.uv] fails instead of re-locking.

Developer docs and scripts

  • New CONTRIBUTING.md: setup, tests on both pydantic lanes, building, model regeneration, the schema-drift check and releasing.
  • Model generation moves from the Makefile to scripts/generate_models.sh, with the same pinned command. The schema-drift test now checks that script.
  • Sync stubs: uv run python scripts/generate_sync_stubs.py.
  • pytest settings move to [tool.pytest]. skills/tests and .github/scripts keep their own pytest.ini.
  • The README image uses an absolute URL, so it renders on PyPI.

What changes in the published package

  • Wheel: the same 49 permit/ files as main's build, including py.typed and _sync_types.pyi, and no tests package.
  • Dependencies: Requires-Dist allows the same versions as main on every final Python release, 3.8 to 3.16.
  • Pre-releases: uv_build writes the markers as full-version ranges, so a Python 3.14.0 alpha, beta or RC gets no pydantic requirement. This is noted in pyproject.toml.
  • Metadata: the license is an SPDX expression (Apache-2.0), Author-email includes the name, and there are Project-URL entries.
  • sdist: ships the README, MIGRATION.md and CONTRIBUTING.md, and no longer ships tests/, setup.py or requirements.txt.

Architectural changes

No architectural change.

How it was tested

  • uv lock --check passes with uv 0.12.17 and 0.12.18.
  • uv build with either version ships py.typed and _sync_types.pyi.
  • Offline tests pass on pydantic 1 and 2, Python 3.10 and 3.14: 291 passed, 3 skipped.
  • Migration skill tests: 86 passed. CI script tests: 107 passed.
  • All 16 compatibility legs pass, and they resolve the same floors as main.
  • The dependency audit finds 0 vulnerabilities in all four trees.
  • The publish version step accepts v3.0.0 and 3.0.1rc1, and rejects malformed tags without touching pyproject.toml.
  • Mutation checks: loosening a pydantic floor fails the tests, and editing scripts/generate_models.sh fails the flag-parity test.
  • actionlint, zizmor, shellcheck and all pre-commit hooks pass. The required check names match main's.
  • The uv-lock hook, 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 modifies uv.lock.

Manual test plan

  1. Install uv 0.12.17 (uv self update 0.12.17) and run uv sync. Expect pydantic 2.x.
  2. Run uv sync --group pydantic-v1, then uv run python -c "import pydantic; print(pydantic.VERSION)". Expect 1.10.x.
  3. Run uv run pytest -m "not e2e" -q. Expect 291 passed, 3 skipped.
  4. Run uv build and check that the wheel holds only permit/ (with py.typed and _sync_types.pyi) and dist-info.

Blast radius and isolation

  • Affected: the build, CI, the release workflow and Dependabot. No SDK code changes.
  • Consumers: the same allowed dependency versions, plus the metadata changes above.
  • Contributors: use the uv version pinned in the dev group. An older uv fails the required-version check, and the uv-lock hook catches lock drift.
  • Isolation: isolated.

Scope and size

  • About 760 lines added and 350 removed, excluding uv.lock and tests.
  • About 70 test lines changed.
  • Single responsibility: yes.

Deferred to PER-16222

  • Run the pre-commit hooks from the locked tools, and upgrade ruff and mypy with strict rules.
  • Pin hook revs by SHA, and add a Dependabot pre-commit entry.

🤖 Generated with Claude Code

zeevmoney and others added 17 commits September 21, 2026 17:19
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>
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
@linear-code

linear-code Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

PER-16221

PER-16336

zeevmoney and others added 11 commits September 23, 2026 14:41
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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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
Copilot AI review requested due to automatic review settings September 28, 2026 21:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

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
Copilot AI review requested due to automatic review settings September 28, 2026 21:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@EliMoshkovich EliMoshkovich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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 --check passes.
  • uv build --no-sources: the wheel has exactly 49 permit/ files, including py.typed and _sync_types.pyi, and no tests/harness. The sdist has README, MIGRATION.md, CONTRIBUTING.md and LICENSE.
  • The wheel's Requires-Dist has the same ranges as main. The markers become python_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.1rc1 rewrites 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.17 from uv.lock (version-file).
  • Dependabot compatibility: dependabot-core currently bundles uv==0.12.18 (uv/helpers/requirements.txt), so the required-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:

  1. The uv-lock hook rev in .pre-commit-config.yaml has to be kept equal to the uv== 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.
  2. FYI: uv_build now ships the sdist with a normalized pyproject.toml (comments stripped) plus the original as pyproject.toml.orig. That's harmless and just a heads-up in case someone asks what the extra file in the sdist is.

Comment thread .pre-commit-config.yaml Outdated
@zeevmoney

zeevmoney commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

From @EliMoshkovich's review: "The uv-lock hook rev in .pre-commit-config.yaml has to be kept equal to the uv== dev pin by hand."

Fixed in 3351fbd. The hook now runs uv lock --check with the uv on PATH, which is the pinned uv under uv run, so there is no second version to keep equal. Details and local results are in the inline thread. This commit came after your approval, so it needs another look.

"uv_build now ships the sdist with a normalized pyproject.toml (comments stripped) plus the original as pyproject.toml.orig."

I confirmed this with uv 0.12.18, the version the release job builds with. The sdist's pyproject.toml has no comments, and pyproject.toml.orig is byte-identical to the repo's file. No change needed.

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>
Copilot AI lite review requested due to automatic review settings September 29, 2026 22:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@zeevmoney
zeevmoney merged commit 040a744 into main Sep 29, 2026
30 of 31 checks passed
@zeevmoney
zeevmoney deleted the per-16221/uv-migration branch September 29, 2026 22:49
zeevmoney added a commit that referenced this pull request Sep 30, 2026
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>
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.

3 participants