Skip to content

Commit 040a744

Browse files
zeevmoneyclaude
andauthored
Migrate packaging, dependencies and CI to uv (#127)
Move packaging, dependencies and CI from setup.py and requirements*.txt to uv. No SDK code changes. Packaging: - pyproject.toml holds the PEP 621 metadata and builds with uv_build. The runtime requirements match main's requirements.txt line for line. - uv.lock is committed. setup.py, requirements*.txt, MANIFEST.in, pytest.ini and the Makefile are removed. Dependencies: - Dev tools are exact pins in a PEP 735 dev group. pydantic-v1 and pydantic-v2 are conflicting groups, so both lanes are locked. - tomli is added for Python 3.10 only. - Includes Dependabot's dev updates from #129, #131, #132 and #133. 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. The release build job pins its own uv by version and checksum. - [tool.uv] required-version is a floor, and exclude-newer is 7 days. CI and hooks: - Test, compatibility, audit, pre-commit and schema-drift jobs run through uv. Job names are unchanged. - The publish workflow sets the version with `uv version` and builds with `uv build`. Build, scan and publish are unchanged. - Dependabot uses the uv ecosystem and ignores pydantic and uv_build, which are updated by hand. - A local uv-lock pre-commit hook runs `uv lock --check` with the uv on PATH. Docs: - New CONTRIBUTING.md. Model generation moves to scripts/generate_models.sh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 99707ce commit 040a744

32 files changed

Lines changed: 2492 additions & 401 deletions

‎.github/dependabot.yml‎

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,71 @@
11
version: 2
22
updates:
3-
# Python dependencies (requirements.txt + requirements-dev.txt).
3+
# Python dependencies: pyproject.toml ([project].dependencies and the
4+
# dependency groups) plus uv.lock, which Dependabot re-locks in the same PR.
45
#
5-
# This package publishes open `>=` ranges rather than a lockfile, so a
6-
# Dependabot PR here raises the *floor* consumers are allowed to install on,
7-
# not just the version CI happens to resolve. That is the whole point: the
8-
# floor is the exposure, and the audit gate in security.yml scans it
9-
# explicitly.
10-
- package-ecosystem: "pip"
6+
# Consumers never see uv.lock -- this package publishes open `>=` ranges --
7+
# so a Dependabot PR here raises the *floor* consumers are allowed to install
8+
# on, not just the version CI happens to resolve. That is the whole point:
9+
# the floor is the exposure, and the audit gate in security.yml scans it
10+
# explicitly. pydantic's floors are the exception: they are kept by hand
11+
# (see `ignore` below).
12+
- package-ecosystem: "uv"
1113
directory: "/"
1214
schedule:
1315
interval: "weekly"
1416
day: "monday"
1517
open-pull-requests-limit: 5
16-
# REQUIRED, not cosmetic. With a setup.py present Dependabot classifies
17-
# this project as a library and defaults to `widen`, which only relaxes
18-
# upper bounds and would never raise a `>=` floor -- so the automation
19-
# would silently never do the one thing this file exists to do.
20-
# `increase` raises the lower bound instead.
18+
# REQUIRED, not cosmetic. Left at `auto`, Dependabot may classify this
19+
# published package as a library and default to `widen`, which only
20+
# relaxes upper bounds and would never raise a `>=` floor -- so the
21+
# automation would silently never do the one thing this file exists to
22+
# do. `increase` raises the lower bound instead (and moves the exact `==`
23+
# pins in the dev group).
2124
versioning-strategy: increase
2225
# Wait 7 days before proposing a release, 14 for a major. A brand-new
2326
# version is the window in which a compromised or yanked package is most
2427
# likely to still be live, and nothing here is urgent enough to need
25-
# day-zero adoption. Security updates are exempt from cooldown by
26-
# Dependabot and still arrive immediately.
28+
# day-zero adoption. Security updates are exempt from this cooldown, but
29+
# the `uv lock` Dependabot runs still applies exclude-newer = "7 days" from
30+
# pyproject.toml, so a fix released less than 7 days ago cannot be locked
31+
# and its update fails until the release is 7 days old. To take such a fix
32+
# sooner, lock it by hand with an exclude-newer-package entry (see
33+
# CONTRIBUTING.md, "Dependencies").
2734
cooldown:
2835
default-days: 7
2936
semver-major-days: 14
3037
groups:
3138
minor-and-patch:
3239
update-types: ["minor", "patch"]
3340
ignore:
34-
# pydantic is dual-supported on purpose: permit/utils/pydantic_version.py
35-
# branches on PYDANTIC_VERSION and every model imports from either
36-
# `pydantic` (v1) or `pydantic.v1` (v2 compat shim). A Dependabot major
37-
# bump cannot reason about that and would silently propose dropping v1
38-
# support, so majors are handled by hand. Minor/patch still flow through.
41+
# pydantic is updated by hand, for every update type. The SDK supports
42+
# both majors on purpose (permit/utils/pydantic_version.py), and
43+
# [project].dependencies has one pydantic line per Python range, with
44+
# floors and `!=` exclusions whose reasons are in the comments there.
45+
# Dependabot cannot keep those lines correct:
46+
# - it skips a requirement whose marker contains `<`, so the
47+
# python_version < "3.13" line would never be updated;
48+
# - on the lines it does update, it rewrites each `!=X.Y.*` exclusion as
49+
# `!=X.Y.0.dev`, which lets the X.Y releases back in (as PR #130 did to
50+
# the old requirements.txt);
51+
# - a major bump would drop pydantic 1 support.
52+
# An ignore with no update-types also stops Dependabot security updates
53+
# for pydantic. The audit gate in security.yml scans the newest pydantic
54+
# and its pydantic 1 and pydantic 2 floors (resolved for Python 3.10), and
55+
# fails on a fixable advisory. The pydantic versions in uv.lock move with
56+
# `uv lock --upgrade-package pydantic`.
3957
#
40-
# Removal gate: drop this entry once the SDK stops supporting pydantic v1.
58+
# Removal gate: drop this entry once pydantic is one requirement with no
59+
# `!=` exclusions and no `<` marker.
4160
- dependency-name: "pydantic"
42-
update-types: ["version-update:semver-major"]
61+
# uv_build, the build backend in [build-system], is updated by hand
62+
# together with the uv version and checksum that python-sdk-publish.yml's
63+
# build job pins. That uv builds releases with its own built-in backend
64+
# only while the uv_build bound allows its version. A Dependabot PR that
65+
# raised the bound past it would make release builds download uv_build
66+
# from PyPI instead, around the checksum-verified binary, and nothing
67+
# would fail.
68+
- dependency-name: "uv_build"
4369
commit-message:
4470
prefix: "deps"
4571
prefix-development: "deps-dev"
@@ -48,7 +74,7 @@ updates:
4874

4975
# GitHub Actions versions.
5076
# Note: cooldown.semver-major-days is not supported for github-actions --
51-
# Dependabot only honours it on semver-strict ecosystems like pip and npm.
77+
# Dependabot only honours it on semver-strict ecosystems like uv and npm.
5278
- package-ecosystem: "github-actions"
5379
directory: "/"
5480
schedule:

‎.github/scripts/audit-deps.sh‎

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,34 +8,39 @@
88
# file literally named requirements.txt, plus one Trivy report per tree:
99
#
1010
# runtime-ceiling/ + trivy-runtime-ceiling.json
11-
# requirements.txt alone, current resolution. What a fresh
12-
# `pip install permit` gets today.
11+
# pyproject.toml [project].dependencies alone, current resolution. What
12+
# a fresh `pip install permit` gets today.
1313
# runtime-floor/ + trivy-runtime-floor.json
1414
# runtime-floor-pydantic-v2/ + trivy-runtime-floor-pydantic-v2.json
15-
# requirements.txt alone, lowest-direct. Together, the lowest versions
16-
# the PUBLISHED specs permit -- i.e. real consumer exposure. These are
17-
# the trees that matter most for a library with open `>=` ranges.
18-
# requirements.txt accepts either pydantic major, and lowest-direct
19-
# picks the lowest release it allows, which is a pydantic 1 release, so
20-
# runtime-floor alone never scans a pydantic 2 floor.
15+
# pyproject.toml [project].dependencies alone, lowest-direct. Together,
16+
# the lowest versions the PUBLISHED specs permit -- i.e. real consumer
17+
# exposure. These are the trees that matter most for a library with open
18+
# `>=` ranges. The dependencies accept either pydantic major, and
19+
# lowest-direct picks the lowest release they allow, which is a pydantic 1
20+
# release, so runtime-floor alone never scans a pydantic 2 floor.
2121
# runtime-floor-pydantic-v2 holds pydantic to 2 and scans the lowest
2222
# pydantic 2 (and the pydantic-core it pins) the specs permit.
2323
# dev-ceiling/ + trivy-dev-ceiling.json
24-
# requirements.txt + requirements-dev.txt, current resolution. Test
25-
# tooling only; never ships to a user.
24+
# [project].dependencies + the `dev` dependency group, current
25+
# resolution. Test tooling only; never ships to a user.
26+
#
27+
# These are compiled from pyproject.toml, NOT exported from uv.lock: the lock
28+
# pins one resolution for this repo's own CI, while the audit has to see what a
29+
# consumer can resolve from the published ranges -- today's ceiling and the
30+
# floors.
2631
#
2732
# Plus pip-audit-<tree>.json (advisory only) for each of the four trees.
2833
#
29-
# WHY RUNTIME IS COMPILED ALONE. Compiling the runtime and dev files together
34+
# WHY RUNTIME IS COMPILED ALONE. Compiling the runtime and dev deps together
3035
# lets a dev tool drag a runtime dependency's floor upward and hide the real
3136
# exposure: when a dev tool needs a newer release of a runtime dependency than
32-
# the floor in requirements.txt, the combined floor resolves that newer release,
37+
# the floor in pyproject.toml, the combined floor resolves that newer release,
3338
# but a consumer installing only `permit` can still land on the older one.
3439
# Scanning the combined floor would silently under-report exactly the versions
3540
# users can actually get.
3641
#
3742
# WHY COMPILE AT ALL. Trivy's pip analyzer only understands `==`. Pointed at
38-
# this repo's raw requirements.txt it reports zero findings and exits 0 -- a
43+
# a list of open ranges it reports zero findings and exits 0 -- a
3944
# silently green gate. It also keys on the FILENAME, which is why each tree is
4045
# written to its own directory as `requirements.txt` rather than scanned as a
4146
# loose file (a loose file reports "Not scanned" and, again, exits 0).
@@ -46,6 +51,14 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
4651

4752
# The declared minimum. Resolving at the floor of supported Python is the
4853
# worst case a consumer can legitimately be in.
54+
#
55+
# Python 3.10 only, on purpose. On 3.13 and 3.14 the pydantic floors are higher
56+
# (1.10.18/2.8.0 and 1.10.25/2.13), so those exact versions are never resolved
57+
# here. An advisory that affects everything below its fixed version, which is
58+
# almost every advisory, affects the lower 3.10 floor whenever it affects a
59+
# higher one, so it still fails this gate. What is missed is an advisory
60+
# confined to a later range that leaves the 3.10 floor out; the ceiling trees
61+
# still cover the newest releases.
4962
PYTHON_VERSION="${AUDIT_PYTHON_VERSION:-3.10}"
5063

5164
# A resolved tree with almost nothing in it means the compile silently produced
@@ -57,7 +70,17 @@ compile_tree() {
5770
local name="$1" resolution="$2"
5871
shift 2
5972
mkdir -p "${OUT}/${name}"
60-
local args=(--python-version "${PYTHON_VERSION}" --quiet -o "${OUT}/${name}/requirements.txt")
73+
# --no-sources: the published build ignores [tool.uv.sources] (uv build
74+
# --no-sources), so the audit must too. --exclude-newer false: the publish-age
75+
# cooldown in pyproject.toml applies to this repo's `uv lock` only; consumers
76+
# resolve against the index as it is today.
77+
local args=(
78+
--no-sources
79+
--exclude-newer false
80+
--python-version "${PYTHON_VERSION}"
81+
--quiet
82+
-o "${OUT}/${name}/requirements.txt"
83+
)
6184
if [ -n "${resolution}" ]; then
6285
args+=(--resolution "${resolution}")
6386
fi
@@ -78,12 +101,26 @@ echo "::group::Resolving dependency trees (python ${PYTHON_VERSION})"
78101
# lowest-direct, not lowest: pin the declared bounds to their floor but let
79102
# transitives resolve normally. Plain `lowest` would drag every transitive back
80103
# to its first ever release and drown the report in irrelevant history.
81-
compile_tree runtime-ceiling "" "${REPO_ROOT}/requirements.txt"
82-
compile_tree runtime-floor "lowest-direct" "${REPO_ROOT}/requirements.txt"
104+
# Passing pyproject.toml compiles [project].dependencies only; dependency
105+
# groups are added solely by an explicit --group.
106+
compile_tree runtime-ceiling "" "${REPO_ROOT}/pyproject.toml"
107+
compile_tree runtime-floor "lowest-direct" "${REPO_ROOT}/pyproject.toml"
83108
echo "pydantic>=2" >"${OUT}/pydantic-v2-constraint.txt"
84-
compile_tree runtime-floor-pydantic-v2 "lowest-direct" "${REPO_ROOT}/requirements.txt" \
109+
compile_tree runtime-floor-pydantic-v2 "lowest-direct" "${REPO_ROOT}/pyproject.toml" \
85110
--constraints "${OUT}/pydantic-v2-constraint.txt"
86-
compile_tree dev-ceiling "" "${REPO_ROOT}/requirements.txt" "${REPO_ROOT}/requirements-dev.txt"
111+
compile_tree dev-ceiling "" "${REPO_ROOT}/pyproject.toml" \
112+
--group "${REPO_ROOT}/pyproject.toml:dev"
113+
114+
# The package-count check above cannot tell a dev tree from a runtime one, so a
115+
# --group that silently matched nothing would scan the runtime tree twice and
116+
# report the dev tooling as clean.
117+
if ! grep -q '^pytest==' "${OUT}/dev-ceiling/requirements.txt"; then
118+
message="Tree 'dev-ceiling' does not contain pytest, so the 'dev' dependency group"
119+
message+=" was not resolved. Refusing to scan a runtime-only tree and report the dev"
120+
message+=" tooling as clean."
121+
echo "::error title=Dependency resolution failed::${message}"
122+
exit 1
123+
fi
87124
echo "::endgroup::"
88125

89126
# Trivy exits non-zero on findings when --exit-code is set. We do not set it:

‎.github/scripts/check_schema_drift.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
permit/api/models.py is generated from https://api.permit.io/v2/openapi.json and
55
then edited by hand in a few places. Nothing regenerates it on a schedule, so the
66
API schema can move on without the SDK noticing. This script generates models from
7-
the current schema with the same generator and flags as `make generate-models`,
7+
the current schema with the same generator and flags as scripts/generate_models.sh,
88
then compares the two modules structurally: it parses both with `ast` and compares
99
classes, fields, field types, required vs optional, defaults, aliases, the model
1010
`Config.extra` setting and enum members. Formatting, field order, titles,
@@ -64,8 +64,8 @@
6464
# The generator release that produced permit/api/models.py (0.33.0 was current on
6565
# its 2025-09-17 timestamp). --exclude-newer freezes the generator's own
6666
# dependencies and formatters at the end of that day (UTC), whose pydantic-core has
67-
# no Python 3.14 wheel, hence --python 3.11. The Makefile's generate-models target
68-
# uses the same values; test_check_schema_drift.py keeps the two in step.
67+
# no Python 3.14 wheel, hence --python 3.11. scripts/generate_models.sh uses the
68+
# same values; test_check_schema_drift.py keeps the two in step.
6969
GENERATOR_PYTHON = "3.11"
7070
GENERATOR_EXCLUDE_NEWER = "2025-09-18T00:00:00Z"
7171
GENERATOR_PACKAGE = "datamodel-code-generator==0.33.0"
@@ -500,8 +500,8 @@ def render(result: Result, compared_with: str) -> str:
500500
out.append("")
501501
if result.new or result.stale:
502502
out.append(
503-
"To resolve: regenerate the models (`make generate-models`, see the comment above generate-models in "
504-
"the Makefile), or add each intended difference to `.github/scripts/schema_drift_allowlist.json` "
503+
"To resolve: regenerate the models (`bash scripts/generate_models.sh`, see the comment at the top of "
504+
"that script), or add each intended difference to `.github/scripts/schema_drift_allowlist.json` "
505505
"with a one-line reason."
506506
)
507507
out.append("")

‎.github/scripts/format_audit.py‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -489,12 +489,21 @@ def render(
489489
out.append("### How to fix")
490490
out.append("")
491491
out.append(
492-
"Raise the affected lower bound in `requirements.txt` (or `requirements-dev.txt`) "
493-
"to at least the *Fixed in* version above. Because this package publishes open "
492+
"Raise the affected lower bound in `pyproject.toml` (`[project].dependencies`, or the "
493+
"pin in the `dev` dependency group) to at least the *Fixed in* version above, then "
494+
"run `uv lock`. Because this package publishes open "
494495
"`>=` ranges, the floor is what consumers can actually install -- bumping only the "
495496
"resolved version does not close the hole."
496497
)
497498
out.append("")
499+
out.append(
500+
"This audit resolves without the 7-day publish-age cooldown (`exclude-newer`) in "
501+
"`[tool.uv]`, but `uv lock` applies it. If `uv lock` says the fixed version was "
502+
"filtered by `exclude-newer`, add `exclude-newer-package = { <package> = false }` "
503+
"under `[tool.uv]` in `pyproject.toml`, run `uv lock` and commit both files. Once "
504+
"the release is 7 days old, remove the entry and run `uv lock` again."
505+
)
506+
out.append("")
498507
out.append(
499508
"An advisory with no fix available does not block the build -- it is reported "
500509
"here so it can be tracked, but no version bump can resolve it. Suppression "

‎.github/scripts/pytest.ini‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
# Configuration for the audit script tests alone. pytest reads the first
2-
# pytest.ini it finds walking up from the tests it is given, so this file keeps
3-
# it from reaching the SDK's pytest.ini at the repository root, whose
4-
# asyncio_mode option belongs to pytest-asyncio. These tests need only pytest
5-
# and the standard library, and warn about nothing: any warning is an error.
1+
# Configuration for the CI script tests alone (format_audit.py and
2+
# check_schema_drift.py), passed with -c so pytest does not use the SDK's
3+
# configuration in pyproject.toml ([tool.pytest]), whose testpaths and
4+
# asyncio_mode belong to the SDK's suite. These tests need only pytest and the
5+
# standard library, and warn about nothing: any warning is an error.
66
[pytest]
77
filterwarnings = error

‎.github/scripts/test_check_schema_drift.py‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
These pin what the workflow relies on: which differences fail and which are only
44
reported, that the allowlist suppresses exactly what it records and nothing else,
55
that a run which could not compare exits 2 instead of passing, and that the
6-
generator the script runs is the one `make generate-models` runs. No network and no
7-
generator: each test compares small model modules written as source text.
6+
generator the script runs is the one scripts/generate_models.sh runs. No network
7+
and no generator: each test compares small model modules written as source text.
88
9-
Run with: python -m pytest .github/scripts/test_check_schema_drift.py
9+
Run with:
10+
uv run --only-dev pytest -c .github/scripts/pytest.ini .github/scripts/test_check_schema_drift.py
1011
"""
1112

1213
from __future__ import annotations
@@ -481,14 +482,16 @@ def test_pipes_and_backticks_in_schema_text_cannot_break_the_table():
481482
assert row.count("`") == 6
482483

483484

484-
# --- the generator is the one make generate-models runs -----------------------
485+
# --- the generator is the one scripts/generate_models.sh runs -----------------
485486

486487

487-
def test_generator_matches_the_makefile():
488-
makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
489-
recipe = re.search(r"^generate-models:\n((?:\t.*\n?)+)", makefile, re.MULTILINE)
490-
assert recipe, "the Makefile has no generate-models recipe"
491-
words = shlex.split(recipe.group(1).replace("\\\n", " "))
488+
def test_generator_matches_the_generate_models_script():
489+
script = (REPO_ROOT / "scripts" / "generate_models.sh").read_text(encoding="utf-8")
490+
# The command starts on a line beginning with `uvx ` and continues over every
491+
# line that ends in a backslash.
492+
command = re.search(r"^uvx (?:.*\\\n)*.*", script, re.MULTILINE)
493+
assert command, "scripts/generate_models.sh has no line starting with `uvx `"
494+
words = shlex.split(command.group(0).replace("\\\n", " "))
492495
assert words[:7] == [
493496
"uvx",
494497
"--python",

‎.github/scripts/test_format_audit.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
of a fence or a workflow command, and a pip-audit that did not check a tree is
66
always named rather than passing for a clean result.
77
8-
Run with: python -m pytest .github/scripts/test_format_audit.py
8+
Run with:
9+
uv run --only-dev pytest -c .github/scripts/pytest.ini .github/scripts/test_format_audit.py
910
"""
1011

1112
from __future__ import annotations
@@ -349,6 +350,14 @@ def test_fixable_high_blocks():
349350
assert Finding("CVE-1", "pkg", "1.0", "HIGH", "2.0", "t", "", "trivy").blocking is True
350351

351352

353+
def test_fix_instructions_cover_a_fix_uv_lock_still_filters_out():
354+
# The gate resolves with --exclude-newer false, so it blocks on the day a fix
355+
# is released, while `uv lock` keeps that release out for 7 days. The report
356+
# must say how to lock it anyway, or the block cannot be cleared.
357+
out = render([Finding("CVE-1", "pkg", "1.0", "HIGH", "2.0", "t", "", "trivy")], [], "", blocking=True)
358+
assert "exclude-newer-package = { <package> = false }" in out
359+
360+
352361
def test_gate_exits_1_on_fixable_high(tmp_path: Path):
353362
report = tmp_path / "trivy.json"
354363
report.write_text(json.dumps(trivy_report(vuln())))

0 commit comments

Comments
 (0)