Skip to content

Adopt strict ruff and mypy, reformat and fix the codebase - #128

Draft
zeevmoney wants to merge 1 commit into
per-16221/uv-migrationfrom
per-16222/strict-tooling
Draft

zeevmoney wants to merge 1 commit into
per-16221/uv-migrationfrom
per-16222/strict-tooling

Conversation

@zeevmoney

@zeevmoney zeevmoney commented Sep 23, 2026 •

Copy link
Copy Markdown

Linear issue

PER-16222. Stacked on #127 (uv migration), which is stacked on #126.

Why

This is a public SDK. It was linted by ruff 0.6.9 with pyupgrade disabled, and type-checked by a non-strict mypy 1.11.2.

  • mypy ignored the tests, and its pre-commit environment could not see aiohttp or loguru.
  • Under pydantic 2, mypy was checking the SDK's pydantic.v1 models against the v2 API.
  • The tool versions in uv.lock were not the ones that actually ran.

What changed

Tooling

  • ruff 0.16.7: select = ["ALL"], line length 100, Google docstring convention and docstring-code-format. Every ignore has a one-line justification in pyproject.toml. The generated permit/api/models.py stays out of lint and format (force-exclude).
  • mypy 2.3.1 strict, plus warn_unreachable and 11 extra error codes, over permit/, tests/ and .github/scripts/. It runs under both pydantic majors with the pydantic.v1.mypy plugin.
  • typos checks spelling.
  • Pre-commit:
    • ruff, ruff-format, mypy and typos are repo: local hooks running uv run --locked, so uv.lock is the only source of their versions and a stale lock fails loudly.
    • The mypy hook also re-runs when pyproject.toml or uv.lock changes.
    • pre-commit-hooks v6.0.0 and uv-pre-commit are pinned by SHA.
    • check-shebang-scripts-are-executable is added.
  • CI: pre-commit.yml runs uv sync --locked, then the hooks, then mypy again on the pydantic-v1 lane.
  • pytest: strict = true and filterwarnings = ["error"].
  • Dependabot: a new pre-commit ecosystem entry. ruff, mypy and typos get their own group, so a lint-rule break can't hold up runtime floor bumps.
  • Versions respect the repo's 7-day publish cooldown: ruff 0.16.8 was too new.

Code

  • Mechanical changes: formatting, absolute imports, pyupgrade, and about 180 docstrings on the public API.
  • Typing:
    • if TYPE_CHECKING: branches import pydantic.v1 at the 28 version-conditional import sites. The runtime branches are verbatim.
    • ParamSpec on the SDK's decorators and @overload on delete().
    • Explicit re-exports in permit/__init__.py.
  • Test bugs mypy found: an unbound variable in test_envs, and a reused variable whose finally block would have crashed and hidden the real failure.
  • No public API or behaviour change.
    • Runtime-visible aliases (Context, AuthorizedUsersDict, IncEx, User, Resource), bare-dict pydantic fields and deep_merge's key access are deliberately kept identical, each with a justified suppression.
    • dir(permit) and the from permit import * names are identical to Migrate packaging, dependencies and CI to uv #127 on both pydantic majors.
    • One autofix that would have changed behaviour was caught and reverted: Optional[Dict] → dict in UserInput/ResourceInput would have stopped pydantic v1 copying the caller's dict. It is covered by a regression test.

Bug fixes exposed by the stricter checks (each with regression tests that fail on #127):

  • decimal_encoder raised TypeError on Decimal("NaN") and Decimal("Infinity"). It now matches upstream fastapi.
  • A pre-release pydantic version (e.g. 2.14.0b2) crashed import permit. PYDANTIC_VERSION is unchanged for every version that worked before.
  • import permit raised under -W error::DeprecationWarning, because PermitConnectionError subclasses the deprecated PermitException at import time. Importing the SDK is now warning-free. Users who instantiate or subclass PermitException still get the warning.

Deliberately not in this PR: py.typed. With it, users' type checkers would see false errors: the sync client is typed as coroutines, @validate_arguments methods accept dicts their annotations reject, and the generated models use positional Field defaults. PER-16231 fixes those first, then ships the marker.

Architectural changes

No architectural change.

How it was tested

  • ruff check, ruff format --check, typos and mypy report 0 findings under pydantic 1.10.26 and under 2.13.5. uv lock --check passes.
  • The offline suite passes on both lanes with warnings as errors: 153 tests, 22 of them new.
  • The full suite against a local Permit stack and PDP (pydantic 2): 126 passed, 4 skipped. That is the same pass/skip set as Migrate packaging, dependencies and CI to uv #127, plus the new tests.
  • A runtime snapshot of every permit module (fields, aliases, signatures, the 266 @validate_arguments models) and a syntax-tree diff of permit/, compared against Migrate packaging, dependencies and CI to uv #127 on both pydantic majors. The only differences are the equivalent rewrites and fixes listed above.
  • The internal end-to-end harness against the same local stack: 58/58 phases, 95 integrity checks with 0 differences, 0 SDK findings.
  • prek run --all-files is green; actionlint and zizmor are clean on the changed workflow.
  • Reviewed by four specialised agents (behaviour and API preservation, typing design, lint configuration, CI and supply chain). Their findings were fixed or moved to PER-16231.

Manual test plan

  1. With uv 0.12.18: uv sync, then uv run pre-commit run --all-files. Expect all hooks to pass.
  2. uv sync --group pydantic-v1 && uv run --no-sync mypy. Expect no issues.
  3. uv run pytest tests/test_offline_regressions.py. Expect all to pass with warnings as errors.
  4. python -W error::DeprecationWarning -c "import permit" in the synced env. Expect exit 0.

Blast radius and isolation

  • Blast radius: every file is touched by the formatter. The runtime surface is identical apart from the three bug fixes.
  • Contributors: need uv 0.12.18. With an older uv the hooks fail at the [tool.uv] table, and CONTRIBUTING.md says so.
  • Isolation: isolated.

Scope and size

  • permit/: +2,169 / -1,567, mostly formatting, docstrings and annotations. Runtime logic changes are only the equivalent rewrites and the three fixes.
  • Tests and scripts: +1,069 / -545.
  • It's a large diff by line count, but almost all of it is mechanical. Review the pyproject.toml config, permit/exceptions.py, permit/api/encoders.py, permit/utils/pydantic_version.py, and the new tests at the end of tests/test_offline_regressions.py.

🤖 Generated with Claude Code

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
@linear-code

linear-code Bot commented Sep 23, 2026

Copy link
Copy Markdown

PER-16222

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant