Compare commits

...
42 Commits
Author SHA1 Message Date
d3vyce 88ecfa8486 Version 5.1.0 2026-07-28 08:19:33 -04:00
dependabot[bot]andd3vyce b4a7335e5e ⬆ Bump coverage from 7.15.0 to 7.15.2
Bumps [coverage](https://github.com/coveragepy/coveragepy) from 7.15.0 to 7.15.2.
- [Release notes](https://github.com/coveragepy/coveragepy/releases)
- [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst)
- [Commits](https://github.com/coveragepy/coveragepy/compare/7.15.0...7.15.2)

---
updated-dependencies:
- dependency-name: coverage
  dependency-version: 7.15.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 14:16:27 +02:00
dependabot[bot]andd3vyce 97828cc156 ⬆ Bump typer from 0.26.8 to 0.27.0
Bumps [typer](https://github.com/fastapi/typer) from 0.26.8 to 0.27.0.
- [Release notes](https://github.com/fastapi/typer/releases)
- [Changelog](https://github.com/fastapi/typer/blob/master/docs/release-notes.md)
- [Commits](https://github.com/fastapi/typer/compare/0.26.8...0.27.0)

---
updated-dependencies:
- dependency-name: typer
  dependency-version: 0.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 14:16:14 +02:00
dependabot[bot]andd3vyce e7540c6e27 ⬆ Bump zensical from 0.0.50 to 0.0.51
Bumps [zensical](https://github.com/zensical/zensical) from 0.0.50 to 0.0.51.
- [Release notes](https://github.com/zensical/zensical/releases)
- [Commits](https://github.com/zensical/zensical/compare/v0.0.50...v0.0.51)

---
updated-dependencies:
- dependency-name: zensical
  dependency-version: 0.0.51
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 14:16:05 +02:00
d3vyceandd3vyce 7779dcbadd fix: ty warning 2026-07-28 14:15:44 +02:00
dependabot[bot]andd3vyce ea354813d1 ⬆ Bump ty from 0.0.58 to 0.0.64
Bumps [ty](https://github.com/astral-sh/ty) from 0.0.58 to 0.0.64.
- [Release notes](https://github.com/astral-sh/ty/releases)
- [Changelog](https://github.com/astral-sh/ty/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ty/compare/0.0.58...0.0.64)

---
updated-dependencies:
- dependency-name: ty
  dependency-version: 0.0.64
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 14:15:44 +02:00
d3vyceandd3vyce fd83a7142d fix: ruff warnings 2026-07-28 13:55:31 +02:00
dependabot[bot]andd3vyce a407677be0 ⬆ Bump ruff from 0.15.21 to 0.16.0
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.21 to 0.16.0.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.21...0.16.0)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.16.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 13:55:31 +02:00
dependabot[bot]andd3vyce ea5b5695b5 ⬆ Bump pymdown-extensions from 10.21.3 to 11.0
Bumps [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) from 10.21.3 to 11.0.
- [Release notes](https://github.com/facelessuser/pymdown-extensions/releases)
- [Commits](https://github.com/facelessuser/pymdown-extensions/compare/10.21.3...11.0)

---
updated-dependencies:
- dependency-name: pymdown-extensions
  dependency-version: '11.0'
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 13:20:29 +02:00
d3vyceandd3vyce 651326d54b fix: EventSession.commit() wasted and N+1 reloads 2026-07-26 16:17:59 +02:00
d3vyceandd3vyce 169bf710f0 fix: skip unnecessary String cast on already-string search columns 2026-07-26 16:10:38 +02:00
d3vyceandd3vyce adc0ff14c1 fix: skip redundant refresh/reload round trips in create() and update() 2026-07-26 15:50:13 +02:00
d3vyceandd3vyce 19a823dc8b refactor: batch facet queries into a single round trip 2026-07-26 15:49:58 +02:00
d3vyceandd3vyce c15aee22b0 Version 5.0.0 2026-07-11 15:09:41 +02:00
d3vyceandd3vyce 7da08ffd9a docs: add v5 migration guide 2026-07-11 15:09:41 +02:00
d3vyceandd3vyce 51acf61d0f chore: remove security module 2026-07-11 15:09:41 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
436caa3141 ⬆ Bump pytest from 9.0.3 to 9.1.1 (#352)
Bumps [pytest](https://github.com/pytest-dev/pytest) from 9.0.3 to 9.1.1.
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/9.0.3...9.1.1)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.1.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 14:55:46 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a0d51e043d ⬆ Bump typer from 0.26.7 to 0.26.8 (#353)
Bumps [typer](https://github.com/fastapi/typer) from 0.26.7 to 0.26.8.
- [Release notes](https://github.com/fastapi/typer/releases)
- [Changelog](https://github.com/fastapi/typer/blob/master/docs/release-notes.md)
- [Commits](https://github.com/fastapi/typer/compare/0.26.7...0.26.8)

---
updated-dependencies:
- dependency-name: typer
  dependency-version: 0.26.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 14:55:39 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
507f3d99d1 ⬆ Bump prek from 0.4.8 to 0.4.9 (#354)
Bumps [prek](https://github.com/j178/prek) from 0.4.8 to 0.4.9.
- [Release notes](https://github.com/j178/prek/releases)
- [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md)
- [Commits](https://github.com/j178/prek/compare/v0.4.8...v0.4.9)

---
updated-dependencies:
- dependency-name: prek
  dependency-version: 0.4.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 14:55:31 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a49d256f17 ⬆ Bump coverage from 7.14.1 to 7.15.0 (#355)
Bumps [coverage](https://github.com/coveragepy/coveragepy) from 7.14.1 to 7.15.0.
- [Release notes](https://github.com/coveragepy/coveragepy/releases)
- [Changelog](https://github.com/coveragepy/coveragepy/blob/main/CHANGES.rst)
- [Commits](https://github.com/coveragepy/coveragepy/compare/7.14.1...7.15.0)

---
updated-dependencies:
- dependency-name: coverage
  dependency-version: 7.15.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 14:55:24 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
03433f53b1 ⬆ Bump fastapi from 0.136.3 to 0.139.0 (#356)
Bumps [fastapi](https://github.com/fastapi/fastapi) from 0.136.3 to 0.139.0.
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.136.3...0.139.0)

---
updated-dependencies:
- dependency-name: fastapi
  dependency-version: 0.139.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 14:55:17 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3eed5f01d4 ⬆ Bump mkdocstrings-python from 2.0.3 to 2.0.5 (#320)
Bumps [mkdocstrings-python](https://github.com/mkdocstrings/python) from 2.0.3 to 2.0.5.
- [Release notes](https://github.com/mkdocstrings/python/releases)
- [Changelog](https://github.com/mkdocstrings/python/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mkdocstrings/python/compare/2.0.3...2.0.5)

---
updated-dependencies:
- dependency-name: mkdocstrings-python
  dependency-version: 2.0.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 14:50:42 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
61dc4cf9b0 ⬆ Bump ruff from 0.15.15 to 0.15.21 (#349)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.15.15 to 0.15.21.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.15...0.15.21)

---
updated-dependencies:
- dependency-name: ruff
  dependency-version: 0.15.21
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 14:50:30 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9eb7d006db ⬆ Bump zensical from 0.0.43 to 0.0.50 (#351)
Bumps [zensical](https://github.com/zensical/zensical) from 0.0.43 to 0.0.50.
- [Release notes](https://github.com/zensical/zensical/releases)
- [Commits](https://github.com/zensical/zensical/compare/v0.0.43...v0.0.50)

---
updated-dependencies:
- dependency-name: zensical
  dependency-version: 0.0.50
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 14:50:22 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
31f8ad96f7 ⬆ Bump prek from 0.4.3 to 0.4.8 (#350)
Bumps [prek](https://github.com/j178/prek) from 0.4.3 to 0.4.8.
- [Release notes](https://github.com/j178/prek/releases)
- [Changelog](https://github.com/j178/prek/blob/master/CHANGELOG.md)
- [Commits](https://github.com/j178/prek/compare/v0.4.3...v0.4.8)

---
updated-dependencies:
- dependency-name: prek
  dependency-version: 0.4.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 14:50:10 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>d3vyce
953d58e5cc ⬆ Bump ty from 0.0.44 to 0.0.58 (#348)
* ⬆ Bump ty from 0.0.44 to 0.0.58

---
updated-dependencies:
- dependency-name: ty
  dependency-version: 0.0.58
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix ty warnings

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: d3vyce <nicolas.sudres@proton.me>
2026-07-11 14:49:30 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
498e75c624 ⬆ Bump codecov/codecov-action from 6 to 7 (#309)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 6 to 7.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 18:58:25 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
541bfeb1a4 ⬆ Bump actions/checkout from 6 to 7 (#325)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 18:58:12 +02:00
d3vyceandGitHub c16909f585 fix: restore static type hints for FixtureRegistry, load_fixtures, load_fixtures_by_context lost in lazy-import refactor (#347) 2026-07-07 18:42:30 +02:00
d3vyceandGitHub c256e58d64 fix: batch_merge crashes with AnnotatedJoin has no attribute _autoincrement_column on joined-table inheritance (#345) 2026-07-07 18:22:13 +02:00
d3vyceandGitHub 040c023b8a feat: allow default context via FIXTURES_CONTEXT env var for fixtures load (#343) 2026-07-06 18:17:07 +02:00
d3vyce 06d49d5dc4 Version 5.0.0-beta2 2026-07-04 05:01:12 -04:00
d3vyceandGitHub de06839a16 fix: 'Could not refresh instance' when using EventSession (#341) 2026-07-04 01:57:45 +02:00
d3vyceandGitHub ff367e4281 fix: internal rollback() corrupts caller's ambient transaction and discards uncommitted work (#339) 2026-07-04 01:53:10 +02:00
d3vyceandGitHub 9cb4c1474f feat: always include Context.BASE fixtures when loading/listing by context (#337) 2026-07-04 01:08:03 +02:00
d3vyceandGitHub 44ba5bdd4b perf: lazy-import in CLI commands to speed up startup (#335) 2026-07-02 22:40:48 +02:00
d3vyceandGitHub fe2c0f3eff Rework fixtures module (#333)
* feat: fixture refresh DB-generated values onto returned instances

* refactor: replace utils.get_obj_by_attr/get_field_by_attr with registry.obj/field lookups

* refactor: log fixture command output instead of print

* chore: clean up fixture module
2026-07-01 19:24:00 +02:00
d3vyceandGitHub 70e0b3b9d5 chore: Database enhancement (#329) 2026-06-27 13:30:38 +02:00
d3vyceandGitHub 1e021005bc fix: wait_for_row_change raises and never detects changes under REPEATABLE READ (#327) 2026-06-26 18:50:45 +02:00
d3vyce 025f1907fd Version 5.0.0-beta1 2026-06-25 15:15:49 -04:00
d3vyceandGitHub 9698a0743b chore: rework DB module (#324) 2026-06-25 21:11:50 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
22f307d0fc ⬆ Bump starlette from 1.0.1 to 1.3.1 (#315)
Bumps [starlette](https://github.com/Kludex/starlette) from 1.0.1 to 1.3.1.
- [Release notes](https://github.com/Kludex/starlette/releases)
- [Changelog](https://github.com/Kludex/starlette/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/starlette/compare/1.0.1...1.3.1)

---
updated-dependencies:
- dependency-name: starlette
  dependency-version: 1.3.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-20 15:27:11 +02:00
75 changed files with 3452 additions and 4179 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
permissions:
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v7
+5 -5
View File
@@ -18,7 +18,7 @@ jobs:
name: Lint (Ruff)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v7
@@ -39,7 +39,7 @@ jobs:
name: Type Check (ty)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v7
@@ -77,7 +77,7 @@ jobs:
--health-retries 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v7
@@ -96,7 +96,7 @@ jobs:
- name: Upload coverage to Codecov
if: matrix.python-version == '3.14'
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: coverage
@@ -105,7 +105,7 @@ jobs:
- name: Upload test results to Codecov
if: matrix.python-version == '3.14'
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
report_type: test_results
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
-2
View File
@@ -31,7 +31,6 @@ Install only the extras you need:
```bash
uv add "fastapi-toolsets[cli]"
uv add "fastapi-toolsets[metrics]"
uv add "fastapi-toolsets[security]"
uv add "fastapi-toolsets[pytest]"
```
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
### Optional
- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers
- **CLI**: Django-like command-line interface with fixture management and custom commands support
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
-2
View File
@@ -31,7 +31,6 @@ Install only the extras you need:
```bash
uv add "fastapi-toolsets[cli]"
uv add "fastapi-toolsets[metrics]"
uv add "fastapi-toolsets[security]"
uv add "fastapi-toolsets[pytest]"
```
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
### Optional
- **Security**: Composable authentication sources (`BearerTokenAuth`, `CookieAuth`, `APIKeyHeaderAuth`, `MultiAuth`) with HMAC-signed cookies and OAuth 2.0 / OIDC helpers
- **CLI**: Django-like command-line interface with fixture management and custom commands support
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
+10 -3
View File
@@ -85,13 +85,17 @@ The `paginate` shorthand was an alias for `offset_paginate`. It has been removed
=== "Before (`v1`)"
```python
result = await UserCrud.paginate(session=session, page=2, items_per_page=20, schema=UserRead)
result = await UserCrud.paginate(
session=session, page=2, items_per_page=20, schema=UserRead
)
```
=== "Now (`v2`)"
```python
result = await UserCrud.offset_paginate(session=session, page=2, items_per_page=20, schema=UserRead)
result = await UserCrud.offset_paginate(
session=session, page=2, items_per_page=20, schema=UserRead
)
```
---
@@ -124,8 +128,11 @@ For shared base classes that are not meant to be raised directly, use `abstract=
class BillingError(ApiException, abstract=True):
"""Base for all billing-related errors — not raised directly."""
class PaymentRequiredError(BillingError):
api_error = ApiError(code=402, msg="Payment Required", desc="...", err_code="BILLING-402")
api_error = ApiError(
code=402, msg="Payment Required", desc="...", err_code="BILLING-402"
)
```
---
+13 -2
View File
@@ -45,12 +45,16 @@ Each new method accepts `search`, `filter`, and `order` boolean toggles (all `Tr
```python
from fastapi_toolsets.crud import OrderByClause
@router.get("/offset")
async def list_articles_offset(
session: SessionDep,
params: Annotated[dict, Depends(ArticleCrud.offset_params(default_page_size=20))],
filter_by: Annotated[dict, Depends(ArticleCrud.filter_params())],
order_by: Annotated[OrderByClause | None, Depends(ArticleCrud.order_params(default_field=Article.created_at))],
order_by: Annotated[
OrderByClause | None,
Depends(ArticleCrud.order_params(default_field=Article.created_at)),
],
search: str | None = None,
) -> OffsetPaginatedResponse[ArticleRead]:
return await ArticleCrud.offset_paginate(
@@ -79,7 +83,9 @@ Each new method accepts `search`, `filter`, and `order` boolean toggles (all `Tr
),
],
) -> OffsetPaginatedResponse[ArticleRead]:
return await ArticleCrud.offset_paginate(session=session, **params, schema=ArticleRead)
return await ArticleCrud.offset_paginate(
session=session, **params, schema=ArticleRead
)
```
The same pattern applies to `cursor_paginate_params()` and `paginate_params()`. To disable a feature, pass the toggle:
@@ -109,6 +115,7 @@ Model method callbacks (`on_create`, `on_delete`, `on_update`) and the `@watch`
```python
from fastapi_toolsets.models import WatchedFieldsMixin, watch
@watch("status")
class Order(Base, UUIDMixin, WatchedFieldsMixin):
__tablename__ = "orders"
@@ -131,21 +138,25 @@ Model method callbacks (`on_create`, `on_delete`, `on_update`) and the `@watch`
```python
from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for
class Order(Base, UUIDMixin):
__tablename__ = "orders"
__watched_fields__ = ("status",)
status: Mapped[str]
@listens_for(Order, [ModelEvent.CREATE])
async def on_order_created(order: Order, event_type: ModelEvent, changes: None):
await notify_new_order(order.id)
@listens_for(Order, [ModelEvent.UPDATE])
async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict):
if "status" in changes:
await notify_status_change(order.id, changes["status"])
@listens_for(Order, [ModelEvent.DELETE])
async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None):
await notify_order_cancelled(order.id)
+4 -2
View File
@@ -21,7 +21,7 @@ The function creates and manages its own **dedicated session** internally, yield
user.balance += 100
# With a custom lock mode
async with lock_tables(session, [Order], mode=LockMode.EXCLUSIVE):
async with lock_tables(session=session, tables=[Order], mode=LockMode.EXCLUSIVE):
await process_order(session, order_id)
```
@@ -35,6 +35,8 @@ The function creates and manages its own **dedicated session** internally, yield
user.balance += 100
# With a custom lock mode
async with lock_tables(session_maker, [Order], mode=LockMode.EXCLUSIVE) as session:
async with lock_tables(
session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE
) as session:
await process_order(session, order_id)
```
+166
View File
@@ -0,0 +1,166 @@
# Migrating to v5.0
This page covers every breaking change introduced in **v5.0** and the steps required to update your code.
---
## Database
`db.py` is now the `db/` package, built around one object, [`Database`](../reference/db.md#fastapi_toolsets.db.Database), that owns the engine and sessionmaker. The free functions that took a `session_maker` you built and passed around yourself are gone from request-handling code; `Database` builds the sessionmaker for you.
### `create_db_dependency` / `create_db_context` removed in favor of `Database`
Build one `Database` with your URL (or an existing `engine=`), then use the instance directly as the FastAPI dependency, and `db.session()` for sessions outside request handlers.
=== "Before (`v4`)"
```python
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from fastapi_toolsets.db import create_db_dependency, create_db_context
engine = create_async_engine("postgresql+asyncpg://...")
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
get_db = create_db_dependency(session_maker=SessionLocal)
get_db_context = create_db_context(session_maker=SessionLocal)
@app.get("/users")
async def list_users(session: AsyncSession = Depends(get_db)): ...
async def seed():
async with get_db_context() as session:
...
```
=== "Now (`v5`)"
```python
from fastapi_toolsets.db import Database
db = Database(url="postgresql+asyncpg://...")
@app.get("/users")
async def list_users(session: AsyncSession = Depends(db)): ...
async def seed():
async with db.session() as session:
...
```
Call `db.install(app)` to also commit before the response is sent (instead of in dependency teardown) and to dispose the engine on shutdown. See [the db module docs](../module/db.md#committing-before-the-response).
### `get_transaction` renamed to `transaction`
Same behavior (savepoint when already in a transaction, new transaction otherwise), new name, same import path.
=== "Before (`v4`)"
```python
from fastapi_toolsets.db import get_transaction
async with get_transaction(session=session):
session.add(model)
```
=== "Now (`v5`)"
```python
from fastapi_toolsets.db import transaction
async with transaction(session=session):
session.add(model)
```
If you have a `Database` instance, `db.begin()` opens a session already inside a transaction:
```python
async with db.begin() as session:
session.add(User(name="ada"))
```
### `lock_tables` is now also a `Database` method
The free `lock_tables(session_maker, tables, ...)` function still exists for callers who manage their own session factory, but prefer `db.lock_tables(tables, ...)`, which drops the `session_maker` argument:
=== "Before (`v4`)"
```python
from fastapi_toolsets.db import lock_tables, LockMode
async with lock_tables(
session_maker=session_maker, tables=[Order], mode=LockMode.EXCLUSIVE
) as session:
await process_order(session, order_id)
```
=== "Now (`v5`)"
```python
from fastapi_toolsets.db import LockMode
async with db.lock_tables(tables=[Order], mode=LockMode.EXCLUSIVE) as session:
await process_order(session, order_id)
```
### `create_database` and `cleanup_tables` moved to `fastapi_toolsets.db.testing`
=== "Before (`v4`)"
```python
from fastapi_toolsets.db import create_database, cleanup_tables
```
=== "Now (`v5`)"
```python
from fastapi_toolsets.db.testing import create_database, cleanup_tables
```
## Fixtures
### `get_obj_by_attr` / `get_field_by_attr` are now `FixtureRegistry` methods
Both also change their first argument: instead of the fixture *function*, pass the fixture's *registered name* and let the registry look it up.
=== "Before (`v4`)"
```python
from fastapi_toolsets.fixtures import get_obj_by_attr, get_field_by_attr
@fixtures.register(depends_on=["roles"])
def users():
admin_role = get_obj_by_attr(fixtures=roles, attr_name="name", value="admin")
admin_role_id = get_field_by_attr(fixtures=roles, attr_name="name", value="admin")
return [User(id=1, username="alice", role_id=admin_role.id)]
```
=== "Now (`v5`)"
```python
@fixtures.register(depends_on=["roles"])
def users():
admin_role = fixtures.obj(name="roles", attr_name="name", value="admin")
admin_role_id = fixtures.field(name="roles", attr_name="name", value="admin")
return [User(id=1, username="alice", role_id=admin_role.id)]
```
### Loading/listing by context now always includes `Context.BASE`
`FixtureRegistry.get_variants`/`get_by_context`, `load_fixtures_by_context`, and the `fixtures list`/`fixtures load` CLI commands now implicitly union every queried context with `Context.BASE`. In `v4`, requesting a single context (e.g. `"testing"`) returned only fixtures tagged with that context; in `v5` it also returns every `Context.BASE`-tagged fixture.
If you relied on strict exclusion of base fixtures, move them out of `Context.BASE` into their own context.
### `load_fixtures` / `load_fixtures_by_context` now return reloaded instances
Loaded objects are re-queried from the database after insert, with all relationships eager-loaded, instead of returning the exact objects your fixture function constructed. Code that compares returned objects by identity to the fixture function's output, or that expected relationships to remain unloaded, should be updated — and expect one extra query per model type per load.
## Security
The security module has been removed and moved to a dedicated python package: [`fastapi-multiauth`](https://github.com/d3vyce/fastapi-multiauth).
Run `uv add fastapi-multiauth` and replace `from fastapi_toolsets.security import ...` with `from fastapi_multiauth import ...`.
+17 -2
View File
@@ -24,7 +24,7 @@ Configure the CLI in your `pyproject.toml`:
```toml
[tool.fastapi-toolsets]
cli = "myapp.cli:cli" # Custom Typer app
custom_cli = "myapp.cli:cli" # Custom Typer app
fixtures = "myapp.fixtures:registry" # FixtureRegistry instance
db_context = "myapp.db:db_context" # Async context manager for sessions
```
@@ -68,6 +68,20 @@ manager fixtures --help
╰──────────────────────────────────────────────────────────────────────────────────╯
```
### `fixtures load`
```bash
manager fixtures load [CONTEXTS]... [--strategy merge|insert|skip_existing] [--dry-run]
```
`CONTEXTS` defaults to `Context.BASE` when omitted, and can also be set via the `FIXTURES_CONTEXT` environment variable, handy for CI/deploy scripts that shouldn't need an explicit argument per environment:
```bash
FIXTURES_CONTEXT=testing manager fixtures load
```
An explicit CLI argument always takes precedence over the environment variable.
## Custom CLI
You can extend the CLI by providing your own Typer app. The `manager` entry point will merge your app's commands with the built-in ones:
@@ -78,6 +92,7 @@ import typer
cli = typer.Typer()
@cli.command()
def hello():
print("Hello from my app!")
@@ -85,7 +100,7 @@ def hello():
```toml
[tool.fastapi-toolsets]
cli = "myapp.cli:cli"
custom_cli = "myapp.cli:cli"
```
---
+37 -7
View File
@@ -30,6 +30,7 @@ UserCrud = CrudFactory(model=User)
from fastapi_toolsets.crud.factory import AsyncCrud
from myapp.models import User
class UserCrud(AsyncCrud[User]):
model = User
searchable_fields = [User.username, User.email]
@@ -61,6 +62,7 @@ from fastapi_toolsets.crud.factory import AsyncCrud
T = TypeVar("T", bound=DeclarativeBase)
class AuditedCrud(AsyncCrud[T], Generic[T]):
"""Base CRUD with custom function"""
@@ -101,7 +103,9 @@ user = await UserCrud.first(session=session, filters=[User.email == email])
users = await UserCrud.get_multi(session=session, filters=[User.is_active == True])
# Update
user = await UserCrud.update(session=session, obj=UserUpdateSchema(username="bob"), filters=[User.id == user_id])
user = await UserCrud.update(
session=session, obj=UserUpdateSchema(username="bob"), filters=[User.id == user_id]
)
# Delete
await UserCrud.delete(session=session, filters=[User.id == user_id])
@@ -160,14 +164,18 @@ user = await UserCrud.get(session, [User.id == user_id], with_for_update=True)
user = await UserCrud.get(session, [User.id == user_id], with_for_update="nowait")
# Skip rows already locked by another transaction (e.g. job queues)
rows = await JobCrud.get_multi(session, filters=[Job.status == "pending"], with_for_update="skip_locked")
rows = await JobCrud.get_multi(
session, filters=[Job.status == "pending"], with_for_update="skip_locked"
)
# Lock atomically as part of update (prevents race between SELECT and UPDATE)
user = await UserCrud.update(session, UserUpdate(credits=10), [User.id == user_id], with_for_update=True)
user = await UserCrud.update(
session, UserUpdate(credits=10), [User.id == user_id], with_for_update=True
)
```
!!! warning
`with_for_update` requires an open transaction. Wrap your call in `async with session.begin()` or use the `get_transaction` helper if you are not already inside one.
`with_for_update` requires an open transaction. Wrap your call in `async with session.begin()` or use the `transaction` helper if you are not already inside one.
!!! note
`NOWAIT` raises `sqlalchemy.exc.OperationalError` immediately if the row is locked rather than waiting.
@@ -193,6 +201,7 @@ Three pagination methods are available. All return a typed response whose `pagi
from typing import Annotated
from fastapi import Depends
@router.get("")
async def get_users(
session: SessionDep,
@@ -228,7 +237,9 @@ By default `offset_paginate` runs two queries: one for the page items and one `C
@router.get("")
async def get_users(
session: SessionDep,
params: Annotated[dict, Depends(UserCrud.offset_paginate_params(include_total=False))],
params: Annotated[
dict, Depends(UserCrud.offset_paginate_params(include_total=False))
],
) -> OffsetPaginatedResponse[UserRead]:
return await UserCrud.offset_paginate(session=session, **params, schema=UserRead)
```
@@ -303,6 +314,7 @@ PostCrud = CrudFactory(model=Post, cursor_column=Post.created_at)
```python
from fastapi_toolsets.schemas import PaginatedResponse
@router.get("")
async def list_users(
session: SessionDep,
@@ -446,6 +458,7 @@ from typing import Annotated
from fastapi import Depends
@router.get("", response_model_exclude_none=True)
async def list_users(
session: SessionDep,
@@ -491,6 +504,16 @@ The distinct values for each facet field are returned in the `filter_attributes`
!!! info "Key format uses `__` as a separator for relationship chains."
A direct column `User.status` produces `"status"`. A relationship tuple `(User.role, Role.name)` produces `"role__name"`. A deeper chain `(User.role, Role.permission, Permission.name)` produces `"role__permission__name"`. An unknown `filter_by` key raises [`InvalidFacetFilterError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.InvalidFacetFilterError) (HTTP 422).
#### Skipping facet queries
!!! info "Added in `v5.1.0`"
Facet values only change with the filters, not with the page. Pass `include_facets=False` to `offset_paginate_params()` / `cursor_paginate_params()` on pages 2..N to skip the facet queries entirely (`filter_attributes` will be `None`):
```python
params: Annotated[dict, Depends(UserCrud.offset_paginate_params(include_facets=False))]
```
## Sorting
!!! info "Added in `v1.3`"
@@ -530,6 +553,7 @@ from typing import Annotated
from fastapi import Depends
@router.get("")
async def list_users(
session: SessionDep,
@@ -622,7 +646,9 @@ PostCrud = CrudFactory(
m2m_fields={"tag_ids": Post.tags},
)
post = await PostCrud.create(session=session, obj=PostCreateSchema(title="Hello", tag_ids=[1, 2, 3]))
post = await PostCrud.create(
session=session, obj=PostCreateSchema(title="Hello", tag_ids=[1, 2, 3])
)
```
## Upsert
@@ -649,6 +675,7 @@ class UserRead(PydanticBase):
id: UUID
username: str
@router.get(
"/{uuid}",
responses=generate_error_responses(NotFoundError),
@@ -660,12 +687,15 @@ async def get_user(session: SessionDep, uuid: UUID) -> Response[UserRead]:
schema=UserRead,
)
@router.get("")
async def list_users(
session: SessionDep,
params: Annotated[dict, Depends(crud.UserCrud.offset_paginate_params())],
) -> OffsetPaginatedResponse[UserRead]:
return await crud.UserCrud.offset_paginate(session=session, **params, schema=UserRead)
return await crud.UserCrud.offset_paginate(
session=session, **params, schema=UserRead
)
```
The schema must have `from_attributes=True` (or inherit from [`PydanticBase`](../reference/schemas.md#fastapi_toolsets.schemas.PydanticBase)) so it can be built from SQLAlchemy model instances.
+121 -59
View File
@@ -7,96 +7,161 @@ SQLAlchemy async session management with transactions, table locking, advisory l
## Overview
The `db` module provides helpers to create FastAPI dependencies and context managers for `AsyncSession`, along with utilities for nested transactions, table locks, advisory locks, and polling for row changes.
The `db` module is built around one object, [`Database`](../reference/db.md#fastapi_toolsets.db.Database), which owns the engine and sessionmaker and exposes the FastAPI dependency, a commit-before-response middleware, session/transaction context managers, and table locking. Free helpers cover savepoint-aware transactions, advisory locks, many-to-many association tables, and row-change polling.
## Session dependency
## Setup
Use [`create_db_dependency`](../reference/db.md#fastapi_toolsets.db.create_db_dependency) to create a FastAPI dependency that yields a session and auto-commits on success:
Create one `Database` for your app. Provide a **URL** (the facade builds and disposes the engine) or pass an existing **`engine=`** you own (e.g. for Alembic or `event.listen`). The session factory is built internally with `expire_on_commit=False`.
```python
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from fastapi_toolsets.db import create_db_dependency
from fastapi import Depends, FastAPI
from sqlalchemy.ext.asyncio import AsyncSession
engine = create_async_engine(url="postgresql+asyncpg://...", future=True)
session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
from fastapi_toolsets.db import Database
get_db = create_db_dependency(session_maker=session_maker)
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
@router.get("/users")
async def list_users(session: AsyncSession = Depends(get_db)):
...
app = FastAPI()
db.install(app) # commit middleware + engine disposal on shutdown
@app.get("/users")
async def list_users(session: AsyncSession = Depends(db)): ...
```
The `Database` instance **is** the dependency: use it directly as `Depends(db)`. The whole request runs as a single transaction (CRUD writes use savepoints under it).
The **URL** may be a plain string or a Pydantic [`PostgresDsn`](https://docs.pydantic.dev/latest/api/networks/#pydantic.networks.PostgresDsn). In URL mode you can tune the engine: pass `connect_args` for DBAPI-level options and any other keyword for `create_async_engine` (e.g. `pool_size`, `echo`, `pool_pre_ping`):
```python
from pydantic_settings import BaseSettings
from pydantic import PostgresDsn
class Settings(BaseSettings):
database_url: PostgresDsn
settings = Settings()
db = Database(
settings.database_url,
pool_size=20,
pool_pre_ping=True,
connect_args={"server_settings": {"application_name": "myapp"}},
)
```
## Committing before the response
[`db.install(app)`](../reference/db.md#fastapi_toolsets.db.Database) adds a middleware that commits the request's session when the response starts, after the endpoint returns and before the body is sent. With the middleware installed, the dependency does not commit again.
The request is committed as a single transaction:
- **Read-after-write**: a follow-up request sees the write.
- **Atomicity**: multi-write endpoints roll back as a unit on failure.
- **Errors roll back**: on a raised exception the session rolls back and nothing is committed.
Without `install`, the session commits in the dependency teardown, which runs after the response has been sent.
!!! warning "Streaming / SSE endpoints"
For a `StreamingResponse` / `EventSourceResponse`, the commit fires at the **start** of the stream. A stream that **writes** must open a short-lived session per write with [`db.session()`](#session-context-manager); the start-time commit will not flush writes made later during the stream.
## Lifespan
`db.install(app)` disposes the engine on shutdown, composing around your own lifespan:
```python
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app):
await warm_cache() # your startup
yield
await flush_metrics() # your shutdown
app = FastAPI(lifespan=lifespan)
db.install(app) # your shutdown runs first, then the engine is disposed
```
If you have no lifespan of your own, [`db.lifespan`](../reference/db.md#fastapi_toolsets.db.Database) works standalone as `FastAPI(lifespan=db.lifespan)`. Engine disposal is idempotent and is a no-op when you passed your own `engine=`.
## Session context manager
Use [`create_db_context`](../reference/db.md#fastapi_toolsets.db.create_db_context) for sessions outside request handlers (e.g. background tasks, CLI commands):
Use [`db.session()`](../reference/db.md#fastapi_toolsets.db.Database) for sessions outside request handlers (e.g. background tasks, CLI commands). It commits on clean exit and rolls back on exception:
```python
from fastapi_toolsets.db import create_db_context
db_context = create_db_context(session_maker=session_maker)
async def seed():
async with db_context() as session:
async with db.session() as session:
...
```
## Nested transactions
## Transactions
[`get_transaction`](../reference/db.md#fastapi_toolsets.db.get_transaction) handles savepoints automatically, allowing safe nesting:
[`transaction`](../reference/db.md#fastapi_toolsets.db.transaction) opens a transaction on a session, using a savepoint when one is already open so it nests safely:
```python
from fastapi_toolsets.db import get_transaction
from fastapi_toolsets.db import transaction
async def create_user_with_role(session=session):
async with get_transaction(session=session):
async def create_user_with_role(session):
async with transaction(session):
...
async with get_transaction(session=session): # uses savepoint
async with transaction(session): # uses a savepoint
...
```
When you have a `Database`, [`db.begin()`](../reference/db.md#fastapi_toolsets.db.Database) opens a session already inside a transaction:
```python
async with db.begin() as session:
session.add(User(name="ada")) # commits on exit, rolls back on exception
```
## Table locking
[`lock_tables`](../reference/db.md#fastapi_toolsets.db.lock_tables) acquires PostgreSQL table-level locks before executing critical sections. It opens a **dedicated session** internally and yields it to the caller, so the lock is guaranteed to be released when the context exits:
[`db.lock_tables`](../reference/db.md#fastapi_toolsets.db.Database) acquires PostgreSQL table-level locks for a critical section. It opens a dedicated session internally and releases the lock when the context exits:
```python
from fastapi_toolsets.db import lock_tables, LockMode
from fastapi_toolsets.db import LockMode
async with lock_tables(session_maker=session_maker, tables=[User], mode=LockMode.EXCLUSIVE) as session:
async with db.lock_tables([User], mode=LockMode.EXCLUSIVE) as session:
# No other transaction can modify User until this block exits
...
```
Available lock modes are defined in [`LockMode`](../reference/db.md#fastapi_toolsets.db.LockMode): `ACCESS_SHARE`, `ROW_SHARE`, `ROW_EXCLUSIVE`, `SHARE_UPDATE_EXCLUSIVE`, `SHARE`, `SHARE_ROW_EXCLUSIVE`, `EXCLUSIVE`, `ACCESS_EXCLUSIVE`.
Pass `timeout` to limit how long the lock waits before giving up. On timeout, a [`LockTimeoutError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.LockTimeoutError) is raised instead of a raw database error:
Pass `timeout` to limit how long the lock waits. On timeout, a [`LockTimeoutError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.LockTimeoutError) is raised instead of a raw database error:
```python
async with lock_tables(session_maker, [Order], timeout="2s") as session:
async with db.lock_tables([Order], timeout="2s") as session:
...
```
## Advisory locking
[`advisory_lock`](../reference/db.md#fastapi_toolsets.db.advisory_lock) acquires a PostgreSQL session-level advisory lock. The lock is released explicitly when the context exits, regardless of whether the transaction has committed.
[`advisory_lock`](../reference/db.md#fastapi_toolsets.db.advisory_lock) acquires a PostgreSQL session-level advisory lock on a session you provide. The lock is released when the context exits:
```python
from fastapi_toolsets.db import advisory_lock
# Blocking exclusive lock waits until the lock is free
# Blocking exclusive lock: waits until the lock is free
async with advisory_lock(session=session, key=42):
...
# Non-blocking yields False immediately if already held
# Non-blocking: yields False immediately if already held
async with advisory_lock(session=session, key=42, nowait=True) as acquired:
if not acquired:
raise HTTPException(409, "Resource is locked")
# Blocking with a timeout raises LockTimeoutError if not acquired in time
# Blocking with a timeout: raises LockTimeoutError if not acquired in time
async with advisory_lock(session=session, key=42, timeout="5s"):
...
# Shared multiple readers allowed simultaneously, blocks exclusive writers
# Shared lock: multiple readers allowed simultaneously, blocks exclusive writers
async with advisory_lock(session=session, key=42, shared=True):
...
@@ -106,11 +171,11 @@ async with advisory_lock(session=session, key=(1, user_id)):
```
!!! note
Advisory locks use PostgreSQL session-level functions (`pg_advisory_lock` / `pg_advisory_unlock`). The lock is tied to the database connection, not the SQLAlchemy transaction it is released when the context exits, even if the surrounding transaction is still open.
Advisory locks use PostgreSQL session-level functions (`pg_advisory_lock` / `pg_advisory_unlock`). The lock is tied to the database connection, not the SQLAlchemy transaction, so it is released when the context exits even if the surrounding transaction is still open.
## Row-change polling
[`wait_for_row_change`](../reference/db.md#fastapi_toolsets.db.wait_for_row_change) polls a row until a specific column changes value, useful for waiting on async side effects:
[`wait_for_row_change`](../reference/db.md#fastapi_toolsets.db.wait_for_row_change) polls a row until a specific column changes value:
```python
from fastapi_toolsets.db import wait_for_row_change
@@ -120,7 +185,7 @@ await wait_for_row_change(
session=session,
model=Order,
pk_value=order_id,
columns=[Order.status],
columns=["status"],
interval=1.0,
timeout=30.0,
)
@@ -128,28 +193,25 @@ await wait_for_row_change(
## Creating a database
!!! info "Added in `v2.1`"
[`create_database`](../reference/db.md#fastapi_toolsets.db.create_database) creates a database at a given URL. It connects to *server_url* and issues a `CREATE DATABASE` statement:
[`create_database`](../reference/db.md#fastapi_toolsets.db.testing.create_database) (in `fastapi_toolsets.db.testing`) connects to *server_url* and issues a `CREATE DATABASE` statement:
```python
from fastapi_toolsets.db import create_database
from fastapi_toolsets.db.testing import create_database
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
await create_database(db_name="myapp_test", server_url=SERVER_URL)
```
For test isolation with automatic cleanup, use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) from the `pytest` module instead — it handles drop-before, create, and drop-after automatically.
For test isolation with automatic cleanup, use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) from the `pytest` module, which handles drop-before, create, and drop-after.
## Cleaning up tables
!!! info "Added in `v2.1`"
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables) truncates all tables:
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.testing.cleanup_tables) (in `fastapi_toolsets.db.testing`) truncates all tables:
```python
from fastapi_toolsets.db import cleanup_tables
from fastapi_toolsets.db.testing import cleanup_tables
@pytest.fixture(autouse=True)
async def clean(db_session):
@@ -159,50 +221,50 @@ async def clean(db_session):
## Many-to-Many helpers
SQLAlchemy's ORM collection API triggers lazy-loads when you append to a relationship inside a savepoint (e.g. inside `lock_tables` or a nested `get_transaction`). The three `m2m_*` helpers bypass the ORM collection entirely and issue direct SQL against the association table.
The three `m2m_*` helpers modify a many-to-many association table with direct SQL, without loading the ORM collection.
### `m2m_add` insert associations
### `m2m_add`: insert associations
[`m2m_add`](../reference/db.md#fastapi_toolsets.db.m2m_add) inserts one or more rows into a secondary table without touching the ORM collection:
[`m2m_add`](../reference/db.md#fastapi_toolsets.db.m2m_add) inserts one or more rows into a secondary table:
```python
from fastapi_toolsets.db import lock_tables, m2m_add
from fastapi_toolsets.db import m2m_add
async with lock_tables(session_maker, [Tag]) as session:
async with db.lock_tables([Tag]) as session:
tag = await TagCrud.create(session, TagCreate(name="python"))
await m2m_add(session, post, Post.tags, tag)
```
Pass `ignore_conflicts=True` to silently skip associations that already exist:
Pass `ignore_conflicts=True` to skip associations that already exist:
```python
await m2m_add(session, post, Post.tags, tag, ignore_conflicts=True)
```
### `m2m_remove` delete associations
### `m2m_remove`: delete associations
[`m2m_remove`](../reference/db.md#fastapi_toolsets.db.m2m_remove) deletes specific association rows. Removing a non-existent association is a no-op:
```python
from fastapi_toolsets.db import get_transaction, m2m_remove
from fastapi_toolsets.db import m2m_remove, transaction
async with get_transaction(session):
async with transaction(session):
await m2m_remove(session, post, Post.tags, tag1, tag2)
```
### `m2m_set` replace the full set
### `m2m_set`: replace the full set
[`m2m_set`](../reference/db.md#fastapi_toolsets.db.m2m_set) atomically replaces all associations: it deletes every existing row for the owner instance then inserts the new set. Passing no related instances clears the association entirely:
[`m2m_set`](../reference/db.md#fastapi_toolsets.db.m2m_set) replaces all associations: it deletes every existing row for the owner instance then inserts the new set. Passing no related instances clears the association:
```python
from fastapi_toolsets.db import get_transaction, m2m_set
from fastapi_toolsets.db import m2m_set, transaction
# Replace all tags
async with get_transaction(session):
async with transaction(session):
await m2m_set(session, post, Post.tags, tag_a, tag_b)
# Clear all tags
async with get_transaction(session):
async with transaction(session):
await m2m_set(session, post, Post.tags)
```
+8 -2
View File
@@ -20,6 +20,7 @@ UserDep = PathDependency(model=User, field=User.id, session_dep=get_db)
SessionDep = Annotated[AsyncSession, Depends(get_db)]
UserDep = PathDependency(model=User, field=User.id, session_dep=SessionDep)
@router.get("/users/{user_id}")
async def get_user(user: User = UserDep):
return user
@@ -30,6 +31,7 @@ By default the parameter name is inferred from the field (`user_id` for `User.id
```python
UserDep = PathDependency(model=User, field=User.id, session_dep=get_db, param_name="id")
@router.get("/users/{id}")
async def get_user(user: User = UserDep):
return user
@@ -43,11 +45,15 @@ async def get_user(user: User = UserDep):
from fastapi_toolsets.dependencies import BodyDependency
# Plain callable
RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=get_db, body_field="role_id")
RoleDep = BodyDependency(
model=Role, field=Role.id, session_dep=get_db, body_field="role_id"
)
# Annotated
SessionDep = Annotated[AsyncSession, Depends(get_db)]
RoleDep = BodyDependency(model=Role, field=Role.id, session_dep=SessionDep, body_field="role_id")
RoleDep = BodyDependency(
model=Role, field=Role.id, session_dep=SessionDep, body_field="role_id"
)
@router.post("/users")
+12 -3
View File
@@ -53,7 +53,9 @@ All built-in exceptions accept optional keyword arguments to customise the respo
| `data` | Overrides the `data` field |
```python
raise NotFoundError(detail="User 42 not found", desc="No user with that ID exists in the database.")
raise NotFoundError(
detail="User 42 not found", desc="No user with that ID exists in the database."
)
```
## Custom exceptions
@@ -64,6 +66,7 @@ Subclass [`ApiException`](../reference/exceptions.md#fastapi_toolsets.exceptions
from fastapi_toolsets.exceptions import ApiException
from fastapi_toolsets.schemas import ApiError
class PaymentRequiredError(ApiException):
api_error = ApiError(
code=402,
@@ -105,11 +108,17 @@ Use `abstract=True` when creating a shared base that is not meant to be raised d
class BillingError(ApiException, abstract=True):
"""Base for all billing-related errors."""
class PaymentRequiredError(BillingError):
api_error = ApiError(code=402, msg="Payment Required", desc="...", err_code="BILLING-402")
api_error = ApiError(
code=402, msg="Payment Required", desc="...", err_code="BILLING-402"
)
class SubscriptionExpiredError(BillingError):
api_error = ApiError(code=402, msg="Subscription Expired", desc="...", err_code="BILLING-402-EXP")
api_error = ApiError(
code=402, msg="Subscription Expired", desc="...", err_code="BILLING-402-EXP"
)
```
## OpenAPI response documentation
+40 -11
View File
@@ -13,6 +13,7 @@ from fastapi_toolsets.fixtures import FixtureRegistry, Context
fixtures = FixtureRegistry()
@fixtures.register
def roles():
return [
@@ -20,6 +21,7 @@ def roles():
Role(id=2, name="user"),
]
@fixtures.register(depends_on=["roles"], contexts=[Context.TESTING])
def test_users():
return [
@@ -65,6 +67,13 @@ Both functions return a `dict[str, list[...]]` mapping each fixture name to the
A fixture with no `contexts` defined takes `Context.BASE` by default.
`Context.BASE` fixtures are always included alongside whatever context you load or list — there's no way to load a non-base context in isolation:
```python
# also loads any Context.BASE fixtures, even though only TESTING is requested
await load_fixtures_by_context(session, fixtures, Context.TESTING)
```
### Custom contexts
Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is expected.
@@ -72,14 +81,18 @@ Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is
```python
from enum import Enum
class AppContext(str, Enum):
STAGING = "staging"
DEMO = "demo"
@fixtures.register(contexts=[AppContext.STAGING])
def staging_data():
return [Config(key="feature_x", enabled=True)]
# loads staging_data plus any Context.BASE fixtures
await load_fixtures_by_context(session, fixtures, AppContext.STAGING)
```
@@ -90,7 +103,8 @@ Pass `contexts` to `FixtureRegistry` to set a default for all fixtures registere
```python
testing_registry = FixtureRegistry(contexts=[Context.TESTING])
@testing_registry.register # implicitly contexts=[Context.TESTING]
@testing_registry.register # implicitly contexts=[Context.TESTING]
def test_orders():
return [Order(id=1, total=99)]
```
@@ -104,12 +118,14 @@ The same fixture name may be registered under different (non-overlapping) contex
def users():
return [User(id=1, username="admin")]
@fixtures.register(contexts=[Context.TESTING])
def users():
return [User(id=2, username="tester")]
# loads both admin and tester
await load_fixtures_by_context(session, fixtures, Context.BASE, Context.TESTING)
# loads both admin and tester (Context.BASE is included automatically)
await load_fixtures_by_context(session, fixtures, Context.TESTING)
```
Registering two variants with overlapping context sets raises `ValueError`.
@@ -147,18 +163,28 @@ Fixtures with the same name are allowed as long as their context sets do not ove
## Looking up fixture instances
[`get_obj_by_attr`](../reference/fixtures.md#fastapi_toolsets.fixtures.utils.get_obj_by_attr) retrieves a specific instance from a fixture function by attribute value — useful when building cross-fixture `depends_on` relationships:
[`FixtureRegistry.obj`](../reference/fixtures.md#fastapi_toolsets.fixtures.registry.FixtureRegistry.obj) retrieves a specific instance from a registered fixture by attribute value, looked up by name on the registry — useful when building cross-fixture `depends_on` relationships:
```python
from fastapi_toolsets.fixtures import get_obj_by_attr
@fixtures.register(depends_on=["roles"])
def users():
admin_role = get_obj_by_attr(roles, "name", "admin")
admin_role = fixtures.obj("roles", "name", "admin")
return [User(id=1, username="alice", role_id=admin_role.id)]
```
Raises `StopIteration` if no matching instance is found.
Looking the fixture up by name (instead of importing the `roles` function directly) means fixture modules never need to import each other, which avoids circular imports in larger projects split across multiple files — the same reason `depends_on` takes fixture names rather than the functions themselves. The registry passed in must be the one that actually contains the fixture by load time; with a single shared registry this is automatic, but if you merge registries with `include_registry`, call `obj`/`field` on the merged registry.
[`FixtureRegistry.field`](../reference/fixtures.md#fastapi_toolsets.fixtures.registry.FixtureRegistry.field) is shorthand for pulling a single attribute (`id` by default):
```python
@fixtures.register(depends_on=["roles"])
def users():
return [
User(id=1, username="alice", role_id=fixtures.field("roles", "name", "admin"))
]
```
Both raise `StopIteration` if no matching instance is found, and `KeyError` if the fixture name isn't registered.
## Pytest integration
@@ -173,18 +199,21 @@ from app.models import Base
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/test_db"
@pytest.fixture
async def db_session():
async with create_db_session(database_url=DATABASE_URL, base=Base, cleanup=True) as session:
async with create_db_session(
database_url=DATABASE_URL, base=Base, cleanup=True
) as session:
yield session
register_fixtures(registry=registry, namespace=globals())
```
```python
# test_users.py
async def test_user_can_login(fixture_users: list[User], fixture_roles: list[Role]):
...
async def test_user_can_login(fixture_users: list[User], fixture_roles: list[Role]): ...
```
The load order is resolved automatically from the `depends_on` declarations in your registry. Each generated fixture receives `db_session` as a dependency and returns the list of loaded model instances.
+3
View File
@@ -47,10 +47,12 @@ If neither of these applies to you, declaring metrics at module level (e.g. `HTT
```python
from prometheus_client import Counter, Histogram
@metrics.register
def http_requests():
return Counter("http_requests_total", "Total HTTP requests", ["method", "status"])
@metrics.register
def request_duration():
return Histogram("request_duration_seconds", "Request duration")
@@ -79,6 +81,7 @@ from prometheus_client import Gauge
_queue_depth = Gauge("queue_depth", "Current queue depth")
@metrics.register(collect=True)
def collect_queue_depth():
_queue_depth.set(get_current_queue_depth())
+20 -2
View File
@@ -11,6 +11,7 @@ The `models` module provides mixins that each add a single, well-defined column
```python
from fastapi_toolsets.models import UUIDMixin, TimestampMixin
class Article(Base, UUIDMixin, TimestampMixin):
__tablename__ = "articles"
@@ -31,11 +32,13 @@ Adds a `id: UUID` primary key generated server-side by PostgreSQL using `gen_ran
```python
from fastapi_toolsets.models import UUIDMixin
class User(Base, UUIDMixin):
__tablename__ = "users"
username: Mapped[str]
# id is None before flush
user = User(username="alice")
session.add(user)
@@ -54,11 +57,13 @@ Adds a `id: UUID` primary key generated server-side by PostgreSQL using `uuidv7(
```python
from fastapi_toolsets.models import UUIDv7Mixin
class Event(Base, UUIDv7Mixin):
__tablename__ = "events"
name: Mapped[str]
# id is None before flush
event = Event(name="user.signup")
session.add(event)
@@ -73,6 +78,7 @@ Adds a `created_at: datetime` column set to `clock_timestamp()` on insert. The c
```python
from fastapi_toolsets.models import UUIDMixin, CreatedAtMixin
class Order(Base, UUIDMixin, CreatedAtMixin):
__tablename__ = "orders"
@@ -86,11 +92,13 @@ Adds an `updated_at: datetime` column set to `clock_timestamp()` on insert and a
```python
from fastapi_toolsets.models import UUIDMixin, UpdatedAtMixin
class Post(Base, UUIDMixin, UpdatedAtMixin):
__tablename__ = "posts"
title: Mapped[str]
post = Post(title="Hello")
await session.flush()
await session.refresh(post)
@@ -111,6 +119,7 @@ Convenience mixin that combines [`CreatedAtMixin`](../reference/models.md#fastap
```python
from fastapi_toolsets.models import UUIDMixin, TimestampMixin
class Article(Base, UUIDMixin, TimestampMixin):
__tablename__ = "articles"
@@ -134,7 +143,7 @@ SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=EventSe
```
!!! info "Callbacks fire on `session.commit()` only — not on savepoints."
Savepoints created by [`get_transaction`](db.md) or `begin_nested()` do **not**
Savepoints created by [`transaction`](db.md) or `begin_nested()` do **not**
trigger callbacks. All events accumulated across flushes are dispatched once
when the outermost `commit()` is called.
@@ -166,10 +175,12 @@ class Order(Base, UUIDMixin):
__watched_fields__ = ("status",)
...
class UrgentOrder(Order):
# inherits __watched_fields__ = ("status",)
...
class PriorityOrder(Order):
__watched_fields__ = ("priority",)
# overrides parent — UPDATE fires only for priority changes
@@ -183,20 +194,24 @@ Register handlers with the [`listens_for`](../reference/models.md#fastapi_toolse
```python
from fastapi_toolsets.models import ModelEvent, UUIDMixin, listens_for
class Order(Base, UUIDMixin):
__tablename__ = "orders"
__watched_fields__ = ("status",)
status: Mapped[str]
@listens_for(Order, [ModelEvent.CREATE])
async def on_order_created(order: Order, event_type: ModelEvent, changes: None):
await notify_new_order(order.id)
@listens_for(Order, [ModelEvent.DELETE])
async def on_order_deleted(order: Order, event_type: ModelEvent, changes: None):
await notify_order_cancelled(order.id)
@listens_for(Order, [ModelEvent.UPDATE])
async def on_order_updated(order: Order, event_type: ModelEvent, changes: dict):
if "status" in changes:
@@ -212,8 +227,11 @@ A single handler can listen for multiple events at once. When `event_types` is o
async def on_order_changed(order: Order, event_type: ModelEvent, changes: dict | None):
await invalidate_cache(order.id)
@listens_for(Order) # all events
async def on_any_order_event(order: Order, event_type: ModelEvent, changes: dict | None):
async def on_any_order_event(
order: Order, event_type: ModelEvent, changes: dict | None
):
await audit_log(order.id, event_type)
```
+13 -4
View File
@@ -21,6 +21,7 @@ Use [`create_async_client`](../reference/pytest.md#fastapi_toolsets.pytest.utils
```python
from fastapi_toolsets.pytest import create_async_client
@pytest.fixture
async def http_client(db_session):
async def _override_get_db():
@@ -52,6 +53,7 @@ Use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.ut
```python
from fastapi_toolsets.pytest import create_worker_database, create_db_session
@pytest.fixture(scope="session")
async def worker_db_url():
async with create_worker_database(
@@ -96,21 +98,28 @@ Use [`worker_database_url`](../reference/pytest.md#fastapi_toolsets.pytest.utils
```python
from fastapi_toolsets.pytest import worker_database_url
url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test")
url = worker_database_url(
"postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test"
)
# → "postgresql+asyncpg://user:pass@localhost/gw0" under xdist
# → "postgresql+asyncpg://user:pass@localhost/test" otherwise
url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", default_test_db="test", prefix="myapp")
url = worker_database_url(
"postgresql+asyncpg://user:pass@localhost/myapp",
default_test_db="test",
prefix="myapp",
)
# → "postgresql+asyncpg://user:pass@localhost/myapp_gw0" under xdist
# → "postgresql+asyncpg://user:pass@localhost/myapp_test" otherwise
```
## Manual table cleanup
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables) truncates all tables in a single statement and can be called directly when you need more control:
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.testing.cleanup_tables) truncates all tables in a single statement and can be called directly when you need more control:
```python
from fastapi_toolsets.db import cleanup_tables
from fastapi_toolsets.pytest import cleanup_tables
@pytest.fixture(autouse=True)
async def clean(db_session):
+4
View File
@@ -15,6 +15,7 @@ The most common wrapper for a single resource response.
```python
from fastapi_toolsets.schemas import Response
@router.get("/users/{id}")
async def get_user(user: User = UserDep) -> Response[UserSchema]:
return Response(data=user, message="User retrieved")
@@ -39,6 +40,7 @@ Use as the return type when the endpoint always uses [`offset_paginate`](crud.md
```python
from fastapi_toolsets.schemas import OffsetPaginatedResponse
@router.get("/users")
async def list_users(
page: int = 1,
@@ -74,6 +76,7 @@ Use as the return type when the endpoint always uses [`cursor_paginate`](crud.md
```python
from fastapi_toolsets.schemas import CursorPaginatedResponse
@router.get("/events")
async def list_events(
cursor: str | None = None,
@@ -110,6 +113,7 @@ When used as a return annotation, `PaginatedResponse[T]` automatically expands t
from fastapi_toolsets.crud import PaginationType
from fastapi_toolsets.schemas import PaginatedResponse
@router.get("/users")
async def list_users(
pagination_type: PaginationType = PaginationType.OFFSET,
-354
View File
@@ -1,354 +0,0 @@
# Security
Composable authentication helpers for FastAPI that use `Security()` for OpenAPI documentation and accept user-provided validator functions with full type flexibility.
## Overview
The `security` module provides four auth source classes, a `MultiAuth` factory, and a set of OAuth 2.0 / OIDC helper utilities. Each auth class wraps a FastAPI security scheme for OpenAPI and accepts a validator function called as:
```python
await validator(credential, **kwargs)
```
where `kwargs` are the extra keyword arguments provided at instantiation (roles, permissions, enums, etc.). The validator returns the authenticated identity (e.g. a `User` model) which becomes the route dependency value.
```python
from fastapi import Security
from fastapi_toolsets.security import BearerTokenAuth
async def verify_token(token: str, *, role: str) -> User:
user = await db.get_by_token(token)
if not user or user.role != role:
raise UnauthorizedError()
return user
bearer_admin = BearerTokenAuth(verify_token, role="admin")
@app.get("/admin")
async def admin_route(user: User = Security(bearer_admin)):
return user
```
## Auth sources
### [`BearerTokenAuth`](../reference/security.md#fastapi_toolsets.security.BearerTokenAuth)
Reads the `Authorization: Bearer <token>` header. Wraps `HTTPBearer` for OpenAPI.
```python
from fastapi_toolsets.security import BearerTokenAuth
bearer = BearerTokenAuth(validator=verify_token)
@app.get("/me")
async def me(user: User = Security(bearer)):
return user
```
#### Token prefix
The optional `prefix` parameter restricts a `BearerTokenAuth` instance to tokens that start with a given string. The prefix is **kept** in the value passed to the validator — store and compare tokens with their prefix included.
This lets you deploy multiple `BearerTokenAuth` instances in the same application and disambiguate them efficiently in `MultiAuth`:
```python
user_bearer = BearerTokenAuth(verify_user, prefix="user_") # matches "Bearer user_..."
org_bearer = BearerTokenAuth(verify_org, prefix="org_") # matches "Bearer org_..."
```
Use [`generate_token()`](#token-generation) to create correctly-prefixed tokens.
#### Token generation
`BearerTokenAuth.generate_token()` produces a secure random token ready to store in your database and return to the client. If a prefix is configured it is prepended automatically:
```python
bearer = BearerTokenAuth(verify_token, prefix="user_")
token = bearer.generate_token() # e.g. "user_Xk3mN..."
await db.store_token(user_id, token)
return {"access_token": token, "token_type": "bearer"}
```
The client sends `Authorization: Bearer user_Xk3mN...` and the validator receives the full token (prefix included) to compare against the stored value.
### [`CookieAuth`](../reference/security.md#fastapi_toolsets.security.CookieAuth)
Reads a named cookie. Wraps `APIKeyCookie` for OpenAPI.
Cookies are issued with the `Secure` flag set by default, meaning they are only transmitted over HTTPS. Set `secure=False` when running locally over plain HTTP:
```python
from fastapi_toolsets.security import CookieAuth
# Production (HTTPS) — default
cookie_auth = CookieAuth("session", validator=verify_session)
# Local development (HTTP only)
cookie_auth = CookieAuth("session", validator=verify_session, secure=False)
@app.get("/me")
async def me(user: User = Security(cookie_auth)):
return user
```
#### Signed cookies
Pass `secret_key` to enable HMAC-SHA256 signed, tamper-proof cookies. The cookie payload includes an expiry timestamp (`ttl`, default 24 h). No database entry is required — the signature is self-contained.
Use `set_cookie()` to issue the signed cookie on login and `delete_cookie()` to clear it on logout:
```python
# Production
cookie_auth = CookieAuth("session", verify_session, secret_key="your-secret")
# Local development
cookie_auth = CookieAuth("session", verify_session, secret_key="your-secret", secure=False)
@app.post("/login")
async def login(response: Response):
cookie_auth.set_cookie(response, user_id)
return {"ok": True}
@app.post("/logout")
async def logout(response: Response):
cookie_auth.delete_cookie(response)
return {"ok": True}
@app.get("/me")
async def me(user: User = Security(cookie_auth)):
return user
```
When `secret_key` is not set, the raw cookie value is passed directly to the validator (stateful session behaviour — you manage the session store).
### [`APIKeyHeaderAuth`](../reference/security.md#fastapi_toolsets.security.APIKeyHeaderAuth)
Reads an API key from a named HTTP header. Wraps `APIKeyHeader` for OpenAPI.
```python
from fastapi_toolsets.security import APIKeyHeaderAuth
api_key_auth = APIKeyHeaderAuth("X-API-Key", validator=verify_api_key)
@app.get("/data")
async def data(user: User = Security(api_key_auth)):
return user
```
The header name is configurable — use any header your API defines (e.g. `"X-API-Key"`, `"Authorization"`, `"X-Service-Token"`).
## Typed validator kwargs
All auth classes forward extra instantiation keyword arguments to the validator. Arguments can be any type — enums, strings, integers, etc. The validator returns the authenticated identity, which FastAPI injects directly into the route handler.
```python
async def verify_token(token: str, *, role: Role, permission: str) -> User:
user = await decode_token(token)
if user.role != role or permission not in user.permissions:
raise UnauthorizedError()
return user
bearer = BearerTokenAuth(verify_token, role=Role.ADMIN, permission="billing:read")
```
Each auth instance is self-contained — create a separate instance per distinct requirement instead of passing requirements through `Security(scopes=[...])`.
### Using `.require()` inline
If declaring a new top-level variable per role feels verbose, use `.require()` to create a configured clone directly in the route decorator. The original instance is not mutated:
```python
bearer = BearerTokenAuth(verify_token)
@app.get("/admin/stats")
async def admin_stats(user: User = Security(bearer.require(role=Role.ADMIN))):
return {"message": f"Hello admin {user.name}"}
@app.get("/profile")
async def profile(user: User = Security(bearer.require(role=Role.USER))):
return {"id": user.id, "name": user.name}
```
`.require()` kwargs are merged over existing ones — new values win on conflict.
The `prefix` (for `BearerTokenAuth`), cookie name and `secret_key` (for
`CookieAuth`), and header name (for `APIKeyHeaderAuth`) are always preserved.
## MultiAuth
[`MultiAuth`](../reference/security.md#fastapi_toolsets.security.MultiAuth) combines multiple auth sources into a single callable. Sources are tried in order; the first one that finds a credential wins.
If a credential is extracted but the validator raises, the exception propagates immediately — the remaining sources are **not** tried. This prevents silent fallthrough on invalid credentials.
```python
from fastapi_toolsets.security import MultiAuth
multi = MultiAuth(user_bearer, org_bearer, cookie_auth)
@app.get("/data")
async def data_route(user = Security(multi)):
return user
```
### Using `.require()` on MultiAuth
`MultiAuth` also supports `.require()`, which propagates the kwargs to every source that implements it. Sources that do not (e.g. custom `AuthSource` subclasses) are passed through unchanged:
```python
multi = MultiAuth(bearer, cookie)
@app.get("/admin")
async def admin(user: User = Security(multi.require(role=Role.ADMIN))):
return user
```
This is equivalent to calling `.require()` on each source individually:
```python
# These two are identical
multi.require(role=Role.ADMIN)
MultiAuth(
bearer.require(role=Role.ADMIN),
cookie.require(role=Role.ADMIN),
)
```
### Prefix-based dispatch
Because `extract()` is pure string matching (no I/O), prefix-based source selection is essentially free. Only the matching source's validator (which may involve DB or network I/O) is ever called:
```python
user_bearer = BearerTokenAuth(verify_user, prefix="user_")
org_bearer = BearerTokenAuth(verify_org, prefix="org_")
multi = MultiAuth(user_bearer, org_bearer)
# "Bearer user_alice" → only verify_user runs, receives "user_alice"
# "Bearer org_acme" → only verify_org runs, receives "org_acme"
```
Tokens are stored and compared **with their prefix** — use `generate_token()` on each source to issue correctly-prefixed tokens:
```python
user_token = user_bearer.generate_token() # "user_..."
org_token = org_bearer.generate_token() # "org_..."
```
## Custom auth sources
Subclass [`AuthSource`](../reference/security.md#fastapi_toolsets.security.AuthSource) to implement any credential extraction strategy. You only need to implement `extract()` and `authenticate()`:
```python
from fastapi_toolsets.security import AuthSource
from fastapi_toolsets.exceptions import UnauthorizedError
class MTLSAuth(AuthSource):
async def extract(self, request) -> str | None:
return request.headers.get("X-Client-Cert-DN") or None
async def authenticate(self, credential: str):
dn = parse_dn(credential)
if dn.get("O") != "MyOrg":
raise UnauthorizedError()
return {"dn": credential}
```
Custom sources work transparently inside `MultiAuth`.
## OAuth 2.0 / OIDC helpers
The module provides standalone async utilities for building OAuth 2.0 / OIDC login flows. They handle provider discovery, authorization redirects, token exchange, and state encoding — leaving JWT validation and session management to your application.
### Provider discovery
[`oauth_resolve_provider_urls()`](../reference/security.md#fastapi_toolsets.security.oauth_resolve_provider_urls) fetches the OIDC discovery document and returns the endpoint URLs. Results are cached in-process to avoid repeated network calls:
```python
from fastapi_toolsets.security import oauth_resolve_provider_urls
auth_url, token_url, userinfo_url = await oauth_resolve_provider_urls(
"https://accounts.google.com/.well-known/openid-configuration"
)
```
Returns a `(authorization_url, token_url, userinfo_url)` tuple. `userinfo_url` is `None` when the provider does not advertise one.
### Authorization redirect
[`oauth_build_authorization_redirect()`](../reference/security.md#fastapi_toolsets.security.oauth_build_authorization_redirect) constructs the redirect to the provider's authorization page. It requires a `state_token` — a random CSRF token generated by [`oauth_generate_state_token()`](../reference/security.md#fastapi_toolsets.security.oauth_generate_state_token) — that must be stored server-side (e.g. in the session) and verified on the callback to prevent login-CSRF attacks ([RFC 6749 §10.12](https://datatracker.ietf.org/doc/html/rfc6749#section-10.12)):
```python
from fastapi import Request
from fastapi_toolsets.security import oauth_build_authorization_redirect, oauth_generate_state_token
@app.get("/auth/google/login")
async def google_login(request: Request):
auth_url, _, _ = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL)
state_token = oauth_generate_state_token()
request.session["oauth_state"] = state_token # requires SessionMiddleware
return oauth_build_authorization_redirect(
auth_url,
client_id=GOOGLE_CLIENT_ID,
scopes="openid email profile",
redirect_uri="https://myapp.com/auth/google/callback",
destination="/dashboard",
state_token=state_token,
)
```
### Token exchange and userinfo
[`oauth_fetch_userinfo()`](../reference/security.md#fastapi_toolsets.security.oauth_fetch_userinfo) performs the two-step exchange: it POSTs the authorization code to the token endpoint, then GETs the userinfo endpoint with the resulting access token.
On the callback, retrieve the stored token and pass it to [`oauth_decode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_decode_state) to verify the CSRF token before processing the code:
```python
from fastapi import HTTPException, Request
from fastapi_toolsets.security import oauth_decode_state, oauth_fetch_userinfo
@app.get("/auth/google/callback")
async def google_callback(request: Request, code: str, state: str):
# Pop token first — single-use, regardless of whether verification succeeds
state_token = request.session.pop("oauth_state", None)
if state_token is None:
raise HTTPException(status_code=400, detail="missing OAuth state")
destination = oauth_decode_state(state, expected_state_token=state_token, fallback="/")
if not destination.startswith("/"): # reject absolute URLs to prevent open-redirect
destination = "/"
_, token_url, userinfo_url = await oauth_resolve_provider_urls(GOOGLE_DISCOVERY_URL)
userinfo = await oauth_fetch_userinfo(
token_url=token_url,
userinfo_url=userinfo_url,
code=code,
client_id=GOOGLE_CLIENT_ID,
client_secret=GOOGLE_CLIENT_SECRET,
redirect_uri="https://myapp.com/auth/google/callback",
required_scopes="openid email profile",
)
user = await db.upsert_user(email=userinfo["email"])
response = RedirectResponse(destination)
session_cookie.set_cookie(response, str(user.id))
return response
```
Pass `required_scopes` to guard against providers silently granting fewer scopes than requested — `oauth_fetch_userinfo` raises `ValueError` if any are missing.
### State encoding
[`oauth_encode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_encode_state) and [`oauth_decode_state()`](../reference/security.md#fastapi_toolsets.security.oauth_decode_state) encode and decode the destination URL together with the CSRF token embedded in the OAuth `state` parameter. `oauth_decode_state` returns `fallback` if `state` is absent, malformed, or the token does not match:
```python
from fastapi_toolsets.security import oauth_encode_state, oauth_decode_state
state_token = oauth_generate_state_token()
encoded = oauth_encode_state("/dashboard", state_token)
decoded = oauth_decode_state(encoded, expected_state_token=state_token, fallback="/") # "/dashboard"
decoded = oauth_decode_state(encoded, expected_state_token="wrong", fallback="/") # "/"
decoded = oauth_decode_state(None, expected_state_token=state_token, fallback="/") # "/"
```
---
[:material-api: API Reference](../reference/security.md)
+5 -1
View File
@@ -6,7 +6,11 @@ You can import the main symbols from `fastapi_toolsets.crud`:
```python
from fastapi_toolsets.crud import CrudFactory, AsyncCrud
from fastapi_toolsets.crud.search import SearchConfig, get_searchable_fields, build_search_filters
from fastapi_toolsets.crud.search import (
SearchConfig,
get_searchable_fields,
build_search_filters,
)
```
## ::: fastapi_toolsets.crud.factory.AsyncCrud
+20 -18
View File
@@ -1,46 +1,48 @@
# `db`
Here's the reference for all database session utilities, transaction helpers, and locking functions.
Here's the reference for the `Database` facade, the transaction helper, locking
functions, many-to-many helpers, and row-watching utilities.
You can import them directly from `fastapi_toolsets.db`:
```python
from fastapi_toolsets.db import (
Database,
LockMode,
advisory_lock,
cleanup_tables,
create_database,
create_db_dependency,
create_db_context,
get_transaction,
lock_tables,
m2m_add,
m2m_remove,
m2m_set,
transaction,
wait_for_row_change,
)
```
## ::: fastapi_toolsets.db.Database
## ::: fastapi_toolsets.db.transaction
## ::: fastapi_toolsets.db.LockMode
## ::: fastapi_toolsets.db.create_db_dependency
## ::: fastapi_toolsets.db.create_db_context
## ::: fastapi_toolsets.db.get_transaction
## ::: fastapi_toolsets.db.lock_tables
## ::: fastapi_toolsets.db.advisory_lock
## ::: fastapi_toolsets.db.wait_for_row_change
## ::: fastapi_toolsets.db.create_database
## ::: fastapi_toolsets.db.cleanup_tables
## ::: fastapi_toolsets.db.m2m_add
## ::: fastapi_toolsets.db.m2m_remove
## ::: fastapi_toolsets.db.m2m_set
## ::: fastapi_toolsets.db.wait_for_row_change
Admin and test helpers live in `fastapi_toolsets.db.testing`:
```python
from fastapi_toolsets.db.testing import cleanup_tables, create_database
```
## ::: fastapi_toolsets.db.testing.create_database
## ::: fastapi_toolsets.db.testing.cleanup_tables
-3
View File
@@ -12,7 +12,6 @@ from fastapi_toolsets.fixtures import (
FixtureRegistry,
load_fixtures,
load_fixtures_by_context,
get_obj_by_attr,
)
```
@@ -27,5 +26,3 @@ from fastapi_toolsets.fixtures import (
## ::: fastapi_toolsets.fixtures.utils.load_fixtures
## ::: fastapi_toolsets.fixtures.utils.load_fixtures_by_context
## ::: fastapi_toolsets.fixtures.utils.get_obj_by_attr
-43
View File
@@ -1,43 +0,0 @@
# `security`
Here's the reference for the authentication helpers provided by the `security` module.
You can import them directly from `fastapi_toolsets.security`:
```python
from fastapi_toolsets.security import (
AuthSource,
BearerTokenAuth,
CookieAuth,
APIKeyHeaderAuth,
MultiAuth,
oauth_build_authorization_redirect,
oauth_decode_state,
oauth_encode_state,
oauth_fetch_userinfo,
oauth_generate_state_token,
oauth_resolve_provider_urls,
)
```
## ::: fastapi_toolsets.security.AuthSource
## ::: fastapi_toolsets.security.BearerTokenAuth
## ::: fastapi_toolsets.security.CookieAuth
## ::: fastapi_toolsets.security.APIKeyHeaderAuth
## ::: fastapi_toolsets.security.MultiAuth
## ::: fastapi_toolsets.security.oauth_resolve_provider_urls
## ::: fastapi_toolsets.security.oauth_fetch_userinfo
## ::: fastapi_toolsets.security.oauth_generate_state_token
## ::: fastapi_toolsets.security.oauth_build_authorization_redirect
## ::: fastapi_toolsets.security.oauth_encode_state
## ::: fastapi_toolsets.security.oauth_decode_state
@@ -2,8 +2,10 @@ from fastapi import FastAPI
from fastapi_toolsets.exceptions import init_exceptions_handlers
from .db import db
from .routes import router
app = FastAPI()
db.install(app=app)
init_exceptions_handlers(app=app)
app.include_router(router=router)
+5 -8
View File
@@ -1,17 +1,14 @@
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.ext.asyncio import AsyncSession
from fastapi_toolsets.db import create_db_context, create_db_dependency
from fastapi_toolsets.db import Database
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres"
engine = create_async_engine(url=DATABASE_URL, future=True)
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
db = Database(url=DATABASE_URL)
get_db = create_db_dependency(session_maker=async_session_maker)
get_db_context = create_db_context(session_maker=async_session_maker)
get_db = db
SessionDep = Annotated[AsyncSession, Depends(get_db)]
SessionDep = Annotated[AsyncSession, Depends(db)]
+14 -6
View File
@@ -1,6 +1,6 @@
[project]
name = "fastapi-toolsets"
version = "4.1.3"
version = "5.1.0"
description = "Production-ready utilities for FastAPI applications"
readme = "README.md"
license = "MIT"
@@ -50,17 +50,13 @@ cli = [
metrics = [
"prometheus_client>=0.20.0",
]
security = [
"async-lru>=1.0",
"httpx>=0.25.0",
]
pytest = [
"httpx>=0.25.0",
"pytest-xdist>=3.0.0",
"pytest>=8.0.0",
]
all = [
"fastapi-toolsets[cli,metrics,pytest,security]",
"fastapi-toolsets[cli,metrics,pytest]",
]
[project.scripts]
@@ -98,6 +94,18 @@ docs-src = [
requires = ["uv_build>=0.10,<0.12.0"]
build-backend = "uv_build"
[tool.ruff.format]
exclude = ["*.md"]
[tool.ruff.lint]
extend-select = ["E712"]
[tool.ruff.lint.flake8-bugbear]
extend-immutable-calls = ["fastapi.Depends"]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["RUF012", "RUF059", "SIM117", "DTZ001", "S110", "BLE001"]
[tool.pytest.ini_options]
testpaths = ["tests"]
filterwarnings = [
+6 -3
View File
@@ -7,18 +7,21 @@ Example usage:
from fastapi import FastAPI, Depends
from fastapi_toolsets.exceptions import init_exceptions_handlers
from fastapi_toolsets.crud import CrudFactory
from fastapi_toolsets.db import create_db_dependency
from fastapi_toolsets.db import Database
from fastapi_toolsets.schemas import Response
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
app = FastAPI()
db.install(app)
init_exceptions_handlers(app)
UserCrud = CrudFactory(User)
@app.get("/users/{user_id}", response_model=Response[dict])
async def get_user(user_id: int, session = Depends(get_db)):
async def get_user(user_id: int, session = Depends(db)):
user = await UserCrud.get(session, [User.id == user_id])
return Response(data={"user": user.username}, message="Success")
"""
__version__ = "4.1.3"
__version__ = "5.1.0"
+21 -18
View File
@@ -6,7 +6,8 @@ import typer
from rich.console import Console
from rich.table import Table
from ...fixtures import Context, LoadStrategy, load_fixtures_by_context
from ...fixtures import Context, LoadStrategy
from ...logger import get_logger
from ..config import get_db_context, get_fixtures_registry
from ..utils import async_command
@@ -16,13 +17,14 @@ fixture_cli = typer.Typer(
no_args_is_help=True,
)
console = Console()
logger = get_logger()
@fixture_cli.command("list")
def list_fixtures(
ctx: typer.Context,
context: Annotated[
Context | None,
str | None,
typer.Option(
"--context",
"-c",
@@ -32,10 +34,10 @@ def list_fixtures(
) -> None:
"""List all registered fixtures."""
registry = get_fixtures_registry()
fixtures = registry.get_by_context(context.value) if context else registry.get_all()
fixtures = registry.get_by_context(context) if context else registry.get_all()
if not fixtures:
print("No fixtures found.")
logger.info("No fixtures found.")
return
table = Table("Name", "Contexts", "Dependencies")
@@ -46,7 +48,7 @@ def list_fixtures(
table.add_row(fixture.name, contexts, deps)
console.print(table)
print(f"\nTotal: {len(fixtures)} fixture(s)")
logger.info("Total: %d fixture(s)", len(fixtures))
@fixture_cli.command("load")
@@ -54,8 +56,8 @@ def list_fixtures(
async def load(
ctx: typer.Context,
contexts: Annotated[
list[Context] | None,
typer.Argument(help="Contexts to load."),
list[str] | None,
typer.Argument(help="Contexts to load.", envvar="FIXTURES_CONTEXT"),
] = None,
strategy: Annotated[
LoadStrategy,
@@ -69,26 +71,27 @@ async def load(
] = False,
) -> None:
"""Load fixtures into the database."""
from ...fixtures import load_fixtures_by_context
registry = get_fixtures_registry()
db_context = get_db_context()
context_list = list(contexts) if contexts else [Context.BASE]
context_list = contexts or [Context.BASE.value]
ordered = registry.resolve_context_dependencies(*context_list)
if not ordered:
print("No fixtures to load for the specified context(s).")
logger.info("No fixtures to load for the specified context(s).")
return
print(f"\nFixtures to load ({strategy.value} strategy):")
for name in ordered:
fixture = registry.get(name)
instances = list(fixture.func())
model_name = type(instances[0]).__name__ if instances else "?"
print(f" - {name}: {len(instances)} {model_name}(s)")
if dry_run:
print("\n[Dry run - no changes made]")
logger.info("Fixtures to load (%s strategy):", strategy.value)
for name in ordered:
variants = registry.get_load_variants(name, *context_list)
instances = [inst for v in variants for inst in v.func()]
model_name = type(instances[0]).__name__ if instances else "?"
logger.info(" - %s: %d %s(s)", name, len(instances), model_name)
logger.info("[Dry run - no changes made]")
return
async with db_context() as session:
@@ -97,4 +100,4 @@ async def load(
)
total = sum(len(items) for items in result.values())
print(f"\nLoaded {total} record(s) successfully.")
logger.info("Loaded %d record(s) successfully.", total)
+33 -22
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import importlib
import sys
from typing import TYPE_CHECKING, Any, Literal, overload
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
import typer
@@ -13,6 +13,8 @@ from .pyproject import find_pyproject, load_pyproject
if TYPE_CHECKING:
from ..fixtures import FixtureRegistry
T = TypeVar("T")
def _ensure_project_in_path():
"""Add project root to sys.path if not installed in editable mode."""
@@ -88,19 +90,39 @@ def get_config_value(key: str, required: bool = False) -> Any | None:
return value
@overload
def _import_typed(
key: str, expected_type: type[T], *, required: Literal[True]
) -> T: ... # pragma: no cover
@overload
def _import_typed(
key: str, expected_type: type[T], *, required: bool
) -> T | None: ... # pragma: no cover
def _import_typed(key: str, expected_type: type[T], *, required: bool) -> T | None:
"""Import a config value by key and validate its type.
Raises:
typer.BadParameter: If required and missing, or if the imported
value isn't an instance of *expected_type*.
"""
import_path = get_config_value(key, required=required)
if not import_path:
return None
obj = import_from_string(import_path)
if not isinstance(obj, expected_type):
raise typer.BadParameter(
f"'{key}' must be a {expected_type.__name__} instance, got {type(obj).__name__}"
)
return obj
def get_fixtures_registry() -> FixtureRegistry:
"""Import and return the fixtures registry from config."""
from ..fixtures import FixtureRegistry
import_path = get_config_value("fixtures", required=True)
registry = import_from_string(import_path)
if not isinstance(registry, FixtureRegistry):
raise typer.BadParameter(
f"'fixtures' must be a FixtureRegistry instance, got {type(registry).__name__}"
)
return registry
return _import_typed("fixtures", FixtureRegistry, required=True)
def get_db_context() -> Any:
@@ -111,15 +133,4 @@ def get_db_context() -> Any:
def get_custom_cli() -> typer.Typer | None:
"""Import and return the custom CLI Typer instance from config."""
import_path = get_config_value("custom_cli")
if not import_path:
return None
custom = import_from_string(import_path)
if not isinstance(custom, typer.Typer):
raise typer.BadParameter(
f"'custom_cli' must be a Typer instance, got {type(custom).__name__}"
)
return custom
return _import_typed("custom_cli", typer.Typer, required=False)
+2 -1
View File
@@ -1,6 +1,5 @@
"""CLI utility functions."""
import asyncio
import functools
from collections.abc import Callable, Coroutine
from typing import Any, ParamSpec, TypeVar
@@ -24,6 +23,8 @@ def async_command(func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
import asyncio
return asyncio.run(func(*args, **kwargs))
return wrapper
+1 -1
View File
@@ -22,7 +22,6 @@ __all__ = [
"AsyncCrud",
"CrudFactory",
"FacetFieldType",
"get_searchable_fields",
"InvalidFacetFilterError",
"InvalidSearchColumnError",
"JoinType",
@@ -34,4 +33,5 @@ __all__ = [
"SearchConfig",
"SearchFieldType",
"UnsupportedFacetTypeError",
"get_searchable_fields",
]
+114 -51
View File
@@ -22,7 +22,7 @@ from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
from sqlalchemy.sql.base import ExecutableOption
from sqlalchemy.sql.roles import WhereHavingRole
from ..db import get_transaction
from ..db import transaction
from ..exceptions import InvalidOrderFieldError, NotFoundError
from ..schemas import (
CursorPaginatedResponse,
@@ -44,6 +44,7 @@ from ..types import (
)
from .search import (
SearchConfig,
apply_search_joins,
build_facets,
build_filter_by,
build_search_filters,
@@ -51,7 +52,6 @@ from .search import (
search_field_keys,
)
_ForUpdateMode: TypeAlias = bool | Literal["nowait", "skip_locked"]
@@ -128,17 +128,6 @@ def _apply_joins(q: Any, joins: JoinType | None, outer_join: bool) -> Any:
return q
def _apply_search_joins(q: Any, search_joins: list[Any]) -> Any:
"""Apply relationship-based outer joins (from search/filter_by) to a query."""
seen: set[str] = set()
for join_rel in search_joins:
key = str(join_rel)
if key not in seen:
seen.add(key)
q = q.outerjoin(join_rel)
return q
class AsyncCrud(Generic[ModelType]):
"""Generic async CRUD operations for SQLAlchemy models.
@@ -184,16 +173,30 @@ class AsyncCrud(Generic[ModelType]):
return cls.default_load_options
@classmethod
async def _reload_with_options(
cls: type[Self], session: AsyncSession, instance: ModelType
def _capture_pk_values(cls: type[Self], instance: ModelType) -> dict[str, Any]:
"""Capture PK values off instance — call before commit expires attributes."""
return {
cast(str, col.key): getattr(instance, cast(str, col.key))
for col in cls.model.__mapper__.primary_key
}
@classmethod
async def _reload_with_options_by_pk(
cls: type[Self], session: AsyncSession, pk_values: dict[str, Any]
) -> ModelType:
"""Re-query instance by PK with default_load_options applied."""
mapper = cls.model.__mapper__
"""Re-query by previously captured PK values, with default_load_options applied."""
# Only called when cls.default_load_options is set (see call sites).
pk_filters = [
getattr(cls.model, col.key) == getattr(instance, col.key)
for col in mapper.primary_key
getattr(cls.model, key) == value for key, value in pk_values.items()
]
return await cls.get(session, filters=pk_filters)
q = select(cls.model).where(and_(*pk_filters))
q = q.execution_options(populate_existing=True)
q = q.options(*cast(Sequence[ExecutableOption], cls.default_load_options))
result = await session.execute(q)
item = result.unique().scalar_one_or_none()
if item is None: # pragma: no cover — row was just flushed in this transaction
raise NotFoundError()
return cast(ModelType, item)
@classmethod
async def _resolve_m2m(
@@ -264,12 +267,12 @@ class AsyncCrud(Generic[ModelType]):
cls: type[Self],
filter_by: dict[str, Any] | BaseModel | None,
facet_fields: Sequence[FacetFieldType] | None,
) -> tuple[list[Any], list[Any]]:
"""Normalize filter_by and return (filters, joins) to apply to the query."""
) -> tuple[dict[str, Any], list[Any]]:
"""Normalize filter_by and return ({facet_key: filter}, joins) to apply to the query."""
if isinstance(filter_by, BaseModel):
filter_by = filter_by.model_dump(exclude_none=True)
if not filter_by:
return [], []
return {}, []
resolved = cls._resolve_facet_fields(facet_fields)
return build_filter_by(filter_by, resolved or [])
@@ -280,8 +283,13 @@ class AsyncCrud(Generic[ModelType]):
facet_fields: Sequence[FacetFieldType] | None,
filters: list[Any],
search_joins: list[Any],
*,
include_facets: bool = True,
own_filters: dict[str, Any] | None = None,
) -> dict[str, list[Any]] | None:
"""Build facet filter_attributes, or return None if no facet fields configured."""
"""Build facet filter_attributes, or None if disabled/no facet fields configured."""
if not include_facets:
return None
resolved = cls._resolve_facet_fields(facet_fields)
if not resolved:
return None
@@ -291,6 +299,7 @@ class AsyncCrud(Generic[ModelType]):
resolved,
base_filters=filters,
base_joins=search_joins,
own_filters=own_filters,
)
@classmethod
@@ -474,6 +483,7 @@ class AsyncCrud(Generic[ModelType]):
default_page_size: int = 20,
max_page_size: int = 100,
include_total: bool = True,
include_facets: bool = True,
search: bool = True,
filter: bool = True,
order: bool = True,
@@ -489,6 +499,7 @@ class AsyncCrud(Generic[ModelType]):
default_page_size: Default ``items_per_page`` value.
max_page_size: Maximum ``items_per_page`` value.
include_total: Whether to include total count (not a query param).
include_facets: Whether to run facet queries (not a query param).
search: Enable search query parameters.
filter: Enable facet filter query parameters.
order: Enable order query parameters.
@@ -518,7 +529,10 @@ class AsyncCrud(Generic[ModelType]):
]
return cls._build_paginate_params(
pagination_params=pagination_params,
pagination_fixed={"include_total": include_total},
pagination_fixed={
"include_total": include_total,
"include_facets": include_facets,
},
dep_name=f"{cls.model.__name__}OffsetPaginateParams",
search=search,
filter=filter,
@@ -536,6 +550,7 @@ class AsyncCrud(Generic[ModelType]):
*,
default_page_size: int = 20,
max_page_size: int = 100,
include_facets: bool = True,
search: bool = True,
filter: bool = True,
order: bool = True,
@@ -550,6 +565,7 @@ class AsyncCrud(Generic[ModelType]):
Args:
default_page_size: Default ``items_per_page`` value.
max_page_size: Maximum ``items_per_page`` value.
include_facets: Whether to run facet queries (not a query param).
search: Enable search query parameters.
filter: Enable facet filter query parameters.
order: Enable order query parameters.
@@ -581,7 +597,7 @@ class AsyncCrud(Generic[ModelType]):
]
return cls._build_paginate_params(
pagination_params=pagination_params,
pagination_fixed={},
pagination_fixed={"include_facets": include_facets},
dep_name=f"{cls.model.__name__}CursorPaginateParams",
search=search,
filter=filter,
@@ -601,6 +617,7 @@ class AsyncCrud(Generic[ModelType]):
max_page_size: int = 100,
default_pagination_type: PaginationType = PaginationType.OFFSET,
include_total: bool = True,
include_facets: bool = True,
search: bool = True,
filter: bool = True,
order: bool = True,
@@ -617,6 +634,7 @@ class AsyncCrud(Generic[ModelType]):
max_page_size: Maximum ``items_per_page`` value.
default_pagination_type: Default pagination strategy.
include_total: Whether to include total count (not a query param).
include_facets: Whether to run facet queries (not a query param).
search: Enable search query parameters.
filter: Enable facet filter query parameters.
order: Enable order query parameters.
@@ -665,7 +683,10 @@ class AsyncCrud(Generic[ModelType]):
]
return cls._build_paginate_params(
pagination_params=pagination_params,
pagination_fixed={"include_total": include_total},
pagination_fixed={
"include_total": include_total,
"include_facets": include_facets,
},
dep_name=f"{cls.model.__name__}PaginateParams",
search=search,
filter=filter,
@@ -716,7 +737,7 @@ class AsyncCrud(Generic[ModelType]):
Returns:
Created model instance, or ``Response[schema]`` when ``schema`` is given.
"""
async with get_transaction(session):
async with transaction(session):
m2m_exclude = cls._m2m_schema_fields()
data = (
obj.model_dump(exclude=m2m_exclude) if m2m_exclude else obj.model_dump()
@@ -729,9 +750,14 @@ class AsyncCrud(Generic[ModelType]):
setattr(db_model, rel_attr, related_instances)
session.add(db_model)
await session.refresh(db_model)
if cls.default_load_options:
db_model = await cls._reload_with_options(session, db_model)
pk_values: dict[str, Any] | None = None
if cls.default_load_options:
await session.flush()
pk_values = cls._capture_pk_values(db_model)
if pk_values is not None:
db_model = await cls._reload_with_options_by_pk(session, pk_values)
else:
await session.refresh(db_model)
result = cast(ModelType, db_model)
if schema:
return Response(data=schema.model_validate(result))
@@ -1067,7 +1093,7 @@ class AsyncCrud(Generic[ModelType]):
Raises:
NotFoundError: If no record found
"""
async with get_transaction(session):
async with transaction(session):
m2m_exclude = cls._m2m_schema_fields()
# Eagerly load M2M relationships that will be updated so that
@@ -1096,9 +1122,15 @@ class AsyncCrud(Generic[ModelType]):
m2m_resolved = await cls._resolve_m2m(session, obj, only_set=True)
for rel_attr, related_instances in m2m_resolved.items():
setattr(db_model, rel_attr, related_instances)
await session.refresh(db_model)
if cls.default_load_options:
db_model = await cls._reload_with_options(session, db_model)
pk_values: dict[str, Any] | None = None
if cls.default_load_options:
await session.flush()
pk_values = cls._capture_pk_values(db_model)
if pk_values is not None:
db_model = await cls._reload_with_options_by_pk(session, pk_values)
else:
await session.refresh(db_model)
if schema:
return Response(data=schema.model_validate(db_model))
return db_model
@@ -1127,7 +1159,7 @@ class AsyncCrud(Generic[ModelType]):
Returns:
Model instance
"""
async with get_transaction(session):
async with transaction(session):
values = obj.model_dump(exclude_unset=True)
q = insert(cls.model).values(**values)
if set_:
@@ -1189,7 +1221,7 @@ class AsyncCrud(Generic[ModelType]):
Returns:
``None``, or ``Response[None]`` when ``return_response=True``.
"""
async with get_transaction(session):
async with transaction(session):
result = await session.execute(select(cls.model).where(and_(*filters)))
objects = result.scalars().all()
for obj in objects:
@@ -1270,6 +1302,7 @@ class AsyncCrud(Generic[ModelType]):
search_column: str | None = None,
order_fields: Sequence[OrderFieldType] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None,
include_facets: bool = True,
filter_by: dict[str, Any] | BaseModel | None = None,
schema: type[BaseModel],
) -> OffsetPaginatedResponse[Any]:
@@ -1291,6 +1324,9 @@ class AsyncCrud(Generic[ModelType]):
search_column: Restrict search to a single column key.
order_fields: Fields allowed for sorting (overrides class default).
facet_fields: Columns to compute distinct values for (overrides class default)
include_facets: When ``False``, skip facet queries entirely;
``filter_attributes`` will be ``None``. Useful on pages 2..N
where the facet counts were already fetched on page 1.
filter_by: Dict of {column_key: value} to filter by declared facet fields.
Keys must match the column.key of a facet field. Scalar → equality,
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
@@ -1303,7 +1339,6 @@ class AsyncCrud(Generic[ModelType]):
offset = (page - 1) * items_per_page
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
filters.extend(fb_filters)
# Build search filters
if search:
@@ -1317,6 +1352,11 @@ class AsyncCrud(Generic[ModelType]):
filters.extend(search_filters)
search_joins.extend(new_search_joins)
# Facets combine these with each facet's own filter individually, so
# fb_filters is applied to the query below but excluded here.
facet_base_filters = list(filters)
filters.extend(fb_filters.values())
# Build query with joins
q = select(cls.model)
@@ -1324,11 +1364,11 @@ class AsyncCrud(Generic[ModelType]):
q = _apply_joins(q, joins, outer_join)
# Apply search joins (always outer joins for search)
q = _apply_search_joins(q, search_joins)
q = apply_search_joins(q, search_joins)
# Apply order joins (relation joins required for order_by field)
if order_joins:
q = _apply_search_joins(q, order_joins)
q = apply_search_joins(q, order_joins)
if filters:
q = q.where(and_(*filters))
@@ -1351,7 +1391,7 @@ class AsyncCrud(Generic[ModelType]):
count_q = _apply_joins(count_q, joins, outer_join)
# Apply search joins to count query
count_q = _apply_search_joins(count_q, search_joins)
count_q = apply_search_joins(count_q, search_joins)
if filters:
count_q = count_q.where(and_(*filters))
@@ -1371,7 +1411,12 @@ class AsyncCrud(Generic[ModelType]):
items: list[Any] = [schema.model_validate(item) for item in raw_items]
filter_attributes = await cls._build_filter_attributes(
session, facet_fields, filters, search_joins
session,
facet_fields,
facet_base_filters,
search_joins,
include_facets=include_facets,
own_filters=fb_filters,
)
search_columns = cls._resolve_search_columns(search_fields)
order_columns = cls._resolve_order_columns(order_fields)
@@ -1407,6 +1452,7 @@ class AsyncCrud(Generic[ModelType]):
search_column: str | None = None,
order_fields: Sequence[OrderFieldType] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None,
include_facets: bool = True,
filter_by: dict[str, Any] | BaseModel | None = None,
schema: type[BaseModel],
) -> CursorPaginatedResponse[Any]:
@@ -1429,6 +1475,8 @@ class AsyncCrud(Generic[ModelType]):
search_column: Restrict search to a single column key.
order_fields: Fields allowed for sorting (overrides class default).
facet_fields: Columns to compute distinct values for (overrides class default).
include_facets: When ``False``, skip facet queries entirely;
``filter_attributes`` will be ``None``.
filter_by: Dict of {column_key: value} to filter by declared facet fields.
Keys must match the column.key of a facet field. Scalar → equality,
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
@@ -1440,7 +1488,6 @@ class AsyncCrud(Generic[ModelType]):
filters = list(filters) if filters else []
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
filters.extend(fb_filters)
if cls.cursor_column is None:
raise ValueError(
@@ -1472,6 +1519,11 @@ class AsyncCrud(Generic[ModelType]):
filters.extend(search_filters)
search_joins.extend(new_search_joins)
# Facets combine these with each facet's own filter individually, so
# fb_filters is applied to the query below but excluded here.
facet_base_filters = list(filters)
filters.extend(fb_filters.values())
# Build query
q = select(cls.model)
@@ -1479,11 +1531,11 @@ class AsyncCrud(Generic[ModelType]):
q = _apply_joins(q, joins, outer_join)
# Apply search joins (always outer joins)
q = _apply_search_joins(q, search_joins)
q = apply_search_joins(q, search_joins)
# Apply order joins (relation joins required for order_by field)
if order_joins:
q = _apply_search_joins(q, order_joins)
q = apply_search_joins(q, order_joins)
if filters:
q = q.where(and_(*filters))
@@ -1528,11 +1580,10 @@ class AsyncCrud(Generic[ModelType]):
# prev_cursor: points before the first item in ascending order
prev_cursor: str | None = None
if direction is _CursorDirection.NEXT and cursor is not None and items_page:
prev_cursor = _encode_cursor(
getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV
)
elif direction is _CursorDirection.PREV and has_more and items_page:
if items_page and (
(direction is _CursorDirection.NEXT and cursor is not None)
or (direction is _CursorDirection.PREV and has_more)
):
prev_cursor = _encode_cursor(
getattr(items_page[0], cursor_col_name), direction=_CursorDirection.PREV
)
@@ -1540,7 +1591,12 @@ class AsyncCrud(Generic[ModelType]):
items: list[Any] = [schema.model_validate(item) for item in items_page]
filter_attributes = await cls._build_filter_attributes(
session, facet_fields, filters, search_joins
session,
facet_fields,
facet_base_filters,
search_joins,
include_facets=include_facets,
own_filters=fb_filters,
)
search_columns = cls._resolve_search_columns(search_fields)
order_columns = cls._resolve_order_columns(order_fields)
@@ -1580,6 +1636,7 @@ class AsyncCrud(Generic[ModelType]):
search_column: str | None = ...,
order_fields: Sequence[OrderFieldType] | None = ...,
facet_fields: Sequence[FacetFieldType] | None = ...,
include_facets: bool = ...,
filter_by: dict[str, Any] | BaseModel | None = ...,
schema: type[BaseModel],
) -> OffsetPaginatedResponse[Any]: ...
@@ -1606,6 +1663,7 @@ class AsyncCrud(Generic[ModelType]):
search_column: str | None = ...,
order_fields: Sequence[OrderFieldType] | None = ...,
facet_fields: Sequence[FacetFieldType] | None = ...,
include_facets: bool = ...,
filter_by: dict[str, Any] | BaseModel | None = ...,
schema: type[BaseModel],
) -> CursorPaginatedResponse[Any]: ...
@@ -1631,6 +1689,7 @@ class AsyncCrud(Generic[ModelType]):
search_column: str | None = None,
order_fields: Sequence[OrderFieldType] | None = None,
facet_fields: Sequence[FacetFieldType] | None = None,
include_facets: bool = True,
filter_by: dict[str, Any] | BaseModel | None = None,
schema: type[BaseModel],
) -> OffsetPaginatedResponse[Any] | CursorPaginatedResponse[Any]:
@@ -1661,6 +1720,8 @@ class AsyncCrud(Generic[ModelType]):
order_fields: Fields allowed for sorting (overrides class default).
facet_fields: Columns to compute distinct values for (overrides
class default).
include_facets: When ``False``, skip facet queries entirely;
``filter_attributes`` will be ``None``.
filter_by: Dict of ``{column_key: value}`` to filter by declared
facet fields. Keys must match the ``column.key`` of a facet
field. Scalar → equality, list → IN clause. Raises
@@ -1691,6 +1752,7 @@ class AsyncCrud(Generic[ModelType]):
search_column=search_column,
order_fields=order_fields,
facet_fields=facet_fields,
include_facets=include_facets,
filter_by=filter_by,
schema=schema,
)
@@ -1713,6 +1775,7 @@ class AsyncCrud(Generic[ModelType]):
search_column=search_column,
order_fields=order_fields,
facet_fields=facet_fields,
include_facets=include_facets,
filter_by=filter_by,
schema=schema,
)
+89 -62
View File
@@ -1,12 +1,12 @@
"""Search utilities for AsyncCrud."""
import asyncio
import functools
from collections.abc import Sequence
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Any, Literal
from sqlalchemy import String, and_, func, or_, select
from sqlalchemy import String, and_, distinct, func, or_, select
from sqlalchemy.dialects.postgresql import aggregate_order_by
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.attributes import InstrumentedAttribute
@@ -159,8 +159,11 @@ def build_search_filters(
else:
column = field
# Build the filter (cast to String for non-text columns)
column_as_string = column.cast(String)
# Build the filter (cast to String only when needed, to preserve
# pg_trgm GIN index usability on already-String columns)
column_as_string = (
column if isinstance(column.type, String) else column.cast(String)
)
if config.case_sensitive:
filters.append(column_as_string.like(f"%{query}%"))
else:
@@ -181,6 +184,21 @@ def search_field_keys(fields: Sequence[SearchFieldType]) -> list[str]:
return facet_keys(fields)
def apply_search_joins(q: Any, joins: Sequence[Any]) -> Any:
"""Apply relationship-based outer joins (from search/filter_by/facets) to a query.
Deduplicates by relationship identity so a join used by several fields
(e.g. search + a facet on the same relation) is only applied once.
"""
seen: set[str] = set()
for rel in joins:
rel_key = str(rel)
if rel_key not in seen:
seen.add(rel_key)
q = q.outerjoin(rel)
return q
def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]:
"""Return a key for each facet field.
@@ -207,6 +225,7 @@ async def build_facets(
*,
base_filters: "list[ColumnElement[bool]] | None" = None,
base_joins: list[InstrumentedAttribute[Any]] | None = None,
own_filters: "dict[str, ColumnElement[bool]] | None" = None,
) -> dict[str, list[Any]]:
"""Return distinct values for each facet field, respecting current filters.
@@ -216,15 +235,24 @@ async def build_facets(
facet_fields: Columns or relationship tuples to facet on
base_filters: Filter conditions already applied to the main query (search + caller filters)
base_joins: Relationship joins already applied to the main query
own_filters: Map of facet key -> the ``filter_by`` condition for that
same key (if any). Excluded from that facet's own subquery so
filtering on a facet doesn't collapse its own value list down to
just the filtered value.
Returns:
Dict mapping column key to sorted list of distinct non-None values
"""
existing_join_keys: set[str] = {str(j) for j in (base_joins or [])}
if not facet_fields:
return {}
keys = facet_keys(facet_fields)
own_filters = own_filters or {}
async def _query_facet(field: FacetFieldType, key: str) -> tuple[str, list[Any]]:
scalars: list[Any] = []
enum_classes: dict[str, Any] = {}
for field, key in zip(facet_fields, keys):
if isinstance(field, tuple):
# Relationship chain: (User.role, Role.name) — last element is the column
rels = field[:-1]
@@ -235,51 +263,48 @@ async def build_facets(
col_type = column.property.columns[0].type
is_array = isinstance(col_type, ARRAY)
enum_classes[key] = getattr(col_type, "enum_class", None)
if is_array:
unnested = func.unnest(column).label(column.key)
q = select(unnested).select_from(model).distinct()
else:
q = select(column).select_from(model).distinct()
# Apply base joins (deduplicated) — needed here independently
seen_joins: set[str] = set()
for rel in base_joins or []:
rel_key = str(rel)
if rel_key not in seen_joins:
seen_joins.add(rel_key)
q = q.outerjoin(rel)
# Add any extra joins required by this facet field that aren't already applied
for rel in rels:
rel_key = str(rel)
if rel_key not in existing_join_keys and rel_key not in seen_joins:
seen_joins.add(rel_key)
q = q.outerjoin(rel)
if base_filters:
q = q.where(and_(*base_filters))
if is_array:
q = q.order_by(unnested)
else:
q = q.order_by(column)
result = await session.execute(q)
col_type = column.property.columns[0].type
enum_class = getattr(col_type, "enum_class", None)
values = [
row[0].name
if (enum_class is not None and isinstance(row[0], enum_class))
else row[0]
for row in result.all()
if row[0] is not None
filters = [
*(base_filters or []),
*(f for k, f in own_filters.items() if k != key),
]
return key, values
joins = [*(base_joins or []), *rels]
pairs = await asyncio.gather(
*[_query_facet(f, k) for f, k in zip(facet_fields, keys)]
)
return dict(pairs)
if is_array:
unnested = apply_search_joins(
select(func.unnest(column).label("v")).select_from(model), joins
)
if filters:
unnested = unnested.where(and_(*filters))
unnested_sq = unnested.subquery()
v = unnested_sq.c.v
agg = (
select(func.array_agg(aggregate_order_by(distinct(v), v)))
.select_from(unnested_sq)
.where(v.isnot(None))
)
else:
agg = apply_search_joins(
select(
func.array_agg(aggregate_order_by(distinct(column), column))
).select_from(model),
joins,
)
agg = agg.where(and_(*filters, column.isnot(None)))
scalars.append(agg.scalar_subquery().label(key))
row = (await session.execute(select(*scalars))).one()
facets: dict[str, list[Any]] = {}
for key, values in zip(keys, row):
enum_class = enum_classes[key]
facets[key] = [
v.name if (enum_class is not None and isinstance(v, enum_class)) else v
for v in (values or [])
]
return facets
_EQUALITY_TYPES = (String, Integer, Numeric, Date, DateTime, Time, Enum, Uuid)
@@ -301,7 +326,7 @@ def _coerce_bool(value: Any) -> bool:
def build_filter_by(
filter_by: dict[str, Any],
facet_fields: Sequence[FacetFieldType],
) -> tuple["list[ColumnElement[bool]]", list[InstrumentedAttribute[Any]]]:
) -> tuple["dict[str, ColumnElement[bool]]", list[InstrumentedAttribute[Any]]]:
"""Translate a {column_key: value} dict into SQLAlchemy filter conditions.
Args:
@@ -309,7 +334,9 @@ def build_filter_by(
facet_fields: Declared facet fields to validate keys against
Returns:
Tuple of (filter_conditions, joins_needed)
Tuple of ({facet_key: filter_condition}, joins_needed). One filter
condition per key, so callers can identify (and exclude) a facet's
own filter when computing that facet's distinct values.
Raises:
InvalidFacetFilterError: If a key in filter_by is not a declared facet field
@@ -327,7 +354,7 @@ def build_filter_by(
index[key] = (column, rels)
valid_keys = set(index)
filters: list[ColumnElement[bool]] = []
filters: dict[str, ColumnElement[bool]] = {}
joins: list[InstrumentedAttribute[Any]] = []
added_join_keys: set[str] = set()
@@ -347,37 +374,37 @@ def build_filter_by(
if isinstance(col_type, Boolean):
coerce = _coerce_bool
if isinstance(value, list):
filters.append(column.in_([coerce(v) for v in value]))
filters[key] = column.in_([coerce(v) for v in value])
else:
filters.append(column == coerce(value))
filters[key] = column == coerce(value)
elif isinstance(col_type, ARRAY):
if isinstance(value, list):
filters.append(column.overlap(value))
filters[key] = column.overlap(value)
else:
filters.append(column.any(value))
filters[key] = column.any(value)
elif isinstance(col_type, Enum):
enum_class = col_type.enum_class
if enum_class is not None:
def _coerce_enum(v: Any) -> Any:
def _coerce_enum(v: Any, enum_class: Any = enum_class) -> Any:
if isinstance(v, enum_class):
return v
return enum_class[v] # lookup by name: "PENDING", "RED"
if isinstance(value, list):
filters.append(column.in_([_coerce_enum(v) for v in value]))
filters[key] = column.in_([_coerce_enum(v) for v in value])
else:
filters.append(column == _coerce_enum(value))
filters[key] = column == _coerce_enum(value)
else: # pragma: no cover
if isinstance(value, list):
filters.append(column.in_(value))
filters[key] = column.in_(value)
else:
filters.append(column == value)
filters[key] = column == value
elif isinstance(col_type, _EQUALITY_TYPES):
if isinstance(value, list):
filters.append(column.in_(value))
filters[key] = column.in_(value)
else:
filters.append(column == value)
filters[key] = column == value
else:
raise UnsupportedFacetTypeError(key, type(col_type).__name__)
-591
View File
@@ -1,591 +0,0 @@
"""Database utilities: sessions, transactions, and locks."""
import asyncio
from collections.abc import AsyncGenerator, Callable
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from enum import Enum
from typing import Any, TypeVar, cast
import asyncpg
from sqlalchemy import Table, delete, text, tuple_
from sqlalchemy import exc as sa_exc
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
from sqlalchemy.orm.relationships import RelationshipProperty
from .exceptions import LockTimeoutError, NotFoundError, PoolExhaustedError
def _is_lock_not_available(e: sa_exc.DBAPIError) -> bool:
return e.orig is not None and isinstance(
e.orig.__cause__, asyncpg.exceptions.LockNotAvailableError
)
__all__ = [
"LockMode",
"advisory_lock",
"cleanup_tables",
"create_database",
"create_db_context",
"create_db_dependency",
"get_transaction",
"lock_tables",
"m2m_add",
"m2m_remove",
"m2m_set",
"wait_for_row_change",
]
_SessionT = TypeVar("_SessionT", bound=AsyncSession)
def create_db_dependency(
session_maker: async_sessionmaker[_SessionT],
) -> Callable[[], AsyncGenerator[_SessionT, None]]:
"""Create a FastAPI dependency for database sessions.
Creates a dependency function that yields a session and auto-commits
if a transaction is active when the request completes.
Args:
session_maker: Async session factory from create_session_factory()
Returns:
An async generator function usable with FastAPI's Depends()
Example:
```python
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from fastapi_toolsets.db import create_db_dependency
engine = create_async_engine("postgresql+asyncpg://...")
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
get_db = create_db_dependency(SessionLocal)
@app.get("/users")
async def list_users(session: AsyncSession = Depends(get_db)):
...
```
"""
async def get_db() -> AsyncGenerator[_SessionT, None]:
async with session_maker() as session:
try:
await session.connection()
except sa_exc.TimeoutError as e:
raise PoolExhaustedError() from e
yield session
if session.in_transaction():
await session.commit()
return get_db
def create_db_context(
session_maker: async_sessionmaker[_SessionT],
) -> Callable[[], AbstractAsyncContextManager[_SessionT]]:
"""Create a context manager for database sessions.
Creates a context manager for use outside of FastAPI request handlers,
such as in background tasks, CLI commands, or tests.
Args:
session_maker: Async session factory from create_session_factory()
Returns:
An async context manager function
Example:
```python
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from fastapi_toolsets.db import create_db_context
engine = create_async_engine("postgresql+asyncpg://...")
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
get_db_context = create_db_context(SessionLocal)
async def background_task():
async with get_db_context() as session:
user = await UserCrud.get(session, [User.id == 1])
...
```
"""
get_db = create_db_dependency(session_maker)
return asynccontextmanager(get_db)
@asynccontextmanager
async def get_transaction(
session: AsyncSession,
) -> AsyncGenerator[AsyncSession, None]:
"""Get a transaction context, handling nested transactions.
If already in a transaction, creates a savepoint (nested transaction).
Otherwise, starts a new transaction.
Args:
session: AsyncSession instance
Yields:
The session within the transaction context
Example:
```python
async with get_transaction(session):
session.add(model)
# Auto-commits on exit, rolls back on exception
```
"""
if session.in_transaction():
async with session.begin_nested():
yield session
else:
async with session.begin():
yield session
class LockMode(str, Enum):
"""PostgreSQL table lock modes.
See: https://www.postgresql.org/docs/current/explicit-locking.html
"""
ACCESS_SHARE = "ACCESS SHARE"
ROW_SHARE = "ROW SHARE"
ROW_EXCLUSIVE = "ROW EXCLUSIVE"
SHARE_UPDATE_EXCLUSIVE = "SHARE UPDATE EXCLUSIVE"
SHARE = "SHARE"
SHARE_ROW_EXCLUSIVE = "SHARE ROW EXCLUSIVE"
EXCLUSIVE = "EXCLUSIVE"
ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE"
def lock_tables(
session_maker: async_sessionmaker[_SessionT],
tables: list[type[DeclarativeBase]],
*,
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
timeout: str = "5s",
) -> AbstractAsyncContextManager[_SessionT]:
"""Lock PostgreSQL tables for the duration of a transaction.
Args:
session_maker: Async session factory used to create the dedicated
session.
tables: List of SQLAlchemy model classes to lock.
mode: Lock mode (default: SHARE UPDATE EXCLUSIVE).
timeout: Lock timeout (default: "5s").
Yields:
The dedicated session, open within the locked transaction.
Raises:
SQLAlchemyError: If the lock cannot be acquired within *timeout*.
Example:
```python
from fastapi_toolsets.db import lock_tables, LockMode
async with lock_tables(session_maker, [User, Account]) as session:
# Tables are locked; changes are committed when the context exits.
user = await UserCrud.get(session, [User.id == 1])
user.balance += 100
# With custom lock mode
async with lock_tables(session_maker, [Order], mode=LockMode.EXCLUSIVE) as session:
await process_order(session, order_id)
```
"""
table_names = ",".join(table.__tablename__ for table in tables)
@asynccontextmanager
async def _lock() -> AsyncGenerator[_SessionT, None]:
async with session_maker() as session:
try:
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
yield session
await session.commit()
except sa_exc.TimeoutError as e:
await session.rollback()
raise PoolExhaustedError(
f"Connection pool exhausted while locking '{table_names}'. "
) from e
except sa_exc.DBAPIError as e:
await session.rollback()
if _is_lock_not_available(e):
raise LockTimeoutError(
f"Lock on '{table_names}' could not be acquired within {timeout}."
) from e
raise # pragma: no cover
except BaseException:
await session.rollback()
raise
return _lock()
@asynccontextmanager
async def advisory_lock(
session: AsyncSession,
key: int | tuple[int, int],
*,
shared: bool = False,
nowait: bool = False,
timeout: str | None = None,
) -> AsyncGenerator[bool, None]:
"""Acquire a PostgreSQL session-level advisory lock.
Args:
session: AsyncSession instance.
key: Lock key — a single ``int`` (bigint) or a ``(int, int)`` pair for namespacing.
shared: Acquire a shared lock (multiple holders allowed). Default is exclusive.
nowait: Return ``False`` immediately if the lock is unavailable instead of waiting.
timeout: Maximum wait time (e.g. ``"5s"``, ``"500ms"``). Raises ``DBAPIError``
if exceeded. Ignored when *nowait* is ``True``.
Yields:
``True`` if the lock was acquired, ``False`` if *nowait* is ``True`` and the lock
is already held.
Raises:
LockTimeoutError: If *timeout* is set and the lock cannot be acquired in time.
Example:
```python
from fastapi_toolsets.db import advisory_lock
async with advisory_lock(session, 42):
...
async with advisory_lock(session, 42, nowait=True) as acquired:
if not acquired:
raise HTTPException(409, "Resource is locked")
async with advisory_lock(session, 42, timeout="5s"):
...
async with advisory_lock(session, (1, user_id), shared=True):
...
```
"""
suffix = "_shared" if shared else ""
acquire_fn = f"{'pg_try_advisory_lock' if nowait else 'pg_advisory_lock'}{suffix}"
release_fn = f"pg_advisory_unlock{suffix}"
if isinstance(key, tuple):
k1, k2 = key
args = "CAST(:k1 AS integer), CAST(:k2 AS integer)"
params: dict[str, int] = {"k1": k1, "k2": k2}
else:
args = ":k"
params = {"k": key}
acquire_sql = text(f"SELECT {acquire_fn}({args})")
release_sql = text(f"SELECT {release_fn}({args})")
if timeout is not None and not nowait:
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
try:
result = await session.execute(acquire_sql, params)
except sa_exc.DBAPIError as e:
if _is_lock_not_available(e):
raise LockTimeoutError(
f"Advisory lock {key!r} could not be acquired within {timeout}."
) from e
raise # pragma: no cover
acquired = result.scalar() if nowait else True
try:
yield acquired
finally:
if acquired:
await session.execute(release_sql, params)
async def create_database(
db_name: str,
*,
server_url: str,
) -> None:
"""Create a database.
Connects to *server_url* using ``AUTOCOMMIT`` isolation and issues a
``CREATE DATABASE`` statement for *db_name*.
Args:
db_name: Name of the database to create.
server_url: URL used for server-level DDL (must point to an existing
database on the same server).
Example:
```python
from fastapi_toolsets.db import create_database
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
await create_database("myapp_test", server_url=SERVER_URL)
```
"""
engine = create_async_engine(server_url, isolation_level="AUTOCOMMIT")
try:
async with engine.connect() as conn:
await conn.execute(text(f"CREATE DATABASE {db_name}"))
finally:
await engine.dispose()
async def cleanup_tables(
session: AsyncSession,
base: type[DeclarativeBase],
) -> None:
"""Truncate all tables for fast between-test cleanup.
Executes a single ``TRUNCATE … RESTART IDENTITY CASCADE`` statement
across every table in *base*'s metadata, which is significantly faster
than dropping and re-creating tables between tests.
This is a no-op when the metadata contains no tables.
Args:
session: An active async database session.
base: SQLAlchemy DeclarativeBase class containing model metadata.
Example:
```python
@pytest.fixture
async def db_session(worker_db_url):
async with create_db_session(worker_db_url, Base) as session:
yield session
await cleanup_tables(session, Base)
```
"""
tables = base.metadata.sorted_tables
if not tables:
return
table_names = ", ".join(f'"{t.name}"' for t in tables)
await session.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))
await session.commit()
_M = TypeVar("_M", bound=DeclarativeBase)
async def wait_for_row_change(
session: AsyncSession,
model: type[_M],
pk_value: Any,
*,
columns: list[str] | None = None,
interval: float = 0.5,
timeout: float | None = None,
) -> _M:
"""Poll a database row until a change is detected.
Queries the row every ``interval`` seconds and returns the model instance
once a change is detected in any column (or only the specified ``columns``).
Args:
session: AsyncSession instance
model: SQLAlchemy model class
pk_value: Primary key value of the row to watch
columns: Optional list of column names to watch. If None, all columns
are watched.
interval: Polling interval in seconds (default: 0.5)
timeout: Maximum time to wait in seconds. None means wait forever.
Returns:
The refreshed model instance with updated values
Raises:
NotFoundError: If the row does not exist or is deleted during polling
TimeoutError: If timeout expires before a change is detected
Example:
```python
from fastapi_toolsets.db import wait_for_row_change
# Wait for any column to change
updated = await wait_for_row_change(session, User, user_id)
# Watch specific columns with a timeout
updated = await wait_for_row_change(
session, User, user_id,
columns=["status", "email"],
interval=1.0,
timeout=30.0,
)
```
"""
instance = await session.get(model, pk_value)
if instance is None:
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
if columns is not None:
watch_cols = columns
else:
watch_cols = [attr.key for attr in model.__mapper__.column_attrs]
initial = {col: getattr(instance, col) for col in watch_cols}
elapsed = 0.0
while True:
await asyncio.sleep(interval)
elapsed += interval
if timeout is not None and elapsed >= timeout:
raise TimeoutError(
f"No change detected on {model.__name__} "
f"with pk={pk_value!r} within {timeout}s"
)
session.expunge(instance)
instance = await session.get(model, pk_value)
if instance is None:
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} was deleted")
current = {col: getattr(instance, col) for col in watch_cols}
if current != initial:
return instance
def _m2m_prop(rel_attr: QueryableAttribute) -> RelationshipProperty: # type: ignore[type-arg]
"""Return the validated M2M RelationshipProperty for *rel_attr*.
Raises TypeError if *rel_attr* is not a Many-to-Many relationship.
"""
prop = rel_attr.property
if not isinstance(prop, RelationshipProperty) or prop.secondary is None:
raise TypeError(
f"m2m helpers require a Many-to-Many relationship attribute, "
f"got {rel_attr!r}. Use a relationship with a secondary table."
)
return prop
async def m2m_add(
session: AsyncSession,
instance: DeclarativeBase,
rel_attr: QueryableAttribute,
*related: DeclarativeBase,
ignore_conflicts: bool = False,
) -> None:
"""Insert rows into a Many-to-Many association table without loading the ORM collection.
Args:
session: DB async session.
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
*related: One or more related instances to associate with ``instance``.
ignore_conflicts: When ``True``, silently skip rows that already exist
in the association table (``ON CONFLICT DO NOTHING``).
Raises:
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
"""
prop = _m2m_prop(rel_attr)
if not related:
return
secondary = cast(Table, prop.secondary)
assert secondary is not None # guaranteed by _m2m_prop
sync_pairs = prop.secondary_synchronize_pairs
assert sync_pairs is not None # set whenever secondary is set
# synchronize_pairs: [(parent_col, assoc_col), ...]
# secondary_synchronize_pairs: [(related_col, assoc_col), ...]
rows: list[dict[str, Any]] = []
for rel_instance in related:
row: dict[str, Any] = {}
for parent_col, assoc_col in prop.synchronize_pairs:
row[assoc_col.name] = getattr(instance, cast(str, parent_col.key))
for related_col, assoc_col in sync_pairs:
row[assoc_col.name] = getattr(rel_instance, cast(str, related_col.key))
rows.append(row)
stmt = pg_insert(secondary).values(rows)
if ignore_conflicts:
stmt = stmt.on_conflict_do_nothing()
await session.execute(stmt)
async def m2m_remove(
session: AsyncSession,
instance: DeclarativeBase,
rel_attr: QueryableAttribute,
*related: DeclarativeBase,
) -> None:
"""Remove rows from a Many-to-Many association table without loading the ORM collection.
Args:
session: DB async session.
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
*related: One or more related instances to disassociate from ``instance``.
Raises:
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
"""
prop = _m2m_prop(rel_attr)
if not related:
return
secondary = cast(Table, prop.secondary)
assert secondary is not None # guaranteed by _m2m_prop
related_pairs = prop.secondary_synchronize_pairs
assert related_pairs is not None # set whenever secondary is set
parent_where = [
assoc_col == getattr(instance, cast(str, parent_col.key))
for parent_col, assoc_col in prop.synchronize_pairs
]
if len(related_pairs) == 1:
related_col, assoc_col = related_pairs[0]
related_values = [getattr(r, cast(str, related_col.key)) for r in related]
related_where = assoc_col.in_(related_values)
else:
assoc_cols = [ac for _, ac in related_pairs]
rel_cols = [rc for rc, _ in related_pairs]
related_values_t = [
tuple(getattr(r, cast(str, rc.key)) for rc in rel_cols) for r in related
]
related_where = tuple_(*assoc_cols).in_(related_values_t)
await session.execute(delete(secondary).where(*parent_where, related_where))
async def m2m_set(
session: AsyncSession,
instance: DeclarativeBase,
rel_attr: QueryableAttribute,
*related: DeclarativeBase,
) -> None:
"""Replace the entire Many-to-Many association set atomically.
Args:
session: DB async session.
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
*related: The new complete set of related instances.
Raises:
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
"""
prop = _m2m_prop(rel_attr)
secondary = cast(Table, prop.secondary)
assert secondary is not None # guaranteed by _m2m_prop
parent_where = [
assoc_col == getattr(instance, cast(str, parent_col.key))
for parent_col, assoc_col in prop.synchronize_pairs
]
await session.execute(delete(secondary).where(*parent_where))
if related:
await m2m_add(session, instance, rel_attr, *related)
+18
View File
@@ -0,0 +1,18 @@
"""Database package: the ``Database`` facade plus PostgreSQL power-tools."""
from .core import Database, transaction
from .locks import LockMode, advisory_lock, lock_tables
from .m2m import m2m_add, m2m_remove, m2m_set
from .watch import wait_for_row_change
__all__ = [
"Database",
"LockMode",
"advisory_lock",
"lock_tables",
"m2m_add",
"m2m_remove",
"m2m_set",
"transaction",
"wait_for_row_change",
]
+322
View File
@@ -0,0 +1,322 @@
"""The ``Database`` facade: session lifecycle, dependency, middleware, transactions."""
from collections.abc import AsyncGenerator
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from typing import Any
from pydantic import PostgresDsn
from sqlalchemy import exc as sa_exc
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from starlette.requests import Request
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from ..exceptions import PoolExhaustedError
from .locks import LockMode, lock_tables
@asynccontextmanager
async def transaction(
session: AsyncSession,
) -> AsyncGenerator[AsyncSession, None]:
"""Run a block inside a savepoint-aware transaction.
If *session* is already in a transaction, a nested transaction (savepoint)
is opened so the block can roll back independently. Otherwise a top-level
transaction is started. Commits on clean exit, rolls back on exception.
Args:
session: AsyncSession instance.
Yields:
The session within the transaction context.
Example:
```python
from fastapi_toolsets.db import transaction
async with transaction(session):
session.add(model)
```
"""
if session.in_transaction():
async with session.begin_nested():
yield session
else:
async with session.begin():
yield session
class _CommitOnResponseMiddleware:
"""Commit the request's DB session before the response is sent."""
def __init__(self, app: ASGIApp, *, state_attr: str) -> None:
self.app = app
self.state_attr = state_attr
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
async def send_wrapper(message: Message) -> None:
if message["type"] == "http.response.start":
# ``scope["state"]`` is the same dict ``request.state`` writes
# to, so this is the session stashed by the dependency.
state = scope.get("state")
session = state.get(self.state_attr) if state else None
if session is not None and session.in_transaction():
await session.commit()
await send(message)
await self.app(scope, receive, send_wrapper)
class Database:
"""One object that owns the engine, sessions, dependency, and middleware.
Provide exactly one of *url* (the facade builds and disposes the engine) or
*engine* (an engine you own, e.g. for Alembic or ``event.listen``, left
untouched).
Args:
url: Database connection URL. Accepts a plain string or a Pydantic
:class:`~pydantic.PostgresDsn`.
engine: An existing :class:`AsyncEngine` to reuse instead of *url*.
session_class: Session class for the sessionmaker (e.g. ``EventSession``).
expire_on_commit: Expire attributes after commit. Defaults to ``False``.
autoflush: Autoflush the session before queries. Defaults to ``True``.
connect_args: DBAPI-level connection arguments forwarded to
:func:`create_async_engine` (URL mode only).
**engine_options: Extra keyword arguments forwarded to
:func:`create_async_engine` (URL mode only).
Raises:
TypeError: If neither or both of *url* and *engine* are given, or if
*connect_args*/*engine_options* are passed together with *engine*.
Example:
```python
from fastapi import Depends, FastAPI
from fastapi_toolsets.db import Database
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
app = FastAPI()
db.install(app)
@app.get("/users/{user_id}")
async def get_user(user_id: int, session=Depends(db)):
return await UserCrud.get(session, [User.id == user_id])
```
"""
def __init__(
self,
url: str | PostgresDsn | None = None,
*,
engine: AsyncEngine | None = None,
session_class: type[AsyncSession] = AsyncSession,
expire_on_commit: bool = False,
autoflush: bool = True,
connect_args: dict[str, Any] | None = None,
**engine_options: Any,
) -> None:
if (url is None) == (engine is None):
raise TypeError(
"Database requires exactly one of 'url' or 'engine' "
"(got both or neither)."
)
if engine is not None and (engine_options or connect_args is not None):
raise TypeError(
"connect_args/engine_options are only valid in URL mode; "
"configure the engine you pass via 'engine=' yourself."
)
if engine is not None:
self._owns_engine = False
self.engine: AsyncEngine = engine
else:
assert url is not None # guaranteed by the XOR check above
self._owns_engine = True
if connect_args is not None:
engine_options["connect_args"] = connect_args
# ``PostgresDsn`` (and other URL objects) are not str subclasses, so
# coerce to the string form SQLAlchemy expects.
self.engine = create_async_engine(str(url), **engine_options)
self._sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker(
self.engine,
class_=session_class,
expire_on_commit=expire_on_commit,
autoflush=autoflush,
)
# Private, per-instance state attribute; cannot collide with another
# Database or be mismatched against the middleware.
self._state_attr = f"_ft_db_session_{id(self):x}"
self._middleware_installed = False
self._disposed = False
async def _dispose(self) -> None:
"""Dispose the engine once, only if we own it (idempotent)."""
if self._owns_engine and not self._disposed:
self._disposed = True
await self.engine.dispose()
@asynccontextmanager
async def lifespan(self, app: Any) -> AsyncGenerator[None, None]:
"""Dispose the engine on shutdown; use as ``FastAPI(lifespan=db.lifespan)``.
Args:
app: The ASGI application (unused; required by the lifespan protocol).
Yields:
Control to the application for its lifetime.
Example:
```python
app = FastAPI(lifespan=db.lifespan)
```
"""
try:
yield
finally:
await self._dispose()
def install(self, app: Any) -> None:
"""Wire the commit middleware and engine disposal onto *app*.
Args:
app: The FastAPI/Starlette application to wire.
Example:
```python
@asynccontextmanager
async def lifespan(app):
... # your startup
yield
... # your shutdown
app = FastAPI(lifespan=lifespan)
db.install(app)
```
"""
app.add_middleware(_CommitOnResponseMiddleware, state_attr=self._state_attr)
self._middleware_installed = True
inner_lifespan = app.router.lifespan_context
@asynccontextmanager
async def _composed(app_: Any) -> AsyncGenerator[None, None]:
async with self.lifespan(app_), inner_lifespan(app_):
yield
app.router.lifespan_context = _composed
@asynccontextmanager
async def _open(self) -> AsyncGenerator[AsyncSession, None]:
"""Open a session and eagerly acquire a connection (fail-fast on pool)."""
async with self._sessionmaker() as session:
try:
await session.connection()
except sa_exc.TimeoutError as e:
raise PoolExhaustedError() from e
yield session
async def __call__(self, request: Request) -> AsyncGenerator[AsyncSession, None]:
"""FastAPI dependency: yield a session and commit once at the right time.
Args:
request: The incoming request (injected by FastAPI).
Yields:
An AsyncSession for the duration of the request.
Example:
```python
@app.get("/users/{user_id}")
async def get_user(user_id: int, session=Depends(db)):
return await UserCrud.get(session, [User.id == user_id])
```
"""
async with self._open() as session:
setattr(request.state, self._state_attr, session)
yield session
if not self._middleware_installed and session.in_transaction():
await session.commit()
@asynccontextmanager
async def session(self) -> AsyncGenerator[AsyncSession, None]:
"""Open a session outside request handlers (background tasks, CLI, tests).
Commits on clean exit, rolls back on exception.
Yields:
An AsyncSession ready for database operations.
Example:
```python
async with db.session() as session:
user = await UserCrud.get(session, [User.id == 1])
```
"""
async with self._open() as session:
yield session
if session.in_transaction():
await session.commit()
@asynccontextmanager
async def begin(self) -> AsyncGenerator[AsyncSession, None]:
"""Open a session already inside a transaction (sugar for the common case).
Equivalent to ``session()`` + :func:`transaction`. Commits on clean exit,
rolls back on exception.
Yields:
An AsyncSession open within a transaction.
Example:
```python
async with db.begin() as session:
session.add(User(name="ada"))
```
"""
async with self.session() as session, transaction(session):
yield session
def lock_tables(
self,
tables: list[type[DeclarativeBase]],
*,
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
timeout: str = "5s",
) -> AbstractAsyncContextManager[AsyncSession]:
"""Lock PostgreSQL tables for the duration of a dedicated transaction.
Opens its own session from the facade's sessionmaker, changes are
committed when the context exits.
Args:
tables: List of SQLAlchemy model classes to lock.
mode: Lock mode (default: ``SHARE UPDATE EXCLUSIVE``).
timeout: Lock timeout (default: ``"5s"``).
Yields:
The dedicated session, open within the locked transaction.
Raises:
LockTimeoutError: If the lock cannot be acquired within *timeout*.
PoolExhaustedError: If the connection pool is exhausted.
Example:
```python
async with db.lock_tables([User, Account]) as session:
user = await UserCrud.get(session, [User.id == 1])
user.balance += 100
```
"""
return lock_tables(self._sessionmaker, tables, mode=mode, timeout=timeout)
+185
View File
@@ -0,0 +1,185 @@
"""PostgreSQL locking helpers: table locks and advisory locks."""
from collections.abc import AsyncGenerator
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from enum import Enum
from typing import TypeVar
import asyncpg
from sqlalchemy import exc as sa_exc
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from ..exceptions import LockTimeoutError, PoolExhaustedError
_SessionT = TypeVar("_SessionT", bound=AsyncSession)
def _is_lock_not_available(e: sa_exc.DBAPIError) -> bool:
return e.orig is not None and isinstance(
e.orig.__cause__, asyncpg.exceptions.LockNotAvailableError
)
class LockMode(str, Enum):
"""PostgreSQL table lock modes.
See: https://www.postgresql.org/docs/current/explicit-locking.html
"""
ACCESS_SHARE = "ACCESS SHARE"
ROW_SHARE = "ROW SHARE"
ROW_EXCLUSIVE = "ROW EXCLUSIVE"
SHARE_UPDATE_EXCLUSIVE = "SHARE UPDATE EXCLUSIVE"
SHARE = "SHARE"
SHARE_ROW_EXCLUSIVE = "SHARE ROW EXCLUSIVE"
EXCLUSIVE = "EXCLUSIVE"
ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE"
def lock_tables(
session_maker: async_sessionmaker[_SessionT],
tables: list[type[DeclarativeBase]],
*,
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
timeout: str = "5s",
) -> AbstractAsyncContextManager[_SessionT]:
"""Lock PostgreSQL tables for the duration of a transaction.
Prefer the method on a :class:`Database` instance; use this
directly only when you manage your own session factory.
Args:
session_maker: Async session factory used to create the dedicated
session.
tables: List of SQLAlchemy model classes to lock.
mode: Lock mode (default: SHARE UPDATE EXCLUSIVE).
timeout: Lock timeout (default: "5s").
Yields:
The dedicated session, open within the locked transaction.
Raises:
LockTimeoutError: If the lock cannot be acquired within *timeout*.
PoolExhaustedError: If the connection pool is exhausted.
Example:
```python
from fastapi_toolsets.db import lock_tables
async with lock_tables(session_maker, [User, Account]) as session:
user = await UserCrud.get(session, [User.id == 1])
user.balance += 100
```
"""
table_names = ",".join(table.__tablename__ for table in tables)
@asynccontextmanager
async def _lock() -> AsyncGenerator[_SessionT, None]:
async with session_maker() as session:
try:
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
yield session
await session.commit()
except sa_exc.TimeoutError as e:
await session.rollback()
raise PoolExhaustedError(
f"Connection pool exhausted while locking '{table_names}'. "
) from e
except sa_exc.DBAPIError as e:
await session.rollback()
if _is_lock_not_available(e):
raise LockTimeoutError(
f"Lock on '{table_names}' could not be acquired within {timeout}."
) from e
raise # pragma: no cover
except BaseException:
await session.rollback()
raise
return _lock()
@asynccontextmanager
async def advisory_lock(
session: AsyncSession,
key: int | tuple[int, int],
*,
shared: bool = False,
nowait: bool = False,
timeout: str | None = None,
) -> AsyncGenerator[bool, None]:
"""Acquire a PostgreSQL session-level advisory lock.
Args:
session: AsyncSession instance.
key: Lock key, either a single ``int`` (bigint) or a ``(int, int)`` pair for namespacing.
shared: Acquire a shared lock (multiple holders allowed). Default is exclusive.
nowait: Return ``False`` immediately if the lock is unavailable instead of waiting.
timeout: Maximum wait time (e.g. ``"5s"``, ``"500ms"``). Raises ``DBAPIError``
if exceeded. Ignored when *nowait* is ``True``.
Yields:
``True`` if the lock was acquired, ``False`` if *nowait* is ``True`` and the lock
is already held.
Raises:
LockTimeoutError: If *timeout* is set and the lock cannot be acquired in time.
Example:
```python
from fastapi_toolsets.db import advisory_lock
async with advisory_lock(session, 42):
...
async with advisory_lock(session, 42, nowait=True) as acquired:
if not acquired:
raise HTTPException(409, "Resource is locked")
async with advisory_lock(session, 42, timeout="5s"):
...
async with advisory_lock(session, (1, user_id), shared=True):
...
```
"""
suffix = "_shared" if shared else ""
acquire_fn = f"{'pg_try_advisory_lock' if nowait else 'pg_advisory_lock'}{suffix}"
release_fn = f"pg_advisory_unlock{suffix}"
if isinstance(key, tuple):
k1, k2 = key
args = "CAST(:k1 AS integer), CAST(:k2 AS integer)"
params: dict[str, int] = {"k1": k1, "k2": k2}
else:
args = ":k"
params = {"k": key}
acquire_sql = text(f"SELECT {acquire_fn}({args})")
release_sql = text(f"SELECT {release_fn}({args})")
# Lock management runs raw SQL on the caller's session. Guard it with
# ``no_autoflush`` so acquiring or releasing the lock never flushes the
# caller's pending ORM changes; SQLAlchemy 2.1 autoflushes on raw
# ``text()`` too, where 2.0 did not.
try:
with session.no_autoflush:
if timeout is not None and not nowait:
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
result = await session.execute(acquire_sql, params)
except sa_exc.DBAPIError as e:
if _is_lock_not_available(e):
raise LockTimeoutError(
f"Advisory lock {key!r} could not be acquired within {timeout}."
) from e
raise # pragma: no cover
acquired = result.scalar() if nowait else True
try:
yield acquired
finally:
if acquired:
with session.no_autoflush:
await session.execute(release_sql, params)
+168
View File
@@ -0,0 +1,168 @@
"""Many-to-Many association-table helpers (direct, without loading collections)."""
from typing import Any, cast
from sqlalchemy import ColumnElement, Table, delete, tuple_
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
from sqlalchemy.orm.relationships import RelationshipProperty
def _m2m_prop(rel_attr: QueryableAttribute) -> tuple[RelationshipProperty, Table]: # type: ignore[type-arg]
"""Return the validated M2M RelationshipProperty and its secondary table.
Raises TypeError if *rel_attr* is not a Many-to-Many relationship.
"""
prop = rel_attr.property
if not isinstance(prop, RelationshipProperty) or prop.secondary is None:
raise TypeError(
f"m2m helpers require a Many-to-Many relationship attribute, "
f"got {rel_attr!r}. Use a relationship with a secondary table."
)
return prop, cast(Table, prop.secondary)
def _parent_where(
prop: RelationshipProperty, # type: ignore[type-arg]
instance: DeclarativeBase,
) -> list[ColumnElement[bool]]:
"""Build the WHERE clauses matching the owner side of *instance*."""
return [
assoc_col == getattr(instance, cast(str, parent_col.key))
for parent_col, assoc_col in prop.synchronize_pairs
]
async def m2m_add(
session: AsyncSession,
instance: DeclarativeBase,
rel_attr: QueryableAttribute,
*related: DeclarativeBase,
ignore_conflicts: bool = False,
) -> None:
"""Insert rows into a Many-to-Many association table without loading the ORM collection.
Args:
session: DB async session.
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
*related: One or more related instances to associate with ``instance``.
ignore_conflicts: When ``True``, silently skip rows that already exist
in the association table (``ON CONFLICT DO NOTHING``).
Raises:
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
Example:
```python
from fastapi_toolsets.db import m2m_add, transaction
async with transaction(session):
await m2m_add(session, post, Post.tags, tag1, tag2)
```
"""
prop, secondary = _m2m_prop(rel_attr)
if not related:
return
sync_pairs = prop.secondary_synchronize_pairs
assert sync_pairs is not None # set whenever secondary is set
# synchronize_pairs: [(parent_col, assoc_col), ...]
# secondary_synchronize_pairs: [(related_col, assoc_col), ...]
rows: list[dict[str, Any]] = []
for rel_instance in related:
row: dict[str, Any] = {}
for parent_col, assoc_col in prop.synchronize_pairs:
row[assoc_col.name] = getattr(instance, cast(str, parent_col.key))
for related_col, assoc_col in sync_pairs:
row[assoc_col.name] = getattr(rel_instance, cast(str, related_col.key))
rows.append(row)
stmt = pg_insert(secondary).values(rows)
if ignore_conflicts:
stmt = stmt.on_conflict_do_nothing()
await session.execute(stmt)
async def m2m_remove(
session: AsyncSession,
instance: DeclarativeBase,
rel_attr: QueryableAttribute,
*related: DeclarativeBase,
) -> None:
"""Remove rows from a Many-to-Many association table without loading the ORM collection.
Args:
session: DB async session.
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
*related: One or more related instances to disassociate from ``instance``.
Raises:
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
Example:
```python
from fastapi_toolsets.db import m2m_remove, transaction
async with transaction(session):
await m2m_remove(session, post, Post.tags, tag1)
```
"""
prop, secondary = _m2m_prop(rel_attr)
if not related:
return
related_pairs = prop.secondary_synchronize_pairs
assert related_pairs is not None # set whenever secondary is set
parent_where = _parent_where(prop, instance)
if len(related_pairs) == 1:
related_col, assoc_col = related_pairs[0]
related_values = [getattr(r, cast(str, related_col.key)) for r in related]
related_where = assoc_col.in_(related_values)
else:
assoc_cols = [ac for _, ac in related_pairs]
rel_cols = [rc for rc, _ in related_pairs]
related_values_t = [
tuple(getattr(r, cast(str, rc.key)) for rc in rel_cols) for r in related
]
related_where = tuple_(*assoc_cols).in_(related_values_t)
await session.execute(delete(secondary).where(*parent_where, related_where))
async def m2m_set(
session: AsyncSession,
instance: DeclarativeBase,
rel_attr: QueryableAttribute,
*related: DeclarativeBase,
) -> None:
"""Replace the entire Many-to-Many association set atomically.
Args:
session: DB async session.
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
*related: The new complete set of related instances.
Raises:
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
Example:
```python
from fastapi_toolsets.db import m2m_set, transaction
async with transaction(session):
await m2m_set(session, post, Post.tags, tag1, tag2) # replaces all
```
"""
prop, secondary = _m2m_prop(rel_attr)
await session.execute(delete(secondary).where(*_parent_where(prop, instance)))
if related:
await m2m_add(session, instance, rel_attr, *related)
+69
View File
@@ -0,0 +1,69 @@
"""Database admin and test helpers: DDL and truncation."""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import DeclarativeBase
async def create_database(
db_name: str,
*,
server_url: str,
) -> None:
"""Create a database.
Connects to *server_url* using ``AUTOCOMMIT`` isolation and issues a
``CREATE DATABASE`` statement for *db_name*.
Args:
db_name: Name of the database to create.
server_url: URL used for server-level DDL (must point to an existing
database on the same server).
Example:
```python
from fastapi_toolsets.db.testing import create_database
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
await create_database("myapp_test", server_url=SERVER_URL)
```
"""
engine = create_async_engine(server_url, isolation_level="AUTOCOMMIT")
try:
async with engine.connect() as conn:
await conn.execute(text(f"CREATE DATABASE {db_name}"))
finally:
await engine.dispose()
async def cleanup_tables(
session: AsyncSession,
base: type[DeclarativeBase],
) -> None:
"""Truncate all tables for fast between-test cleanup.
Executes a single ``TRUNCATE … RESTART IDENTITY CASCADE`` statement
across every table in *base*'s metadata.
This is a no-op when the metadata contains no tables.
Args:
session: An active async database session.
base: SQLAlchemy DeclarativeBase class containing model metadata.
Example:
```python
@pytest.fixture
async def db_session(worker_db_url):
async with create_db_session(worker_db_url, Base) as session:
yield session
await cleanup_tables(session, Base)
```
"""
tables = base.metadata.sorted_tables
if not tables:
return
table_names = ", ".join(f'"{t.name}"' for t in tables)
await session.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))
await session.commit()
+106
View File
@@ -0,0 +1,106 @@
"""Row-watching helpers: poll a database row until it changes."""
import asyncio
from typing import Any, TypeVar
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase
from ..exceptions import NotFoundError
_M = TypeVar("_M", bound=DeclarativeBase)
async def wait_for_row_change(
session: AsyncSession,
model: type[_M],
pk_value: Any,
*,
columns: list[str] | None = None,
interval: float = 0.5,
timeout: float | None = None,
) -> _M:
"""Poll a database row until a change is detected.
Queries the row every ``interval`` seconds and returns the model instance
once a change is detected in any column (or only the specified ``columns``).
Args:
session: AsyncSession instance.
model: SQLAlchemy model class.
pk_value: Primary key value of the row to watch.
columns: Optional list of column names to watch. If None, all columns
are watched.
interval: Polling interval in seconds (default: 0.5).
timeout: Maximum time to wait in seconds. None means wait forever.
Returns:
The refreshed model instance with updated values.
Raises:
NotFoundError: If the row does not exist or is deleted during polling.
TimeoutError: If timeout expires before a change is detected.
Example:
```python
from fastapi_toolsets.db import wait_for_row_change
# Wait for any column to change
updated = await wait_for_row_change(session, User, user_id)
# Watch specific columns with a timeout
updated = await wait_for_row_change(
session, User, user_id,
columns=["status", "email"],
interval=1.0,
timeout=30.0,
)
```
"""
bind = getattr(session, "bind", None)
if bind is None:
raise TypeError(
"wait_for_row_change requires a session bound to an engine "
"(session.bind is None)"
)
watcher = AsyncSession(bind=bind)
try:
async def _reload() -> _M | None:
await watcher.rollback()
return await watcher.get(model, pk_value, populate_existing=True)
instance = await _reload()
if instance is None:
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
if columns is not None:
watch_cols = columns
else:
watch_cols = [attr.key for attr in model.__mapper__.column_attrs]
initial = {col: getattr(instance, col) for col in watch_cols}
elapsed = 0.0
while True:
await asyncio.sleep(interval)
elapsed += interval
if timeout is not None and elapsed >= timeout:
raise TimeoutError(
f"No change detected on {model.__name__} "
f"with pk={pk_value!r} within {timeout}s"
)
instance = await _reload()
if instance is None:
raise NotFoundError(
f"{model.__name__} with pk={pk_value!r} was deleted"
)
current = {col: getattr(instance, col) for col in watch_cols}
if current != initial:
return instance
finally:
await watcher.close()
+25 -33
View File
@@ -60,7 +60,7 @@ def PathDependency(
name = (
param_name
if param_name is not None
else "{}_{}".format(model.__name__.lower(), field.key)
else f"{model.__name__.lower()}_{field.key}"
)
python_type = field.type.python_type
@@ -70,22 +70,18 @@ def PathDependency(
value = kwargs[name]
return await crud.get(session, filters=[field == value])
setattr(
dependency,
"__signature__",
inspect.Signature(
parameters=[
inspect.Parameter(
name, inspect.Parameter.KEYWORD_ONLY, annotation=python_type
),
inspect.Parameter(
"session",
inspect.Parameter.KEYWORD_ONLY,
annotation=AsyncSession,
default=Depends(session_callable),
),
]
),
dependency.__signature__ = inspect.Signature( # ty:ignore[unresolved-attribute]
parameters=[
inspect.Parameter(
name, inspect.Parameter.KEYWORD_ONLY, annotation=python_type
),
inspect.Parameter(
"session",
inspect.Parameter.KEYWORD_ONLY,
annotation=AsyncSession,
default=Depends(session_callable),
),
]
)
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
@@ -134,22 +130,18 @@ def BodyDependency(
value = kwargs[body_field]
return await crud.get(session, filters=[field == value])
setattr(
dependency,
"__signature__",
inspect.Signature(
parameters=[
inspect.Parameter(
body_field, inspect.Parameter.KEYWORD_ONLY, annotation=python_type
),
inspect.Parameter(
"session",
inspect.Parameter.KEYWORD_ONLY,
annotation=AsyncSession,
default=Depends(session_callable),
),
]
),
dependency.__signature__ = inspect.Signature( # ty:ignore[unresolved-attribute]
parameters=[
inspect.Parameter(
body_field, inspect.Parameter.KEYWORD_ONLY, annotation=python_type
),
inspect.Parameter(
"session",
inspect.Parameter.KEYWORD_ONLY,
annotation=AsyncSession,
default=Depends(session_callable),
),
]
)
return cast(ModelType, Depends(cast(Callable[..., ModelType], dependency)))
+2 -2
View File
@@ -23,8 +23,6 @@ __all__ = [
"ApiException",
"ConflictError",
"ForbiddenError",
"generate_error_responses",
"init_exceptions_handlers",
"InvalidFacetFilterError",
"InvalidOrderFieldError",
"InvalidSearchColumnError",
@@ -34,4 +32,6 @@ __all__ = [
"PoolExhaustedError",
"UnauthorizedError",
"UnsupportedFacetTypeError",
"generate_error_responses",
"init_exceptions_handlers",
]
+25 -26
View File
@@ -146,35 +146,34 @@ def _patched_openapi(
for path_data in openapi_schema.get("paths", {}).values():
for operation in path_data.values():
if isinstance(operation, dict) and "responses" in operation:
if "422" in operation["responses"]:
operation["responses"]["422"] = {
"description": "Validation Error",
"content": {
"application/json": {
"examples": {
"VAL-422": {
"summary": "Validation Error",
"value": {
"data": {
"errors": [
{
"field": "field_name",
"message": "value is not valid",
"type": "value_error",
}
]
},
"status": ResponseStatus.FAIL.value,
"message": "Validation Error",
"description": "1 validation error(s) detected",
"error_code": "VAL-422",
if isinstance(operation, dict) and "422" in operation.get("responses", {}):
operation["responses"]["422"] = {
"description": "Validation Error",
"content": {
"application/json": {
"examples": {
"VAL-422": {
"summary": "Validation Error",
"value": {
"data": {
"errors": [
{
"field": "field_name",
"message": "value is not valid",
"type": "value_error",
}
]
},
}
"status": ResponseStatus.FAIL.value,
"message": "Validation Error",
"description": "1 validation error(s) detected",
"error_code": "VAL-422",
},
}
}
},
}
}
},
}
app.openapi_schema = openapi_schema
return app.openapi_schema
+24 -11
View File
@@ -1,21 +1,34 @@
"""Fixture system for seeding databases with dependency resolution."""
from .enum import LoadStrategy
from .registry import Context, FixtureRegistry
from .utils import (
get_field_by_attr,
get_obj_by_attr,
load_fixtures,
load_fixtures_by_context,
)
from typing import TYPE_CHECKING
from .enum import Context, LoadStrategy
if TYPE_CHECKING:
from .registry import FixtureRegistry
from .utils import load_fixtures, load_fixtures_by_context
__all__ = [
"Context",
"FixtureRegistry",
"LoadStrategy",
"get_field_by_attr",
"get_obj_by_attr",
"load_fixtures",
"load_fixtures_by_context",
"register_fixtures",
]
_LAZY = {
"FixtureRegistry": ".registry",
"load_fixtures": ".utils",
"load_fixtures_by_context": ".utils",
}
def __getattr__(name: str):
module_name = _LAZY.get(name)
if module_name is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
module = importlib.import_module(module_name, __name__)
return getattr(module, name)
+86 -34
View File
@@ -7,11 +7,8 @@ from typing import Any, cast
from sqlalchemy.orm import DeclarativeBase
from ..logger import get_logger
from .enum import Context
logger = get_logger()
def _normalize_contexts(
contexts: list[str | Enum] | tuple[str | Enum, ...],
@@ -20,6 +17,11 @@ def _normalize_contexts(
return [c.value if isinstance(c, Enum) else c for c in contexts]
def _context_filter_values(contexts: tuple[str | Enum, ...]) -> set[str]:
"""Normalize *contexts* for filtering, always including Context.BASE."""
return set(_normalize_contexts(contexts)) | {Context.BASE.value}
@dataclass
class Fixture:
"""A fixture definition with metadata."""
@@ -70,8 +72,6 @@ class FixtureRegistry:
@fixtures.register(contexts=[Context.TESTING])
def users():
return [User(id=2, username="tester")]
# load_fixtures_by_context(..., Context.BASE, Context.TESTING)
# → loads both User(admin) and User(tester) under the "users" name
```
"""
@@ -189,9 +189,7 @@ class FixtureRegistry:
ValueError: If the fixture has multiple context variants — use
:meth:`get_variants` in that case.
"""
if name not in self._fixtures:
raise KeyError(f"Fixture '{name}' not found")
variants = self._fixtures[name]
variants = self.get_variants(name)
if len(variants) > 1:
raise ValueError(
f"Fixture '{name}' has {len(variants)} context variants. "
@@ -205,8 +203,9 @@ class FixtureRegistry:
Args:
name: Fixture name.
*contexts: If given, only return variants whose context set
intersects with these values. Both :class:`Context` enum
values and plain strings are accepted.
intersects with these values (:class:`Context.BASE` variants
are always included). Both :class:`Context` enum values and
plain strings are accepted.
Returns:
List of matching :class:`Fixture` objects (may be empty when a
@@ -220,16 +219,89 @@ class FixtureRegistry:
variants = self._fixtures[name]
if not contexts:
return list(variants)
context_values = set(_normalize_contexts(contexts))
context_values = _context_filter_values(contexts)
return [v for v in variants if set(v.contexts) & context_values]
def get_load_variants(self, name: str, *contexts: str | Enum) -> list[Fixture]:
"""Return variants for *name* filtered by *contexts*.
Raises:
KeyError: If no fixture with *name* is registered.
"""
variants = self.get_variants(name, *contexts)
if contexts and not variants:
return self.get_variants(name)
return variants
def get_all(self) -> list[Fixture]:
"""Get all registered fixtures (all variants of all names)."""
return [f for variants in self._fixtures.values() for f in variants]
def get_dependencies(self, name: str) -> list[str]:
"""Get the union of ``depends_on`` across all variants of *name*.
Raises:
KeyError: If no fixture named *name* is registered.
"""
variants = self._fixtures.get(name)
if variants is None:
raise KeyError(f"Fixture '{name}' not found")
seen: set[str] = set()
deps: list[str] = []
for variant in variants:
for dep in variant.depends_on:
if dep not in seen:
deps.append(dep)
seen.add(dep)
return deps
def obj(self, name: str, attr_name: str, value: Any) -> DeclarativeBase:
"""Get a model instance from a registered fixture by attribute value.
Args:
name: Fixture name to look up.
attr_name: Name of the attribute to match against.
value: Value to match.
Returns:
The first model instance where the attribute matches the given value.
Raises:
KeyError: If no fixture named *name* is registered.
StopIteration: If no matching object is found.
"""
instances = (
obj for variant in self.get_variants(name) for obj in variant.func()
)
try:
return next(obj for obj in instances if getattr(obj, attr_name) == value)
except StopIteration:
raise StopIteration(
f"No object with {attr_name}={value} found in fixture '{name}'"
) from None
def field(self, name: str, attr_name: str, value: Any, *, field: str = "id") -> Any:
"""Get a single field value from a fixture object matched by an attribute.
Args:
name: Fixture name to look up.
attr_name: Name of the attribute to match against.
value: Value to match.
field: Attribute name to return from the matched object (default: ``"id"``).
Returns:
The value of ``field`` on the first matching model instance.
Raises:
KeyError: If no fixture named *name* is registered.
StopIteration: If no matching object is found.
"""
return getattr(self.obj(name, attr_name, value), field)
def get_by_context(self, *contexts: str | Enum) -> list[Fixture]:
"""Get fixtures for specific contexts."""
context_values = set(_normalize_contexts(contexts))
context_values = _context_filter_values(contexts)
return [
f
for variants in self._fixtures.values()
@@ -254,7 +326,6 @@ class FixtureRegistry:
ValueError: If circular dependency detected
"""
resolved: list[str] = []
seen: set[str] = set()
visiting: set[str] = set()
def visit(name: str) -> None:
@@ -264,25 +335,11 @@ class FixtureRegistry:
raise ValueError(f"Circular dependency detected: {name}")
visiting.add(name)
variants = self._fixtures.get(name)
if variants is None:
raise KeyError(f"Fixture '{name}' not found")
# Union of depends_on across all variants, preserving first-seen order.
seen_deps: set[str] = set()
all_deps: list[str] = []
for variant in variants:
for dep in variant.depends_on:
if dep not in seen_deps:
all_deps.append(dep)
seen_deps.add(dep)
for dep in all_deps:
for dep in self.get_dependencies(name):
visit(dep)
visiting.remove(name)
resolved.append(name)
seen.add(name)
for name in names:
visit(name)
@@ -303,9 +360,4 @@ class FixtureRegistry:
# appear multiple times if it has variants in different contexts).
names = list(dict.fromkeys(f.name for f in context_fixtures))
all_deps: set[str] = set()
for name in names:
deps = self.resolve_dependencies(name)
all_deps.update(deps)
return self.resolve_dependencies(*all_deps)
return self.resolve_dependencies(*names)
+126 -89
View File
@@ -1,17 +1,18 @@
"""Fixture loading utilities for database seeding."""
from collections.abc import Callable, Sequence
from collections.abc import Iterator
from enum import Enum
from typing import Any
from typing import Any, cast
from sqlalchemy import Table, select
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import DeclarativeBase, selectinload
from sqlalchemy.orm.interfaces import ExecutableOption, ORMOption
from ..db import get_transaction
from ..db import transaction
from ..logger import get_logger
from ..types import ModelType
from .enum import LoadStrategy
from .registry import FixtureRegistry, _normalize_contexts
@@ -93,17 +94,42 @@ def _group_by_column_set(
return list(groups.values())
def _grouped_table_dicts(
model_cls: type[DeclarativeBase], instances: list[DeclarativeBase]
) -> Iterator[
tuple[type[DeclarativeBase], list[dict[str, Any]], list[DeclarativeBase]]
]:
"""Yield (cls, group_dicts, group_instances) per table in the inheritance
chain and per column-set group, skipping empty groups.
"""
for cls in _get_table_chain(model_cls):
dicts = [_instance_to_dict_for_cls(i, cls) for i in instances]
for group_dicts, group_instances in _group_by_column_set(dicts, instances):
if group_dicts and group_dicts[0]: # pragma: no branch
yield cls, group_dicts, group_instances
async def _batch_insert(
session: AsyncSession,
model_cls: type[DeclarativeBase],
instances: list[DeclarativeBase],
) -> None:
"""INSERT all instances raises on conflict (no duplicate handling)."""
for cls in _get_table_chain(model_cls):
dicts = [_instance_to_dict_for_cls(i, cls) for i in instances]
for group_dicts, _ in _group_by_column_set(dicts, instances):
if group_dicts and group_dicts[0]: # pragma: no branch
await session.execute(pg_insert(cls).values(group_dicts))
"""INSERT all instances, raises on conflict."""
for cls, group_dicts, group_instances in _grouped_table_dicts(model_cls, instances):
table = cast(Table, cls.__table__)
missing_pk_cols = [
col for col in table.primary_key.columns if col.key not in group_dicts[0]
]
if not missing_pk_cols:
await session.execute(pg_insert(table), group_dicts)
continue
stmt = pg_insert(table).returning(
*missing_pk_cols, sort_by_parameter_order=True
)
result = await session.execute(stmt, group_dicts)
for inst, row in zip(group_instances, result):
for col, val in zip(missing_pk_cols, row):
setattr(inst, col.key, val)
async def _batch_merge(
@@ -112,30 +138,27 @@ async def _batch_merge(
instances: list[DeclarativeBase],
) -> None:
"""UPSERT: insert new rows, update existing ones with the provided values."""
for cls in _get_table_chain(model_cls):
pk_names = [col.name for col in cls.__table__.primary_key]
for cls, group_dicts, _ in _grouped_table_dicts(model_cls, instances):
table = cast(Table, cls.__table__)
pk_names = [col.name for col in table.primary_key]
pk_names_set = set(pk_names)
own_col_keys = {col.key for col in cls.__table__.columns}
own_col_keys = {col.key for col in table.columns}
non_pk_cols = [k for k in own_col_keys if k not in pk_names_set]
dicts = [_instance_to_dict_for_cls(i, cls) for i in instances]
for group_dicts, _ in _group_by_column_set(dicts, instances):
if not group_dicts or not group_dicts[0]: # pragma: no cover
continue
stmt = pg_insert(cls).values(group_dicts)
stmt = pg_insert(table).values(group_dicts)
inserted_keys = set(group_dicts[0])
update_cols = [col for col in non_pk_cols if col in inserted_keys]
inserted_keys = set(group_dicts[0])
update_cols = [col for col in non_pk_cols if col in inserted_keys]
if update_cols:
stmt = stmt.on_conflict_do_update(
index_elements=pk_names,
set_={col: stmt.excluded[col] for col in update_cols},
)
else:
stmt = stmt.on_conflict_do_nothing(index_elements=pk_names)
if update_cols:
stmt = stmt.on_conflict_do_update(
index_elements=pk_names,
set_={col: stmt.excluded[col] for col in update_cols},
)
else:
stmt = stmt.on_conflict_do_nothing(index_elements=pk_names)
await session.execute(stmt)
await session.execute(stmt)
async def _batch_skip_existing(
@@ -169,8 +192,14 @@ async def _batch_skip_existing(
loaded = list(no_pk)
if no_pk:
no_pk_dicts = [_instance_to_dict(i) for i in no_pk]
for group_dicts, _ in _group_by_column_set(no_pk_dicts, no_pk):
await session.execute(pg_insert(model_cls).values(group_dicts))
for group_dicts, group_instances in _group_by_column_set(no_pk_dicts, no_pk):
stmt = pg_insert(cast(Table, model_cls.__table__)).returning(
*mapper.primary_key, sort_by_parameter_order=True
)
result = await session.execute(stmt, group_dicts)
for inst, row in zip(group_instances, result):
for col, val in zip(mapper.primary_key, row):
setattr(inst, cast(str, col.key), val)
if with_pk_pairs:
with_pk = [i for i, _ in with_pk_pairs]
@@ -196,6 +225,65 @@ async def _batch_skip_existing(
return loaded
def _relationship_load_options(model: type[DeclarativeBase]) -> list[ExecutableOption]:
"""Build selectinload options for all direct relationships on a model."""
return [
selectinload(getattr(model, rel.key)) for rel in model.__mapper__.relationships
]
async def _reload_with_relationships(
session: AsyncSession,
instances: list[DeclarativeBase],
load_options: list[ExecutableOption],
) -> list[DeclarativeBase]:
"""Reload instances in a single bulk query with relationship eager-loading."""
model = type(instances[0])
mapper = model.__mapper__
pk_cols = mapper.primary_key
if len(pk_cols) == 1:
pk_key = cast(str, pk_cols[0].key)
pk_attr = getattr(model, pk_key)
pks = [getattr(inst, pk_key) for inst in instances]
result = await session.execute(
select(model).where(pk_attr.in_(pks)).options(*load_options)
)
by_pk = {getattr(row, pk_key): row for row in result.unique().scalars()}
return [by_pk[pk] for pk in pks]
# Composite PK: fall back to per-instance reload
reloaded: list[DeclarativeBase] = []
for instance in instances:
pk = _get_primary_key(instance)
refreshed = await session.get(
model,
pk,
options=cast(list[ORMOption], load_options),
populate_existing=True,
)
if refreshed is not None: # pragma: no branch
reloaded.append(refreshed)
return reloaded
async def _refresh_loaded(
session: AsyncSession, instances: list[DeclarativeBase]
) -> list[DeclarativeBase]:
"""Re-select freshly written rows, eager-loading relationships."""
if not instances:
return []
refreshed: list[DeclarativeBase | None] = [None] * len(instances)
for model_cls, group in _group_by_type(instances):
positions = [i for i, inst in enumerate(instances) if type(inst) is model_cls]
load_options = _relationship_load_options(model_cls)
for pos, new in zip(
positions, await _reload_with_relationships(session, group, load_options)
):
refreshed[pos] = new
return cast(list[DeclarativeBase], refreshed)
async def _load_ordered(
session: AsyncSession,
registry: FixtureRegistry,
@@ -208,14 +296,11 @@ async def _load_ordered(
for name in ordered_names:
variants = (
registry.get_variants(name, *contexts)
registry.get_load_variants(name, *contexts)
if contexts is not None
else registry.get_variants(name)
)
if contexts is not None and not variants:
variants = registry.get_variants(name)
if not variants: # pragma: no cover
results[name] = []
continue
@@ -229,7 +314,7 @@ async def _load_ordered(
model_name = type(instances[0]).__name__
loaded: list[DeclarativeBase] = []
async with get_transaction(session):
async with transaction(session):
for model_cls, group in _group_by_type(instances):
match strategy:
case LoadStrategy.INSERT:
@@ -244,8 +329,10 @@ async def _load_ordered(
case _: # pragma: no cover
pass
loaded = await _refresh_loaded(session, loaded)
results[name] = loaded
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
logger.info("Loaded fixture '%s': %d %s(s)", name, len(loaded), model_name)
return results
@@ -264,56 +351,6 @@ def _get_primary_key(instance: DeclarativeBase) -> Any | None:
return None
def get_obj_by_attr(
fixtures: Callable[[], Sequence[ModelType]], attr_name: str, value: Any
) -> ModelType:
"""Get a SQLAlchemy model instance by matching an attribute value.
Args:
fixtures: A fixture function registered via ``@registry.register``
that returns a sequence of SQLAlchemy model instances.
attr_name: Name of the attribute to match against.
value: Value to match.
Returns:
The first model instance where the attribute matches the given value.
Raises:
StopIteration: If no matching object is found in the fixture group.
"""
try:
return next(obj for obj in fixtures() if getattr(obj, attr_name) == value)
except StopIteration:
raise StopIteration(
f"No object with {attr_name}={value} found in fixture '{getattr(fixtures, '__name__', repr(fixtures))}'"
) from None
def get_field_by_attr(
fixtures: Callable[[], Sequence[ModelType]],
attr_name: str,
value: Any,
*,
field: str = "id",
) -> Any:
"""Get a single field value from a fixture object matched by an attribute.
Args:
fixtures: A fixture function registered via ``@registry.register``
that returns a sequence of SQLAlchemy model instances.
attr_name: Name of the attribute to match against.
value: Value to match.
field: Attribute name to return from the matched object (default: ``"id"``).
Returns:
The value of ``field`` on the first matching model instance.
Raises:
StopIteration: If no matching object is found in the fixture group.
"""
return getattr(get_obj_by_attr(fixtures, attr_name, value), field)
async def load_fixtures(
session: AsyncSession,
registry: FixtureRegistry,
@@ -348,8 +385,8 @@ async def load_fixtures_by_context(
Args:
session: Database session
registry: Fixture registry
*contexts: Contexts to load (e.g., ``Context.BASE``, ``Context.TESTING``,
or plain strings for custom contexts)
*contexts: Contexts to load (e.g., ``Context.TESTING``, or plain
strings for custom contexts)
strategy: How to handle existing records
Returns:
+3 -3
View File
@@ -3,19 +3,19 @@
from .columns import (
CreatedAtMixin,
TimestampMixin,
UpdatedAtMixin,
UUIDMixin,
UUIDv7Mixin,
UpdatedAtMixin,
)
from .watched import EventSession, ModelEvent, listens_for
__all__ = [
"CreatedAtMixin",
"EventSession",
"ModelEvent",
"TimestampMixin",
"UUIDMixin",
"UUIDv7Mixin",
"CreatedAtMixin",
"UpdatedAtMixin",
"TimestampMixin",
"listens_for",
]
+48 -34
View File
@@ -5,7 +5,7 @@ from collections.abc import Callable
from enum import Enum
from typing import Any
from sqlalchemy import event
from sqlalchemy import event, select, tuple_
from sqlalchemy import inspect as sa_inspect
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import set_committed_value as _sa_set_committed_value
@@ -29,14 +29,11 @@ _SESSION_DELETES = "_ft_deletes"
_SESSION_UPDATES = "_ft_updates"
_DEFERRED_STRATEGY_KEY = (("deferred", True), ("instrument", True))
_EVENT_HANDLERS: dict[tuple[type, ModelEvent], list[Callable[..., Any]]] = {}
_WATCHED_MODELS: set[type] = set()
_WATCHED_CACHE: dict[type, bool] = {}
_HANDLER_CACHE: dict[tuple[type, ModelEvent], list[Callable[..., Any]]] = {}
def _invalidate_caches() -> None:
"""Clear lookup caches after handler registration."""
_WATCHED_CACHE.clear()
_HANDLER_CACHE.clear()
@@ -56,24 +53,12 @@ def listens_for(
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
for ev in evs:
_EVENT_HANDLERS.setdefault((model_class, ev), []).append(fn)
_WATCHED_MODELS.add(model_class)
_invalidate_caches()
return fn
return decorator
def _is_watched(obj: Any) -> bool:
"""Return True if *obj*'s type (or any ancestor) has registered handlers."""
cls = type(obj)
try:
return _WATCHED_CACHE[cls]
except KeyError:
result = any(klass in _WATCHED_MODELS for klass in cls.__mro__)
_WATCHED_CACHE[cls] = result
return result
def _get_handlers(cls: type, ev: ModelEvent) -> list[Callable[..., Any]]:
"""Return registered handlers for *cls* and *ev*, walking the MRO."""
key = (cls, ev)
@@ -144,18 +129,18 @@ def _upsert_changes(
def _after_flush(session: Any, flush_context: Any) -> None:
# New objects: capture reference. Attributes will be refreshed after commit.
for obj in session.new:
if _is_watched(obj):
if _get_handlers(type(obj), ModelEvent.CREATE):
session.info.setdefault(_SESSION_CREATES, []).append(obj)
# Deleted objects: snapshot now while attributes are still loaded.
for obj in session.deleted:
if _is_watched(obj):
if _get_handlers(type(obj), ModelEvent.DELETE):
snapshot = _snapshot_column_attrs(obj)
session.info.setdefault(_SESSION_DELETES, []).append((obj, snapshot))
# Dirty objects: read old/new from SQLAlchemy attribute history.
for obj in session.dirty:
if not _is_watched(obj):
if not _get_handlers(type(obj), ModelEvent.UPDATE):
continue
watched = _get_watched_fields(type(obj))
@@ -204,10 +189,24 @@ async def _invoke_callback(
await result
async def _batch_reload(
session: AsyncSession, model: type, pk_tuples: list[tuple[Any, ...]]
) -> None:
"""Re-populate all rows of *model* identified by *pk_tuples* in one round trip."""
pk_cols = sa_inspect(model, raiseerr=True).primary_key
where = (
pk_cols[0].in_([pk[0] for pk in pk_tuples])
if len(pk_cols) == 1
else tuple_(*pk_cols).in_(pk_tuples)
)
q = select(model).where(where).execution_options(populate_existing=True)
await session.execute(q)
class EventSession(AsyncSession):
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
async def commit(self) -> None: # noqa: C901
async def commit(self) -> None:
await super().commit()
creates: list[Any] = self.info.pop(_SESSION_CREATES, [])
@@ -245,15 +244,36 @@ class EventSession(AsyncSession):
k: v for k, v in field_changes.items() if k not in create_ids
}
# Dispatch CREATE callbacks.
# Resolve reloadable state up front and group PKs by model type so
# the post-commit reload is one query per type instead of one
# session.get() per object.
create_items: list[Any] = []
update_items: list[tuple[Any, dict[str, dict[str, Any]]]] = []
pk_by_type: dict[type, list[tuple[Any, ...]]] = {}
for obj in creates:
state = sa_inspect(obj, raiseerr=False)
if state is None or state.detached or state.transient: # pragma: no cover
continue
create_items.append(obj)
pk_by_type.setdefault(type(obj), []).append(state.key[1])
for obj, changes in field_changes.values():
state = sa_inspect(obj, raiseerr=False)
if state is None or state.detached or state.transient: # pragma: no cover
continue
update_items.append((obj, changes))
pk_by_type.setdefault(type(obj), []).append(state.key[1])
for model, pk_tuples in pk_by_type.items():
try:
await _batch_reload(self, model, pk_tuples)
except Exception as exc:
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
# Dispatch CREATE callbacks.
for obj in create_items:
try:
state = sa_inspect(obj, raiseerr=False)
if (
state is None or state.detached or state.transient
): # pragma: no cover
continue
await self.refresh(obj)
for handler in _get_handlers(type(obj), ModelEvent.CREATE):
await _invoke_callback(handler, obj, ModelEvent.CREATE, None)
except Exception as exc:
@@ -270,14 +290,8 @@ class EventSession(AsyncSession):
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
# Dispatch UPDATE callbacks.
for obj, changes in field_changes.values():
for obj, changes in update_items:
try:
state = sa_inspect(obj, raiseerr=False)
if (
state is None or state.detached or state.transient
): # pragma: no cover
continue
await self.refresh(obj)
for handler in _get_handlers(type(obj), ModelEvent.UPDATE):
await _invoke_callback(handler, obj, ModelEvent.UPDATE, changes)
except Exception as exc:
+27 -109
View File
@@ -1,16 +1,14 @@
"""Pytest plugin for using FixtureRegistry fixtures in tests."""
from collections.abc import Callable, Sequence
from typing import Any, cast
from collections.abc import Sequence
from typing import Any
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase, selectinload
from sqlalchemy.orm.interfaces import ExecutableOption, ORMOption
from sqlalchemy.orm import DeclarativeBase
from ..db import get_transaction
from ..fixtures import FixtureRegistry, LoadStrategy
from ..fixtures.utils import _get_primary_key, _load_ordered, _refresh_loaded
def register_fixtures(
@@ -57,7 +55,7 @@ def register_fixtures(
# Build list of pytest fixture dependencies
pytest_deps = [session_fixture]
for dep in fixture.depends_on:
for dep in registry.get_dependencies(fixture.name):
pytest_deps.append(f"{prefix}{dep}")
# Create the fixture function
@@ -83,56 +81,38 @@ def _create_fixture_function(
fixture_name: str,
dependencies: list[str],
strategy: LoadStrategy,
) -> Callable[..., Any]:
) -> Any:
"""Create a fixture function with the correct signature.
The function signature must include all dependencies as parameters
for pytest to resolve them correctly.
for pytest (and pytest-anyio's fixture chaining) to resolve them
correctly — dynamic resolution via ``request.getfixturevalue`` deadlocks
when called from inside an already-running async fixture.
"""
# Get the fixture definition
fixture_def = registry.get(fixture_name)
# Build the function dynamically with correct parameters
# We need the session as first param, then all dependencies
async def fixture_func(**kwargs: Any) -> Sequence[DeclarativeBase]:
# Get session from kwargs (first dependency)
session: AsyncSession = kwargs[dependencies[0]]
result = (await _load_ordered(session, registry, [fixture_name], strategy))[
fixture_name
]
# Load the fixture data
instances = list(fixture_def.func())
if strategy is LoadStrategy.SKIP_EXISTING:
# _load_ordered only returns newly-inserted rows for this
# strategy (the CLI seeding contract). A test fixture should
# still hand back the full, usable set including rows that
# were already present, so top up with those.
declared = list(fixture_def.func())
result_pks = {_get_primary_key(r) for r in result}
missing = [
d
for d in declared
if (pk := _get_primary_key(d)) is not None and pk not in result_pks
]
if missing:
result = result + await _refresh_loaded(session, missing)
if not instances:
return []
loaded: list[DeclarativeBase] = []
async with get_transaction(session):
for instance in instances:
if strategy == LoadStrategy.INSERT:
session.add(instance)
loaded.append(instance)
elif strategy == LoadStrategy.MERGE:
merged = await session.merge(instance)
loaded.append(merged)
elif strategy == LoadStrategy.SKIP_EXISTING: # pragma: no branch
pk = _get_primary_key(instance)
if pk is not None:
existing = await session.get(type(instance), pk)
if existing is None:
session.add(instance)
loaded.append(instance)
else:
loaded.append(existing)
else:
session.add(instance)
loaded.append(instance)
if loaded: # pragma: no branch
load_options = _relationship_load_options(type(loaded[0]))
if load_options:
return await _reload_with_relationships(session, loaded, load_options)
return loaded
return result
# Update function signature to include dependencies
# This is needed for pytest to inject the right fixtures
@@ -146,65 +126,3 @@ def _create_fixture_function(
created_func.__doc__ = f"Load {fixture_name} fixture data."
return created_func
def _relationship_load_options(model: type[DeclarativeBase]) -> list[ExecutableOption]:
"""Build selectinload options for all direct relationships on a model."""
return [
selectinload(getattr(model, rel.key)) for rel in model.__mapper__.relationships
]
async def _reload_with_relationships(
session: AsyncSession,
instances: list[DeclarativeBase],
load_options: list[ExecutableOption],
) -> list[DeclarativeBase]:
"""Reload instances in a single bulk query with relationship eager-loading.
Uses one SELECT … WHERE pk IN (…) so selectinload can batch all relationship
queries — 1 + N_relationships round-trips regardless of how many instances
there are, instead of one session.get() per instance.
Preserves the original insertion order.
"""
model = type(instances[0])
mapper = model.__mapper__
pk_cols = mapper.primary_key
if len(pk_cols) == 1:
pk_attr = getattr(model, pk_cols[0].key)
pks = [getattr(inst, pk_cols[0].key) for inst in instances]
result = await session.execute(
select(model).where(pk_attr.in_(pks)).options(*load_options)
)
by_pk = {getattr(row, pk_cols[0].key): row for row in result.unique().scalars()}
return [by_pk[pk] for pk in pks]
# Composite PK: fall back to per-instance reload
reloaded: list[DeclarativeBase] = []
for instance in instances:
pk = _get_primary_key(instance)
refreshed = await session.get(
model,
pk,
options=cast(list[ORMOption], load_options),
populate_existing=True,
)
if refreshed is not None: # pragma: no branch
reloaded.append(refreshed)
return reloaded
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
"""Get the primary key value of a model instance."""
mapper = instance.__class__.__mapper__
pk_cols = mapper.primary_key
if len(pk_cols) == 1:
return getattr(instance, pk_cols[0].name, None)
pk_values = tuple(getattr(instance, col.name, None) for col in pk_cols)
if all(v is not None for v in pk_values):
return pk_values
return None
+1 -1
View File
@@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import (
)
from sqlalchemy.orm import DeclarativeBase
from ..db import cleanup_tables, create_database
from ..db.testing import cleanup_tables, create_database
from ..models.watched import EventSession
+4 -4
View File
@@ -2,7 +2,7 @@
import math
from enum import Enum
from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar, Union
from typing import Annotated, Any, ClassVar, Generic, Literal, TypeVar
from pydantic import BaseModel, ConfigDict, Field, computed_field
@@ -10,11 +10,11 @@ from .types import DataT
__all__ = [
"ApiError",
"CursorPagination",
"CursorPaginatedResponse",
"CursorPagination",
"ErrorResponse",
"OffsetPagination",
"OffsetPaginatedResponse",
"OffsetPagination",
"PaginatedResponse",
"PaginationType",
"PydanticBase",
@@ -174,7 +174,7 @@ class PaginatedResponse(BaseResponse, Generic[DataT]):
cached = cls._discriminated_union_cache.get(item)
if cached is None:
cached = Annotated[
Union[CursorPaginatedResponse[item], OffsetPaginatedResponse[item]], # ty:ignore[invalid-type-form]
CursorPaginatedResponse[item] | OffsetPaginatedResponse[item], # ty:ignore[invalid-type-form]
Field(discriminator="pagination_type"),
]
cls._discriminated_union_cache[item] = cached
-26
View File
@@ -1,26 +0,0 @@
"""Authentication helpers for FastAPI using Security()."""
from .abc import AuthSource
from .oauth import (
oauth_build_authorization_redirect,
oauth_decode_state,
oauth_encode_state,
oauth_fetch_userinfo,
oauth_generate_state_token,
oauth_resolve_provider_urls,
)
from .sources import APIKeyHeaderAuth, BearerTokenAuth, CookieAuth, MultiAuth
__all__ = [
"APIKeyHeaderAuth",
"AuthSource",
"BearerTokenAuth",
"CookieAuth",
"MultiAuth",
"oauth_build_authorization_redirect",
"oauth_decode_state",
"oauth_encode_state",
"oauth_fetch_userinfo",
"oauth_generate_state_token",
"oauth_resolve_provider_urls",
]
-55
View File
@@ -1,55 +0,0 @@
"""Abstract base class for authentication sources."""
import functools
import inspect
from abc import ABC, abstractmethod
from typing import Any, Callable
from fastapi import Request
from fastapi.security import SecurityScopes
from fastapi_toolsets.exceptions import UnauthorizedError
def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Wrap *fn* so it can always be awaited, caching the coroutine check at init time."""
if inspect.iscoroutinefunction(fn):
return fn
@functools.wraps(fn)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
return fn(*args, **kwargs)
return wrapper
class AuthSource(ABC):
"""Abstract base class for authentication sources."""
def __init__(self) -> None:
"""Set up the default FastAPI dependency signature."""
source = self
async def _call(
request: Request,
security_scopes: SecurityScopes, # noqa: ARG001
) -> Any:
credential = await source.extract(request)
if credential is None:
raise UnauthorizedError()
return await source.authenticate(credential)
self._call_fn: Callable[..., Any] = _call
self.__signature__ = inspect.signature(_call)
@abstractmethod
async def extract(self, request: Request) -> str | None:
"""Extract the raw credential from the request without validating."""
@abstractmethod
async def authenticate(self, credential: str) -> Any:
"""Validate a credential and return the authenticated identity."""
async def __call__(self, **kwargs: Any) -> Any:
"""FastAPI dependency dispatch."""
return await self._call_fn(**kwargs)
-197
View File
@@ -1,197 +0,0 @@
"""OAuth 2.0 / OIDC helper utilities."""
import base64
import binascii
import hmac
import json
import secrets
from typing import Any
from urllib.parse import urlencode
import httpx
from async_lru import alru_cache
from fastapi.responses import RedirectResponse
@alru_cache(maxsize=32)
async def oauth_resolve_provider_urls(
discovery_url: str,
) -> tuple[str, str, str | None]:
"""Fetch the OIDC discovery document and return endpoint URLs.
Args:
discovery_url: URL of the provider's ``/.well-known/openid-configuration``.
Returns:
A ``(authorization_url, token_url, userinfo_url)`` tuple.
*userinfo_url* is ``None`` when the provider does not advertise one.
"""
async with httpx.AsyncClient() as client:
resp = await client.get(discovery_url)
resp.raise_for_status()
cfg = resp.json()
return (
cfg["authorization_endpoint"],
cfg["token_endpoint"],
cfg.get("userinfo_endpoint"),
)
async def oauth_fetch_userinfo(
*,
token_url: str,
userinfo_url: str,
code: str,
client_id: str,
client_secret: str,
redirect_uri: str,
required_scopes: str | None = None,
) -> dict[str, Any]:
"""Exchange an authorization code for tokens and return the userinfo payload.
Args:
token_url: Provider's token endpoint.
userinfo_url: Provider's userinfo endpoint.
code: Authorization code received from the provider's callback.
client_id: OAuth application client ID.
client_secret: OAuth application client secret.
redirect_uri: Redirect URI that was used in the authorization request.
required_scopes: Space-separated scopes that must be present in the token
response ``scope`` field (RFC 6749 §3.3). Raises ``ValueError`` if
the provider granted fewer scopes than requested.
Returns:
The JSON payload returned by the userinfo endpoint as a plain ``dict``.
Raises:
ValueError: If the provider granted a different token type than ``bearer``
or did not grant all ``required_scopes``.
"""
async with httpx.AsyncClient() as client:
token_resp = await client.post(
token_url,
data={
"grant_type": "authorization_code",
"code": code,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
},
headers={"Accept": "application/json"},
)
token_resp.raise_for_status()
token_data = token_resp.json()
if token_data.get("token_type", "bearer").lower() != "bearer":
raise ValueError(
f"unsupported token_type: {token_data.get('token_type')!r}"
)
if required_scopes is not None:
granted = set(token_data.get("scope", "").split())
missing = set(required_scopes.split()) - granted
if missing:
raise ValueError(f"provider did not grant required scopes: {missing}")
access_token = token_data["access_token"]
userinfo_resp = await client.get(
userinfo_url,
headers={"Authorization": f"Bearer {access_token}"},
)
userinfo_resp.raise_for_status()
return userinfo_resp.json()
def oauth_generate_state_token() -> str:
"""Generate a cryptographically random CSRF token for the OAuth ``state`` parameter."""
return secrets.token_urlsafe(32)
def oauth_build_authorization_redirect(
authorization_url: str,
*,
client_id: str,
scopes: str,
redirect_uri: str,
destination: str,
state_token: str,
) -> RedirectResponse:
"""Return an OAuth 2.0 authorization ``RedirectResponse``.
Args:
authorization_url: Provider's authorization endpoint.
client_id: OAuth application client ID.
scopes: Space-separated list of requested scopes.
redirect_uri: URI the provider should redirect back to after authorization.
destination: URL the user should be sent to after the full OAuth flow
completes (embedded in ``state``).
state_token: CSRF token generated by :func:`oauth_generate_state_token`.
Must be stored server-side (session or signed cookie) and verified via
:func:`oauth_decode_state` on the callback endpoint (RFC 6749 §10.12).
Returns:
A :class:`~fastapi.responses.RedirectResponse` to the provider's
authorization page.
"""
params = urlencode(
{
"client_id": client_id,
"response_type": "code",
"scope": scopes,
"redirect_uri": redirect_uri,
"state": oauth_encode_state(destination, state_token),
}
)
return RedirectResponse(f"{authorization_url}?{params}")
def oauth_encode_state(url: str, state_token: str) -> str:
"""Encode a destination URL and CSRF token into an OAuth ``state`` parameter.
Args:
url: Post-login destination URL.
state_token: CSRF token from :func:`oauth_generate_state_token`.
"""
payload = json.dumps({"n": state_token, "d": url}, separators=(",", ":"))
return base64.urlsafe_b64encode(payload.encode()).decode()
def oauth_decode_state(
state: str | None, *, expected_state_token: str, fallback: str
) -> str:
"""Decode and CSRF-verify an OAuth ``state`` parameter.
Uses a constant-time comparison for the CSRF token to prevent timing attacks.
Args:
state: Raw ``state`` query parameter from the provider's callback.
expected_state_token: The token stored before the authorization redirect.
If it does not match the decoded value, ``fallback`` is returned.
fallback: URL to return when ``state`` is absent, malformed, or fails
CSRF verification.
Returns:
The destination URL embedded in ``state``, or ``fallback``.
Important:
**Single-use**: delete the stored token from the session immediately
after calling this function — whether it matched or not — so that a
captured callback URL cannot be replayed.
**Open-redirect**: validate the returned URL against a known-good
origin or relative-path allowlist before issuing the final redirect.
Do not forward arbitrary URLs to ``RedirectResponse``.
"""
if not state or state == "null": # "null" guards against JS JSON.stringify(null)
return fallback
try:
padded = state + "=" * (-len(state) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded).decode("utf-8"))
if not isinstance(payload, dict) or not hmac.compare_digest(
payload.get("n", "").encode(), expected_state_token.encode()
):
return fallback
return str(payload["d"])
except (UnicodeDecodeError, ValueError, binascii.Error, KeyError):
return fallback
@@ -1,8 +0,0 @@
"""Built-in authentication source implementations."""
from .header import APIKeyHeaderAuth
from .bearer import BearerTokenAuth
from .cookie import CookieAuth
from .multi import MultiAuth
__all__ = ["APIKeyHeaderAuth", "BearerTokenAuth", "CookieAuth", "MultiAuth"]
@@ -1,120 +0,0 @@
"""Bearer token authentication source."""
import inspect
import secrets
from typing import Annotated, Any, Callable
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer, SecurityScopes
from fastapi_toolsets.exceptions import UnauthorizedError
from ..abc import AuthSource, _ensure_async
class BearerTokenAuth(AuthSource):
"""Bearer token authentication source.
Wraps :class:`fastapi.security.HTTPBearer` for OpenAPI documentation.
The validator is called as ``await validator(credential, **kwargs)``
where ``kwargs`` are the extra keyword arguments provided at instantiation.
Args:
validator: Sync or async callable that receives the credential and any
extra keyword arguments, and returns the authenticated identity
(e.g. a ``User`` model). Should raise
:class:`~fastapi_toolsets.exceptions.UnauthorizedError` on failure.
prefix: Optional token prefix (e.g. ``"user_"``). If set, only tokens
whose value starts with this prefix are matched. The prefix is
**kept** in the value passed to the validator — store and compare
tokens with their prefix included. Use :meth:`generate_token` to
create correctly-prefixed tokens. This enables multiple
``BearerTokenAuth`` instances in the same app (e.g. ``"user_"``
for user tokens, ``"org_"`` for org tokens).
**kwargs: Extra keyword arguments forwarded to the validator on every
call (e.g. ``role=Role.ADMIN``).
"""
def __init__(
self,
validator: Callable[..., Any],
*,
prefix: str | None = None,
**kwargs: Any,
) -> None:
self._validator = _ensure_async(validator)
self._prefix = prefix
self._kwargs = kwargs
self._scheme = HTTPBearer(auto_error=False)
async def _call(
security_scopes: SecurityScopes, # noqa: ARG001
credentials: Annotated[
HTTPAuthorizationCredentials | None, Depends(self._scheme)
] = None,
) -> Any:
if credentials is None:
raise UnauthorizedError()
return await self._validate(credentials.credentials)
self._call_fn = _call
self.__signature__ = inspect.signature(_call)
async def _validate(self, token: str) -> Any:
"""Check prefix and call the validator."""
if self._prefix is not None and not token.startswith(self._prefix):
raise UnauthorizedError()
return await self._validator(token, **self._kwargs)
async def extract(self, request: Request) -> str | None:
"""Extract the raw credential from the request without validating.
Returns ``None`` if no ``Authorization: Bearer`` header is present,
the token is empty, or the token does not match the configured prefix.
The prefix is included in the returned value.
"""
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return None
token = auth[7:]
if not token:
return None
if self._prefix is not None and not token.startswith(self._prefix):
return None
return token
async def authenticate(self, credential: str) -> Any:
"""Validate a credential and return the identity.
Calls ``await validator(credential, **kwargs)`` where ``kwargs`` are
the extra keyword arguments provided at instantiation.
"""
return await self._validate(credential)
def require(self, **kwargs: Any) -> "BearerTokenAuth":
"""Return a new instance with additional (or overriding) validator kwargs."""
return BearerTokenAuth(
self._validator,
prefix=self._prefix,
**{**self._kwargs, **kwargs},
)
def generate_token(self, nbytes: int = 32) -> str:
"""Generate a secure random token for this auth source.
Returns a URL-safe random token. If a prefix is configured it is
prepended — the returned value is what you store in your database
and return to the client as-is.
Args:
nbytes: Number of random bytes before base64 encoding. The
resulting string is ``ceil(nbytes * 4 / 3)`` characters
(43 chars for the default 32 bytes). Defaults to 32.
Returns:
A ready-to-use token string (e.g. ``"user_Xk3..."``).
"""
token = secrets.token_urlsafe(nbytes)
if self._prefix is not None:
return f"{self._prefix}{token}"
return token
@@ -1,148 +0,0 @@
"""Cookie-based authentication source."""
import base64
import hashlib
import hmac
import inspect
import json
import time
from typing import Annotated, Any, Callable
from fastapi import Depends, Request, Response
from fastapi.security import APIKeyCookie, SecurityScopes
from fastapi_toolsets.exceptions import UnauthorizedError
from ..abc import AuthSource, _ensure_async
class CookieAuth(AuthSource):
"""Cookie-based authentication source.
Wraps :class:`fastapi.security.APIKeyCookie` for OpenAPI documentation.
Optionally signs the cookie with HMAC-SHA256 to provide stateless, tamper-
proof sessions without any database entry.
Args:
name: Cookie name.
validator: Sync or async callable that receives the cookie value
(plain, after signature verification when ``secret_key`` is set)
and any extra keyword arguments, and returns the authenticated
identity.
secret_key: When provided, the cookie is HMAC-SHA256 signed.
:meth:`set_cookie` embeds an expiry and signs the payload;
:meth:`extract` verifies the signature and expiry before handing
the plain value to the validator. When ``None`` (default), the raw
cookie value is passed to the validator as-is.
ttl: Cookie lifetime in seconds (default 24 h). Only used when
``secret_key`` is set.
secure: Set the ``Secure`` flag on the cookie so it is only transmitted
over HTTPS (default ``True``). Set to ``False`` only in local
development environments where HTTPS is unavailable.
**kwargs: Extra keyword arguments forwarded to the validator on every
call (e.g. ``role=Role.ADMIN``).
"""
def __init__(
self,
name: str,
validator: Callable[..., Any],
*,
secret_key: str | None = None,
ttl: int = 86400,
secure: bool = True,
**kwargs: Any,
) -> None:
self._name = name
self._validator = _ensure_async(validator)
self._secret_key = secret_key
self._ttl = ttl
self._secure = secure
self._kwargs = kwargs
self._scheme = APIKeyCookie(name=name, auto_error=False)
async def _call(
security_scopes: SecurityScopes, # noqa: ARG001
value: Annotated[str | None, Depends(self._scheme)] = None,
) -> Any:
if value is None:
raise UnauthorizedError()
plain = self._verify(value)
return await self._validator(plain, **self._kwargs)
self._call_fn = _call
self.__signature__ = inspect.signature(_call)
def _hmac(self, data: str) -> str:
if self._secret_key is None:
raise RuntimeError("_hmac called without secret_key configured")
return hmac.new(
self._secret_key.encode(), data.encode(), hashlib.sha256
).hexdigest()
def _sign(self, value: str) -> str:
data = base64.urlsafe_b64encode(
json.dumps({"v": value, "exp": int(time.time()) + self._ttl}).encode()
).decode()
return f"{data}.{self._hmac(data)}"
def _verify(self, cookie_value: str) -> str:
"""Return the plain value, verifying HMAC + expiry when signed."""
if not self._secret_key:
return cookie_value
try:
data, sig = cookie_value.rsplit(".", 1)
except ValueError:
raise UnauthorizedError()
if not hmac.compare_digest(self._hmac(data), sig):
raise UnauthorizedError()
try:
payload = json.loads(base64.urlsafe_b64decode(data))
value: str = payload["v"]
exp: int = payload["exp"]
except Exception:
raise UnauthorizedError()
if exp < int(time.time()):
raise UnauthorizedError()
return value
async def extract(self, request: Request) -> str | None:
return request.cookies.get(self._name)
async def authenticate(self, credential: str) -> Any:
plain = self._verify(credential)
return await self._validator(plain, **self._kwargs)
def require(self, **kwargs: Any) -> "CookieAuth":
"""Return a new instance with additional (or overriding) validator kwargs."""
return CookieAuth(
self._name,
self._validator,
secret_key=self._secret_key,
ttl=self._ttl,
secure=self._secure,
**{**self._kwargs, **kwargs},
)
def set_cookie(self, response: Response, value: str) -> None:
"""Attach the cookie to *response*, signing it when ``secret_key`` is set."""
cookie_value = self._sign(value) if self._secret_key else value
response.set_cookie(
self._name,
cookie_value,
httponly=True,
samesite="lax",
secure=self._secure,
max_age=self._ttl,
)
def delete_cookie(self, response: Response) -> None:
"""Clear the session cookie (logout)."""
response.delete_cookie(
self._name, httponly=True, samesite="lax", secure=self._secure
)
@@ -1,67 +0,0 @@
"""API key header authentication source."""
import inspect
from typing import Annotated, Any, Callable
from fastapi import Depends, Request
from fastapi.security import APIKeyHeader, SecurityScopes
from fastapi_toolsets.exceptions import UnauthorizedError
from ..abc import AuthSource, _ensure_async
class APIKeyHeaderAuth(AuthSource):
"""API key header authentication source.
Wraps :class:`fastapi.security.APIKeyHeader` for OpenAPI documentation.
The validator is called as ``await validator(api_key, **kwargs)``
where ``kwargs`` are the extra keyword arguments provided at instantiation.
Args:
name: HTTP header name that carries the API key (e.g. ``"X-API-Key"``).
validator: Sync or async callable that receives the API key and any
extra keyword arguments, and returns the authenticated identity.
Should raise :class:`~fastapi_toolsets.exceptions.UnauthorizedError`
on failure.
**kwargs: Extra keyword arguments forwarded to the validator on every
call (e.g. ``role=Role.ADMIN``).
"""
def __init__(
self,
name: str,
validator: Callable[..., Any],
**kwargs: Any,
) -> None:
self._name = name
self._validator = _ensure_async(validator)
self._kwargs = kwargs
self._scheme = APIKeyHeader(name=name, auto_error=False)
async def _call(
security_scopes: SecurityScopes, # noqa: ARG001
api_key: Annotated[str | None, Depends(self._scheme)] = None,
) -> Any:
if api_key is None:
raise UnauthorizedError()
return await self._validator(api_key, **self._kwargs)
self._call_fn = _call
self.__signature__ = inspect.signature(_call)
async def extract(self, request: Request) -> str | None:
"""Extract the API key from the configured header."""
return request.headers.get(self._name) or None
async def authenticate(self, credential: str) -> Any:
"""Validate a credential and return the identity."""
return await self._validator(credential, **self._kwargs)
def require(self, **kwargs: Any) -> "APIKeyHeaderAuth":
"""Return a new instance with additional (or overriding) validator kwargs."""
return APIKeyHeaderAuth(
self._name,
self._validator,
**{**self._kwargs, **kwargs},
)
@@ -1,71 +0,0 @@
"""MultiAuth: combine multiple authentication sources into a single callable."""
import inspect
from typing import Any, cast
from fastapi import Request
from fastapi.security import SecurityScopes
from fastapi_toolsets.exceptions import UnauthorizedError
from ..abc import AuthSource
class MultiAuth:
"""Combine multiple authentication sources into a single callable.
Args:
*sources: Auth source instances to try in order.
"""
def __init__(self, *sources: AuthSource) -> None:
self._sources = sources
async def _call(
request: Request,
security_scopes: SecurityScopes, # noqa: ARG001
**kwargs: Any, # noqa: ARG001 — absorbs scheme values injected by FastAPI
) -> Any:
for source in self._sources:
credential = await source.extract(request)
if credential is not None:
return await source.authenticate(credential)
raise UnauthorizedError()
self._call_fn = _call
# Build a merged signature that includes the security-scheme Depends()
# parameters from every source so FastAPI registers them in OpenAPI docs.
seen: set[str] = {"request", "security_scopes"}
merged: list[inspect.Parameter] = [
inspect.Parameter(
"request",
inspect.Parameter.POSITIONAL_OR_KEYWORD,
annotation=Request,
),
inspect.Parameter(
"security_scopes",
inspect.Parameter.POSITIONAL_OR_KEYWORD,
annotation=SecurityScopes,
),
]
for i, source in enumerate(sources):
for name, param in inspect.signature(source).parameters.items():
if name in seen:
continue
merged.append(param.replace(name=f"_s{i}_{name}"))
seen.add(name)
self.__signature__ = inspect.Signature(merged, return_annotation=Any)
async def __call__(self, **kwargs: Any) -> Any:
return await self._call_fn(**kwargs)
def require(self, **kwargs: Any) -> "MultiAuth":
"""Return a new :class:`MultiAuth` with kwargs forwarded to each source."""
new_sources = tuple(
cast(Any, source).require(**kwargs)
if hasattr(source, "require")
else source
for source in self._sources
)
return MultiAuth(*new_sources)
+29 -7
View File
@@ -1,27 +1,28 @@
"""Shared pytest fixtures for fastapi-utils tests."""
import datetime
import decimal
import os
import uuid
from enum import Enum
import pytest
from pydantic import BaseModel
import datetime
import decimal
from sqlalchemy import (
JSON,
Column,
Date,
DateTime,
Enum as SAEnum,
ForeignKey,
Integer,
JSON,
Numeric,
String,
Table,
Uuid,
)
from sqlalchemy import (
Enum as SAEnum,
)
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -38,8 +39,6 @@ DATABASE_URL = os.getenv(
class Base(DeclarativeBase):
"""Base class for test models."""
pass
class Role(Base):
"""Test role model."""
@@ -476,3 +475,26 @@ async def db_session(engine):
# Drop tables after test
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture(scope="function")
async def db_session_expire_on_commit(engine):
"""Session with expire_on_commit=True (the SQLAlchemy default).
Attributes read off an instance after commit are expired and trigger an
implicit (sync) refresh under this setting — which fails under asyncio
with MissingGreenlet. The other ``db_session`` fixture uses
``expire_on_commit=False`` and would not catch that class of bug.
"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
session_factory = async_sessionmaker(engine, expire_on_commit=True)
session = session_factory()
try:
yield session
finally:
await session.close()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
+26 -2
View File
@@ -277,6 +277,10 @@ class TestFixturesCli:
'@registry.register(depends_on=["roles"], contexts=[Context.TESTING])\n'
"def users():\n"
' return [{"id": 1, "name": "alice", "role_id": 1}]\n'
"\n"
'@registry.register(contexts=["staging"])\n'
"def staging_only():\n"
' return [{"id": 3, "name": "staging-user"}]\n'
)
# Create db module
@@ -316,7 +320,7 @@ class TestFixturesCli:
assert result.exit_code == 0
assert "roles" in result.output
assert "users" in result.output
assert "Total: 2 fixture(s)" in result.output
assert "Total: 3 fixture(s)" in result.output
def test_fixtures_list_with_context(self, cli_env):
"""fixtures list --context filters by context."""
@@ -338,6 +342,27 @@ class TestFixturesCli:
assert "roles" in result.output
assert "[Dry run - no changes made]" in result.output
def test_fixtures_list_with_custom_context(self, cli_env):
"""fixtures list --context accepts contexts outside the Context enum, and
always includes base fixtures alongside the requested context."""
tmp_path, cli = cli_env
result = runner.invoke(cli, ["fixtures", "list", "--context", "staging"])
assert result.exit_code == 0
assert "staging_only" in result.output
assert "roles" in result.output
assert "Total: 2 fixture(s)" in result.output
def test_fixtures_load_custom_context_dry_run(self, cli_env):
"""fixtures load accepts a custom context argument outside the Context enum,
and always loads base fixtures alongside it."""
tmp_path, cli = cli_env
result = runner.invoke(cli, ["fixtures", "load", "staging", "--dry-run"])
assert result.exit_code == 0
assert "staging_only" in result.output
assert "roles" in result.output
def test_fixtures_load_invalid_strategy(self, cli_env):
"""fixtures load with invalid strategy shows error."""
tmp_path, cli = cli_env
@@ -529,7 +554,6 @@ class TestAsyncCommand:
@async_command
async def async_func() -> None:
"""This is a docstring."""
pass
assert async_func.__doc__ == """This is a docstring."""
+49
View File
@@ -417,6 +417,55 @@ class TestDefaultLoadOptionsIntegration:
assert updated.role is not None
assert updated.role.name == "admin"
@pytest.mark.anyio
async def test_default_load_options_applied_to_create_expire_on_commit(
self, db_session_expire_on_commit: AsyncSession
):
"""create()'s reload uses captured PK values, not an expired instance attribute.
Regression test for MissingGreenlet: reading a PK off `db_model` after
commit under expire_on_commit=True (the SQLAlchemy default) would
trigger an implicit sync refresh, which fails under asyncio.
"""
UserWithDefaultLoad = CrudFactory(
User, default_load_options=[selectinload(User.role)]
)
role = await RoleCrud.create(
db_session_expire_on_commit, RoleCreate(name="admin")
)
user = await UserWithDefaultLoad.create(
db_session_expire_on_commit,
UserCreate(username="alice", email="alice@test.com", role_id=role.id),
)
assert user.role is not None
assert user.role.name == "admin"
@pytest.mark.anyio
async def test_default_load_options_applied_to_update_expire_on_commit(
self, db_session_expire_on_commit: AsyncSession
):
"""update()'s reload uses captured PK values, not an expired instance attribute.
Regression test for MissingGreenlet under expire_on_commit=True.
"""
UserWithDefaultLoad = CrudFactory(
User, default_load_options=[selectinload(User.role)]
)
role = await RoleCrud.create(
db_session_expire_on_commit, RoleCreate(name="admin")
)
user = await UserCrud.create(
db_session_expire_on_commit,
UserCreate(username="alice", email="alice@test.com"),
)
updated = await UserWithDefaultLoad.update(
db_session_expire_on_commit,
UserUpdate(role_id=role.id),
filters=[User.id == user.id],
)
assert updated.role is not None
assert updated.role.name == "admin"
@pytest.mark.anyio
async def test_load_options_overrides_default_load_options(
self, db_session: AsyncSession
+154 -10
View File
@@ -372,6 +372,22 @@ class TestBuildSearchFilters:
assert len(joins) == 1
def test_skips_cast_on_string_column(self):
"""String columns are filtered directly, without a CAST (keeps pg_trgm indexable)."""
from fastapi_toolsets.crud.search import build_search_filters
filters, _ = build_search_filters(User, "john", search_fields=[User.username])
assert "CAST" not in str(filters[0])
def test_casts_non_string_column(self):
"""Non-string columns (e.g. UUID) still get cast to String so ilike works."""
from fastapi_toolsets.crud.search import build_search_filters
filters, _ = build_search_filters(User, "123", search_fields=[User.id])
assert "CAST" in str(filters[0])
class TestSearchConfig:
"""Tests for SearchConfig options."""
@@ -531,6 +547,15 @@ class TestFacetsNotSet:
assert result.filter_attributes is None
@pytest.mark.anyio
async def test_build_facets_empty_field_list(self, db_session: AsyncSession):
"""build_facets([]) is a no-op that returns {} without querying — the escape hatch."""
from fastapi_toolsets.crud.search import build_facets
result = await build_facets(db_session, User, [])
assert result == {}
class TestFacetsDirectColumn:
"""Facets on direct model columns."""
@@ -606,6 +631,91 @@ class TestFacetsDirectColumn:
assert "username" not in result.filter_attributes
class TestFacetsMixedTypes:
"""Facet values keep their native Python type through the batched query."""
@pytest.mark.anyio
async def test_enum_and_integer_facets_preserve_types(
self, db_session: AsyncSession
):
"""Enum facets return member names (not raw DB values); Integer facets return ints."""
OrderMixedCrud = CrudFactory(
Order, facet_fields=[Order.status, Order.priority, Order.color]
)
await OrderCrud.create(
db_session,
OrderCreate(
name="order-1", status=OrderStatus.PENDING, priority=1, color=Color.RED
),
)
await OrderCrud.create(
db_session,
OrderCreate(
name="order-2", status=OrderStatus.SHIPPED, priority=3, color=Color.BLUE
),
)
result = await OrderMixedCrud.offset_paginate(db_session, schema=OrderRead)
assert result.filter_attributes is not None
assert set(result.filter_attributes["status"]) == {"PENDING", "SHIPPED"}
assert all(isinstance(v, str) for v in result.filter_attributes["status"])
assert set(result.filter_attributes["priority"]) == {1, 3}
assert all(isinstance(v, int) for v in result.filter_attributes["priority"])
assert set(result.filter_attributes["color"]) == {"RED", "BLUE"}
@pytest.mark.anyio
async def test_bool_facet_keeps_python_bool(self, db_session: AsyncSession):
"""A Boolean facet returns Python bool values, not stringified 'true'/'false'."""
UserBoolFacetCrud = CrudFactory(User, facet_fields=[User.is_active])
await UserCrud.create(
db_session, UserCreate(username="alice", email="a@test.com", is_active=True)
)
await UserCrud.create(
db_session, UserCreate(username="bob", email="b@test.com", is_active=False)
)
result = await UserBoolFacetCrud.offset_paginate(db_session, schema=UserRead)
assert result.filter_attributes is not None
assert set(result.filter_attributes["is_active"]) == {True, False}
assert all(isinstance(v, bool) for v in result.filter_attributes["is_active"])
class TestIncludeFacets:
"""include_facets=False skips facet queries entirely."""
@pytest.mark.anyio
async def test_offset_paginate_include_facets_false(self, db_session: AsyncSession):
"""filter_attributes is None when include_facets=False, even with facet_fields set."""
UserFacetCrud = CrudFactory(User, facet_fields=[User.username])
await UserCrud.create(
db_session, UserCreate(username="alice", email="a@test.com")
)
result = await UserFacetCrud.offset_paginate(
db_session, include_facets=False, schema=UserRead
)
assert result.filter_attributes is None
@pytest.mark.anyio
async def test_cursor_paginate_include_facets_false(self, db_session: AsyncSession):
"""filter_attributes is None when include_facets=False for cursor_paginate."""
UserFacetCursorCrud = CrudFactory(
User, cursor_column=User.id, facet_fields=[User.username]
)
await UserCrud.create(
db_session, UserCreate(username="alice", email="a@test.com")
)
result = await UserFacetCursorCrud.cursor_paginate(
db_session, include_facets=False, schema=UserRead
)
assert result.filter_attributes is None
class TestFacetsRespectFilters:
"""Facets reflect the active filter conditions."""
@@ -630,6 +740,28 @@ class TestFacetsRespectFilters:
assert result.filter_attributes is not None
assert result.filter_attributes["username"] == ["alice"]
@pytest.mark.anyio
async def test_array_facet_respects_unrelated_filter(
self, db_session: AsyncSession
):
"""An ARRAY facet is scoped by a filter on a different column (not self-collapse)."""
ArticleFacetCrud = CrudFactory(Article, facet_fields=[Article.labels])
await ArticleCrud.create(
db_session, ArticleCreate(title="Post 1", labels=["python", "fastapi"])
)
await ArticleCrud.create(
db_session, ArticleCreate(title="Post 2", labels=["rust", "axum"])
)
result = await ArticleFacetCrud.offset_paginate(
db_session,
filters=[Article.title == "Post 1"],
schema=ArticleRead,
)
assert result.filter_attributes is not None
assert result.filter_attributes["labels"] == ["fastapi", "python"]
class TestFacetsRelationship:
"""Facets on relationship columns via tuple syntax."""
@@ -785,8 +917,8 @@ class TestFilterBy:
assert len(result.data) == 1
assert result.data[0].username == "alice"
# facet also scoped to the filter
assert result.filter_attributes == {"username": ["alice"]}
# facet excludes its own filter_by condition, so it isn't collapsed
assert result.filter_attributes == {"username": ["alice", "bob"]}
@pytest.mark.anyio
async def test_list_filter_produces_in_clause(self, db_session: AsyncSession):
@@ -924,7 +1056,7 @@ class TestFilterBy:
assert len(result.data) == 1
assert result.data[0].username == "alice"
assert result.filter_attributes == {"username": ["alice"]}
assert result.filter_attributes == {"username": ["alice", "bob"]}
@pytest.mark.anyio
async def test_basemodel_filter_by_offset_paginate(self, db_session: AsyncSession):
@@ -1085,8 +1217,10 @@ class TestFilterBy:
assert result.pagination.total_count == 2
titles = {a.title for a in result.data}
assert titles == {"Post 1", "Post 3"}
# facet returns individual unnested values, not whole arrays
assert result.filter_attributes == {"labels": ["django", "fastapi", "python"]}
# facet excludes its own filter_by condition (not collapsed to matching rows)
assert result.filter_attributes == {
"labels": ["axum", "django", "fastapi", "python", "rust"]
}
@pytest.mark.anyio
async def test_array_overlap_list_value(self, db_session: AsyncSession):
@@ -2127,7 +2261,7 @@ class TestOffsetPaginateParamsSchema:
def test_dependency_name_includes_model_name(self):
"""Dependency function is named after the model."""
dep = RoleCrud.offset_paginate_params(search=False, filter=False, order=False)
assert getattr(dep, "__name__") == "RoleOffsetPaginateParams"
assert dep.__name__ == "RoleOffsetPaginateParams" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
def test_default_page_size_reflected_in_items_per_page_default(self):
"""default_page_size is used as the default for items_per_page."""
@@ -2179,7 +2313,12 @@ class TestOffsetPaginateParamsSchema:
include_total=False, search=False, filter=False, order=False
)
result = await dep(page=2, items_per_page=10)
assert result == {"page": 2, "items_per_page": 10, "include_total": False}
assert result == {
"page": 2,
"items_per_page": 10,
"include_total": False,
"include_facets": True,
}
@pytest.mark.anyio
async def test_integrates_with_offset_paginate(self, db_session: AsyncSession):
@@ -2252,7 +2391,7 @@ class TestCursorPaginateParamsSchema:
dep = RoleCursorCrud.cursor_paginate_params(
search=False, filter=False, order=False
)
assert getattr(dep, "__name__") == "RoleCursorPaginateParams"
assert dep.__name__ == "RoleCursorPaginateParams" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
def test_default_page_size_reflected_in_items_per_page_default(self):
"""default_page_size is used as the default for items_per_page."""
@@ -2290,7 +2429,11 @@ class TestCursorPaginateParamsSchema:
search=False, filter=False, order=False
)
result = await dep(cursor=None, items_per_page=5)
assert result == {"cursor": None, "items_per_page": 5}
assert result == {
"cursor": None,
"items_per_page": 5,
"include_facets": True,
}
@pytest.mark.anyio
async def test_integrates_with_cursor_paginate(self, db_session: AsyncSession):
@@ -2318,7 +2461,7 @@ class TestPaginateParamsSchema:
def test_dependency_name_includes_model_name(self):
"""Dependency function is named after the model."""
dep = RoleCursorCrud.paginate_params(search=False, filter=False, order=False)
assert getattr(dep, "__name__") == "RolePaginateParams"
assert dep.__name__ == "RolePaginateParams" # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
def test_default_pagination_type(self):
"""default_pagination_type is reflected in pagination_type default."""
@@ -2399,6 +2542,7 @@ class TestPaginateParamsSchema:
"cursor": None,
"items_per_page": 10,
"include_total": True,
"include_facets": True,
}
@pytest.mark.anyio
+760 -186
View File
File diff suppressed because it is too large Load Diff
+16 -9
View File
@@ -56,7 +56,7 @@ async def seed(session: AsyncSession):
session.add_all([python, backend])
await session.flush()
now = datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc)
now = datetime.datetime(2024, 1, 1, tzinfo=datetime.UTC)
session.add_all(
[
Article(
@@ -91,13 +91,19 @@ async def seed(session: AsyncSession):
class TestAppSessionDep:
@pytest.mark.anyio
async def test_get_db_yields_async_session(self):
"""get_db yields a real AsyncSession when called directly."""
from docs_src.examples.pagination_search.db import get_db
"""The Database dependency yields a real AsyncSession when called directly."""
from starlette.requests import Request
gen = get_db()
session = await gen.__anext__()
assert isinstance(session, AsyncSession)
await gen.aclose()
from fastapi_toolsets.db import Database
db = Database(DATABASE_URL)
try:
gen = db(Request({"type": "http", "headers": []}))
session = await gen.__anext__()
assert isinstance(session, AsyncSession)
await gen.aclose()
finally:
await db.engine.dispose()
class TestOffsetPagination:
@@ -193,8 +199,9 @@ class TestOffsetPagination:
resp = await client.get("/articles/offset?status=published")
body = resp.json()
# draft is filtered out → should not appear in filter_attributes
assert "draft" not in body["filter_attributes"]["status"]
# a facet excludes its own filter_by condition, so filtering by
# status=published still shows every status the facet offers
assert "draft" in body["filter_attributes"]["status"]
@pytest.mark.anyio
async def test_search_and_filter_combined(self, client: AsyncClient, ex_db_session):
+2
View File
@@ -250,6 +250,7 @@ class TestDbExceptions:
def test_pool_exhausted_handled_as_503(self):
"""init_exceptions_handlers turns PoolExhaustedError into a 503 response."""
from fastapi import FastAPI
from fastapi_toolsets.exceptions import init_exceptions_handlers
app = FastAPI()
@@ -267,6 +268,7 @@ class TestDbExceptions:
def test_lock_timeout_handled_as_503(self):
"""init_exceptions_handlers turns LockTimeoutError into a 503 response."""
from fastapi import FastAPI
from fastapi_toolsets.exceptions import init_exceptions_handlers
app = FastAPI()
+104 -23
View File
@@ -2,6 +2,7 @@
import uuid
from enum import Enum
from typing import cast
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,8 +11,6 @@ from fastapi_toolsets.fixtures import (
Context,
FixtureRegistry,
LoadStrategy,
get_field_by_attr,
get_obj_by_attr,
load_fixtures,
load_fixtures_by_context,
)
@@ -75,7 +74,7 @@ class TestCustomEnumContext:
"""Python prohibits extending an Enum that already has members."""
with pytest.raises(TypeError):
class MyContext(Context): # noqa: F841 # ty: ignore[subclass-of-final-class]
class MyContext(Context): # ty: ignore[subclass-of-final-class]
STAGING = "staging"
def test_custom_enum_values_interchangeable_with_context(self):
@@ -267,7 +266,34 @@ class TestFixtureRegistry:
testing_fixtures = registry.get_by_context(Context.TESTING)
names = {f.name for f in testing_fixtures}
assert names == {"test_data"}
assert names == {"test_data", "base_data"}
def test_get_by_context_always_includes_base(self):
"""Context.BASE fixtures load even for a fully custom context."""
registry = FixtureRegistry()
@registry.register(contexts=[Context.BASE])
def base_data():
return []
@registry.register(contexts=["staging"])
def staging_data():
return []
names = {f.name for f in registry.get_by_context("staging")}
assert names == {"staging_data", "base_data"}
def test_get_load_variants_falls_back_to_all_when_context_has_no_match(self):
"""get_load_variants returns every variant if none match the requested
context (and none are Context.BASE either)."""
registry = FixtureRegistry()
@registry.register(contexts=["staging"])
def env_data():
return []
variants = registry.get_load_variants("env_data", "production")
assert [v.contexts for v in variants] == [["staging"]]
class TestIncludeRegistry:
@@ -812,6 +838,45 @@ class TestLoadFixtures:
db_session, registry, "int_roles", strategy=LoadStrategy.SKIP_EXISTING
)
assert len(result["int_roles"]) == 1
# The generated autoincrement PK must be written back onto the
# returned instance, not just visible via a fresh DB query.
assert cast(IntRole, result["int_roles"][0]).id is not None
@pytest.mark.anyio
async def test_insert_refreshes_autoincrement_pk_on_returned_instance(
self, db_session: AsyncSession
):
"""INSERT strategy writes the generated PK back onto the returned instance."""
registry = FixtureRegistry()
@registry.register
def int_roles():
return [IntRole(name="auto")]
result = await load_fixtures(
db_session, registry, "int_roles", strategy=LoadStrategy.INSERT
)
assert cast(IntRole, result["int_roles"][0]).id is not None
@pytest.mark.anyio
async def test_merge_refreshes_server_default_on_returned_instance(
self, db_session: AsyncSession
):
"""MERGE strategy refreshes the returned instance with server-generated values."""
registry = FixtureRegistry()
@registry.register
def challenges():
return [
Challenge(id=uuid.uuid4(), title="Solo", challenge_type="challenge")
]
result = await load_fixtures(
db_session, registry, "challenges", strategy=LoadStrategy.MERGE
)
# `points` has a column default of 0 applied by the DB, never set on
# the in-memory instance — the returned object must reflect it.
assert cast(Challenge, result["challenges"][0]).points == 0
class TestLoadFixturesByContext:
@@ -891,8 +956,8 @@ class TestLoadFixturesByContext:
assert await UserCrud.count(db_session) == 1
class TestGetObjByAttr:
"""Tests for get_obj_by_attr helper function."""
class TestRegistryObj:
"""Tests for FixtureRegistry.obj."""
def setup_method(self):
"""Set up test fixtures for each test."""
@@ -934,23 +999,20 @@ class TestGetObjByAttr:
),
]
self.roles = roles
self.users = users
def test_get_by_id(self):
"""Get an object by its id attribute."""
role = get_obj_by_attr(self.roles, "id", self.role_id_1)
assert role.name == "admin"
role = self.registry.obj("roles", "id", self.role_id_1)
assert cast(Role, role).name == "admin"
def test_get_user_by_username(self):
"""Get a user by username."""
user = get_obj_by_attr(self.users, "username", "bob")
user = cast(User, self.registry.obj("users", "username", "bob"))
assert user.id == self.user_id_2
assert user.email == "bob@example.com"
def test_returns_first_match(self):
"""Returns the first matching object when multiple could match."""
user = get_obj_by_attr(self.users, "role_id", self.role_id_1)
user = cast(User, self.registry.obj("users", "role_id", self.role_id_1))
assert user.username == "alice"
def test_no_match_raises_stop_iteration(self):
@@ -959,16 +1021,37 @@ class TestGetObjByAttr:
StopIteration,
match="No object with name=nonexistent found in fixture 'roles'",
):
get_obj_by_attr(self.roles, "name", "nonexistent")
self.registry.obj("roles", "name", "nonexistent")
def test_no_match_on_wrong_value_type(self):
"""Raises StopIteration when value type doesn't match."""
with pytest.raises(StopIteration):
get_obj_by_attr(self.roles, "id", "not-a-uuid")
self.registry.obj("roles", "id", "not-a-uuid")
def test_unknown_fixture_raises_key_error(self):
"""Raises KeyError when the fixture name isn't registered."""
with pytest.raises(KeyError):
self.registry.obj("unknown", "id", self.role_id_1)
def test_searches_across_context_variants(self):
"""obj() finds matches across all context variants of a fixture name, not just one."""
registry = FixtureRegistry()
tester_id = uuid.uuid4()
@registry.register(contexts=[Context.BASE])
def variant_users() -> list[User]:
return [User(id=uuid.uuid4(), username="admin", email="admin@x.com")]
@registry.register(contexts=[Context.TESTING])
def variant_users() -> list[User]: # noqa: F811
return [User(id=tester_id, username="tester", email="tester@x.com")]
user = cast(User, registry.obj("variant_users", "username", "tester"))
assert user.id == tester_id
class TestGetFieldByAttr:
"""Tests for get_field_by_attr helper function."""
class TestRegistryField:
"""Tests for FixtureRegistry.field."""
def setup_method(self):
self.registry = FixtureRegistry()
@@ -984,22 +1067,20 @@ class TestGetFieldByAttr:
Role(id=role_id_2, name="user"),
]
self.roles = roles
def test_returns_id_by_default(self):
"""Returns the id field when no field is specified."""
result = get_field_by_attr(self.roles, "name", "admin")
result = self.registry.field("roles", "name", "admin")
assert result == self.role_id_1
def test_returns_specified_field(self):
"""Returns the requested field instead of id."""
result = get_field_by_attr(self.roles, "id", self.role_id_2, field="name")
result = self.registry.field("roles", "id", self.role_id_2, field="name")
assert result == "user"
def test_no_match_raises_stop_iteration(self):
"""Propagates StopIteration from get_obj_by_attr when no match found."""
"""Propagates StopIteration from obj() when no match found."""
with pytest.raises(StopIteration, match="No object with name=missing"):
get_field_by_attr(self.roles, "name", "missing")
self.registry.field("roles", "name", "missing")
class TestGetPrimaryKey:
+2 -2
View File
@@ -197,8 +197,8 @@ class TestCliImportGuard:
importlib.import_module("fastapi_toolsets.cli.app")
finally:
for key in list(sys.modules):
if key.startswith("fastapi_toolsets.cli.app") or key.startswith(
"fastapi_toolsets.cli.config"
if key.startswith(
("fastapi_toolsets.cli.app", "fastapi_toolsets.cli.config")
):
sys.modules.pop(key, None)
sys.modules.update(saved)
+100 -64
View File
@@ -21,17 +21,15 @@ from fastapi_toolsets.models import (
listens_for,
)
from fastapi_toolsets.models.watched import (
EventSession,
_EVENT_HANDLERS,
_SESSION_CREATES,
_SESSION_DELETES,
_SESSION_UPDATES,
_WATCHED_MODELS,
EventSession,
_after_flush,
_after_rollback,
_get_watched_fields,
_invalidate_caches,
_is_watched,
_snapshot_column_attrs,
_upsert_changes,
)
@@ -658,22 +656,6 @@ class TestWatchInheritance:
assert "other" in _watch_inherit_events[0]["changes"]
class TestIsWatched:
def test_watched_model_is_watched(self):
"""_is_watched returns True for models with registered handlers."""
obj = WatchedModel(status="x", other="y")
assert _is_watched(obj) is True
def test_non_watched_model_is_not_watched(self):
"""_is_watched returns False for models without registered handlers."""
assert _is_watched(object()) is False
def test_subclass_of_watched_model_is_watched(self):
"""_is_watched returns True for subclasses of watched models (via MRO)."""
dog = PolyDog(name="Rex")
assert _is_watched(dog) is True
class TestUpsertChanges:
def test_inserts_new_entry(self):
"""New key is inserted with the full changes dict."""
@@ -715,7 +697,10 @@ class TestAfterFlush:
"""New watched objects are added to _SESSION_CREATES."""
obj = object()
session = SimpleNamespace(new=[obj], deleted=[], dirty=[], info={})
with patch("fastapi_toolsets.models.watched._is_watched", return_value=True):
with patch(
"fastapi_toolsets.models.watched._get_handlers",
return_value=[lambda *a: None],
):
_after_flush(session, None)
assert session.info[_SESSION_CREATES] == [obj]
@@ -731,7 +716,10 @@ class TestAfterFlush:
obj = object()
session = SimpleNamespace(new=[], deleted=[obj], dirty=[], info={})
with (
patch("fastapi_toolsets.models.watched._is_watched", return_value=True),
patch(
"fastapi_toolsets.models.watched._get_handlers",
return_value=[lambda *a: None],
),
patch(
"fastapi_toolsets.models.watched._snapshot_column_attrs",
return_value={"id": 1},
@@ -1001,6 +989,69 @@ class TestEventCallbacks:
assert _test_events == []
@pytest.mark.anyio
async def test_create_survives_row_deleted_before_reload(self, mixin_session):
"""A row deleted by another transaction right after commit still fires CREATE."""
keep = WatchedModel(status="active", other="x")
doomed = WatchedModel(status="active", other="x")
mixin_session.add_all([keep, doomed])
await mixin_session.flush()
doomed_id = doomed.id
raced = {"done": False}
async def kill_doomed_row_once():
if raced["done"]:
return
raced["done"] = True
engine = create_async_engine(DATABASE_URL, echo=False)
async with async_sessionmaker(engine)() as other:
row = await other.get(WatchedModel, doomed_id)
await other.delete(row)
await other.commit()
await engine.dispose()
real_batch_reload = _watched_module._batch_reload
async def racing_batch_reload(session, model, pk_tuples):
if any(pk[0] == doomed_id for pk in pk_tuples):
await kill_doomed_row_once()
return await real_batch_reload(session, model, pk_tuples)
# Patch the batched reload EventSession.commit() uses to pick up
# server defaults, so this test still exercises the race.
with (
patch.object(_watched_module, "_batch_reload", racing_batch_reload),
patch.object(_watched_module._logger, "error") as mock_error,
):
await mixin_session.commit()
mock_error.assert_not_called()
assert raced["done"]
created_ids = {e["obj_id"] for e in _test_events if e["event"] == "create"}
assert created_ids == {keep.id, doomed_id}
@pytest.mark.anyio
async def test_batch_reload_exception_is_logged_and_dispatch_continues(
self, mixin_session
):
"""A batched-reload failure is logged; CREATE handlers still fire."""
obj = WatchedModel(status="active", other="x")
mixin_session.add(obj)
async def failing_batch_reload(session, model, pk_tuples):
raise RuntimeError("reload failed")
with (
patch.object(_watched_module, "_batch_reload", failing_batch_reload),
patch.object(_watched_module._logger, "error") as mock_error,
):
await mixin_session.commit()
mock_error.assert_called_once()
creates = [e for e in _test_events if e["event"] == "create"]
assert len(creates) == 1
class TestTransientObject:
"""Create + delete within the same transaction should fire no events."""
@@ -1370,7 +1421,6 @@ class TestListensFor:
for key in list(_EVENT_HANDLERS):
if key[0] is ListenerModel:
del _EVENT_HANDLERS[key]
_WATCHED_MODELS.discard(ListenerModel)
_invalidate_caches()
@pytest.mark.anyio
@@ -1506,8 +1556,8 @@ class TestListensFor:
assert all(e["event"] == "change" for e in _listener_events)
class TestEventSessionWithGetTransaction:
"""Verify callbacks fire correctly when using get_transaction / lock_tables."""
class TestEventSessionWithTransaction:
"""Verify callbacks fire correctly when using transaction / lock_tables."""
@pytest.fixture(autouse=True)
def clear_events(self):
@@ -1517,10 +1567,10 @@ class TestEventSessionWithGetTransaction:
@pytest.mark.anyio
async def test_callbacks_fire_after_outer_commit_not_savepoint(self, mixin_session):
"""get_transaction creates a savepoint; callbacks fire only on outer commit."""
from fastapi_toolsets.db import get_transaction
"""transaction creates a savepoint; callbacks fire only on outer commit."""
from fastapi_toolsets.db import transaction
async with get_transaction(mixin_session):
async with transaction(mixin_session):
obj = WatchedModel(status="active", other="x")
mixin_session.add(obj)
@@ -1535,14 +1585,14 @@ class TestEventSessionWithGetTransaction:
@pytest.mark.anyio
async def test_nested_transactions_accumulate_events(self, mixin_session):
"""Multiple get_transaction blocks accumulate events for a single commit."""
from fastapi_toolsets.db import get_transaction
"""Multiple transaction blocks accumulate events for a single commit."""
from fastapi_toolsets.db import transaction
async with get_transaction(mixin_session):
async with transaction(mixin_session):
obj1 = WatchedModel(status="first", other="x")
mixin_session.add(obj1)
async with get_transaction(mixin_session):
async with transaction(mixin_session):
obj2 = WatchedModel(status="second", other="y")
mixin_session.add(obj2)
@@ -1556,14 +1606,14 @@ class TestEventSessionWithGetTransaction:
@pytest.mark.anyio
async def test_savepoint_rollback_suppresses_events(self, mixin_session):
"""Objects from a rolled-back savepoint don't fire callbacks."""
from fastapi_toolsets.db import get_transaction
from fastapi_toolsets.db import transaction
survivor = WatchedModel(status="kept", other="x")
mixin_session.add(survivor)
await mixin_session.flush()
try:
async with get_transaction(mixin_session):
async with transaction(mixin_session):
doomed = WatchedModel(status="doomed", other="y")
mixin_session.add(doomed)
await mixin_session.flush()
@@ -1590,9 +1640,9 @@ class TestEventSessionWithGetTransaction:
assert len(creates) == 1
@pytest.mark.anyio
async def test_update_inside_get_transaction(self, mixin_session):
"""UPDATE events fire with correct changes after get_transaction commit."""
from fastapi_toolsets.db import get_transaction
async def test_update_inside_transaction(self, mixin_session):
"""UPDATE events fire with correct changes after transaction commit."""
from fastapi_toolsets.db import transaction
obj = WatchedModel(status="initial", other="x")
mixin_session.add(obj)
@@ -1600,7 +1650,7 @@ class TestEventSessionWithGetTransaction:
_test_events.clear()
async with get_transaction(mixin_session):
async with transaction(mixin_session):
obj.status = "updated"
await mixin_session.commit()
@@ -1696,7 +1746,7 @@ class TestEventSessionWithNullableFields:
class TestEventSessionWithFastAPIDependency:
"""Verify EventSession works when session comes from create_db_dependency."""
"""Verify EventSession works when session comes from the Database dependency."""
@pytest.fixture(autouse=True)
def clear_events(self):
@@ -1706,31 +1756,24 @@ class TestEventSessionWithFastAPIDependency:
@pytest.mark.anyio
async def test_create_event_fires_via_dependency(self):
"""CREATE callback fires when session is provided by create_db_dependency."""
"""CREATE callback fires when session is provided by the Database dependency."""
from fastapi import Depends, FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from fastapi_toolsets.db import create_db_dependency
from fastapi_toolsets.db import Database
from fastapi_toolsets.models import EventSession
engine = create_async_engine(DATABASE_URL, echo=False)
session_factory = async_sessionmaker(
engine, expire_on_commit=False, class_=EventSession
)
async with engine.begin() as conn:
await conn.run_sync(MixinBase.metadata.create_all)
get_db = create_db_dependency(session_factory)
db = Database(engine=engine, session_class=EventSession)
app = FastAPI()
@app.post("/watched")
async def create_watched(session: AsyncSession = Depends(get_db)):
async def create_watched(session: AsyncSession = Depends(db)):
obj = WatchedModel(status="from-api", other="x")
session.add(obj)
return {"id": str(obj.id)}
@@ -1753,40 +1796,33 @@ class TestEventSessionWithFastAPIDependency:
@pytest.mark.anyio
async def test_update_event_fires_via_dependency(self):
"""UPDATE callback fires when session is provided by create_db_dependency."""
"""UPDATE callback fires when session is provided by the Database dependency."""
from fastapi import Depends, FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from fastapi_toolsets.db import create_db_dependency
from fastapi_toolsets.db import Database
from fastapi_toolsets.models import EventSession
engine = create_async_engine(DATABASE_URL, echo=False)
session_factory = async_sessionmaker(
engine, expire_on_commit=False, class_=EventSession
)
async with engine.begin() as conn:
await conn.run_sync(MixinBase.metadata.create_all)
get_db = create_db_dependency(session_factory)
db = Database(engine=engine, session_class=EventSession)
app = FastAPI()
# Pre-seed an object.
async with session_factory() as seed_session:
async with db.session() as seed_session:
obj = WatchedModel(status="initial", other="x")
seed_session.add(obj)
await seed_session.commit()
await seed_session.flush()
obj_id = obj.id
_test_events.clear()
@app.put("/watched/{item_id}")
async def update_watched(item_id: str, session: AsyncSession = Depends(get_db)):
async def update_watched(item_id: str, session: AsyncSession = Depends(db)):
from sqlalchemy import select
stmt = select(WatchedModel).where(WatchedModel.id == item_id)
+12 -12
View File
@@ -11,8 +11,13 @@ from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from fastapi_toolsets.db import get_transaction
from fastapi_toolsets.db import transaction
from fastapi_toolsets.fixtures import Context, FixtureRegistry, LoadStrategy
from fastapi_toolsets.fixtures.utils import (
_get_primary_key,
_relationship_load_options,
_reload_with_relationships,
)
from fastapi_toolsets.pytest import (
create_async_client,
create_db_session,
@@ -20,11 +25,6 @@ from fastapi_toolsets.pytest import (
register_fixtures,
worker_database_url,
)
from fastapi_toolsets.pytest.plugin import (
_get_primary_key,
_relationship_load_options,
_reload_with_relationships,
)
from fastapi_toolsets.pytest.utils import _get_xdist_worker
from .conftest import (
@@ -387,14 +387,14 @@ class TestCreateDbSession:
assert session.autoflush is False
@pytest.mark.anyio
async def test_get_transaction_commits_visible_to_separate_session(self):
"""Data written via get_transaction() is committed and visible to other sessions."""
async def test_transaction_commits_visible_to_separate_session(self):
"""Data written via transaction() is committed and visible to other sessions."""
role_id = uuid.uuid4()
async with create_db_session(DATABASE_URL, Base, drop_tables=False) as session:
# Simulate what _create_fixture_function does: insert via get_transaction
# Simulate what _create_fixture_function does: insert via transaction()
# with no explicit commit afterward.
async with get_transaction(session):
async with transaction(session):
role = Role(id=role_id, name="visible_to_other_session")
session.add(role)
@@ -409,9 +409,9 @@ class TestCreateDbSession:
result = await other.execute(select(Role).where(Role.id == role_id))
fetched = result.scalar_one_or_none()
assert fetched is not None, (
"Fixture data inserted via get_transaction() must be committed "
"Fixture data inserted via transaction() must be committed "
"and visible to a separate session. If create_db_session uses "
"create_db_context, auto-begin forces get_transaction() into "
"db.session(), auto-begin forces transaction() into "
"savepoints instead of real commits."
)
assert fetched.name == "visible_to_other_session"
+2 -2
View File
@@ -5,11 +5,11 @@ from pydantic import ValidationError
from fastapi_toolsets.schemas import (
ApiError,
CursorPagination,
CursorPaginatedResponse,
CursorPagination,
ErrorResponse,
OffsetPagination,
OffsetPaginatedResponse,
OffsetPagination,
PaginatedResponse,
PaginationType,
Response,
File diff suppressed because it is too large Load Diff
Generated
+169 -191
View File
@@ -192,101 +192,86 @@ wheels = [
[[package]]
name = "coverage"
version = "7.14.1"
version = "7.15.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" }
sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" },
{ url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" },
{ url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" },
{ url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" },
{ url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" },
{ url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" },
{ url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" },
{ url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" },
{ url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" },
{ url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" },
{ url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" },
{ url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" },
{ url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" },
{ url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" },
{ url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" },
{ url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" },
{ url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" },
{ url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" },
{ url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" },
{ url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" },
{ url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" },
{ url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" },
{ url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" },
{ url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" },
{ url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" },
{ url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" },
{ url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" },
{ url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" },
{ url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" },
{ url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" },
{ url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" },
{ url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" },
{ url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" },
{ url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" },
{ url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" },
{ url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" },
{ url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" },
{ url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" },
{ url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" },
{ url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" },
{ url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" },
{ url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" },
{ url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" },
{ url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" },
{ url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" },
{ url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" },
{ url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" },
{ url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" },
{ url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" },
{ url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" },
{ url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" },
{ url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" },
{ url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" },
{ url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" },
{ url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" },
{ url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" },
{ url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" },
{ url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" },
{ url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" },
{ url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" },
{ url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" },
{ url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" },
{ url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" },
{ url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" },
{ url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" },
{ url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" },
{ url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" },
{ url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" },
{ url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" },
{ url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" },
{ url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" },
{ url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" },
{ url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" },
{ url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" },
{ url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" },
{ url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" },
{ url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" },
{ url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" },
{ url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" },
{ url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" },
{ url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" },
{ url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" },
{ url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" },
{ url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" },
{ url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" },
{ url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" },
{ url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" },
{ url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" },
{ url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" },
{ url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" },
{ url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" },
{ url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" },
{ url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" },
{ url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" },
{ url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" },
{ url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" },
{ url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" },
{ url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" },
{ url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" },
{ url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" },
{ url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" },
{ url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" },
{ url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" },
{ url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" },
{ url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" },
{ url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" },
{ url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" },
{ url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" },
{ url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" },
{ url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" },
{ url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" },
{ url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" },
{ url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" },
{ url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" },
{ url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" },
{ url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" },
{ url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" },
{ url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" },
{ url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" },
{ url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" },
{ url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" },
{ url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" },
{ url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" },
{ url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" },
{ url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" },
{ url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" },
{ url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" },
{ url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" },
{ url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" },
{ url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" },
{ url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" },
{ url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" },
{ url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" },
{ url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" },
{ url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" },
{ url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" },
{ url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" },
{ url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" },
{ url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" },
{ url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" },
{ url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" },
{ url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" },
{ url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" },
{ url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" },
{ url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" },
{ url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" },
{ url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" },
{ url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" },
{ url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" },
]
[package.optional-dependencies]
@@ -314,7 +299,7 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.136.3"
version = "0.139.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -323,14 +308,14 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/af/a5f50ccfa659ec1802cb4ca842c23f06d906a8cc9aef6016a2caeea3d4ed/fastapi-0.139.0.tar.gz", hash = "sha256:99ab7b2d92223c76d6cf10757ab3f89d45b38267fc20b2a136cf02f6beac3145", size = 423016, upload-time = "2026-07-01T16:35:33.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
{ url = "https://files.pythonhosted.org/packages/9e/7c/8e3c6ad324ea5cb36604fc3f968554887891c316d9dfde57761611d907ad/fastapi-0.139.0-py3-none-any.whl", hash = "sha256:cf15e1e9e667ddb0ad63811e60bd11390d1aac838ca4a7a23f421807b2308189", size = 130339, upload-time = "2026-07-01T16:35:32.19Z" },
]
[[package]]
name = "fastapi-toolsets"
version = "4.1.3"
version = "5.1.0"
source = { editable = "." }
dependencies = [
{ name = "asyncpg" },
@@ -341,7 +326,6 @@ dependencies = [
[package.optional-dependencies]
all = [
{ name = "async-lru" },
{ name = "httpx" },
{ name = "prometheus-client" },
{ name = "pytest" },
@@ -359,10 +343,6 @@ pytest = [
{ name = "pytest" },
{ name = "pytest-xdist" },
]
security = [
{ name = "async-lru" },
{ name = "httpx" },
]
[package.dev-dependencies]
dev = [
@@ -402,12 +382,10 @@ tests = [
[package.metadata]
requires-dist = [
{ name = "async-lru", marker = "extra == 'security'", specifier = ">=1.0" },
{ name = "asyncpg", specifier = ">=0.29.0" },
{ name = "fastapi", specifier = ">=0.100.0" },
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest", "security"], marker = "extra == 'all'" },
{ name = "fastapi-toolsets", extras = ["cli", "metrics", "pytest"], marker = "extra == 'all'" },
{ name = "httpx", marker = "extra == 'pytest'", specifier = ">=0.25.0" },
{ name = "httpx", marker = "extra == 'security'", specifier = ">=0.25.0" },
{ name = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
{ name = "pydantic", specifier = ">=2.0" },
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
@@ -415,7 +393,7 @@ requires-dist = [
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.0" },
]
provides-extras = ["cli", "metrics", "security", "pytest", "all"]
provides-extras = ["cli", "metrics", "pytest", "all"]
[package.metadata.requires-dev]
dev = [
@@ -786,16 +764,16 @@ wheels = [
[[package]]
name = "mkdocstrings-python"
version = "2.0.3"
version = "2.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "griffelib" },
{ name = "mkdocs-autorefs" },
{ name = "mkdocstrings" },
]
sdist = { url = "https://files.pythonhosted.org/packages/29/33/c225eaf898634bdda489a6766fc35d1683c640bffe0e0acd10646b13536d/mkdocstrings_python-2.0.3.tar.gz", hash = "sha256:c518632751cc869439b31c9d3177678ad2bfa5c21b79b863956ad68fc92c13b8", size = 199083, upload-time = "2026-02-20T10:38:36.368Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b1/b6/e858701499d57eee8b3fd8e78168083956c6683ddbe727b46758b19e1119/mkdocstrings_python-2.0.5.tar.gz", hash = "sha256:3a4d92556ad39637e88af94a5374213af9a8e3040c3824ceaed04b486c017594", size = 199578, upload-time = "2026-06-19T10:41:08.868Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" },
{ url = "https://files.pythonhosted.org/packages/d1/fc/10ab7e80650a9c9e8f4f1105f8c8e73567f88ed0c06ada589ab81d38687c/mkdocstrings_python-2.0.5-py3-none-any.whl", hash = "sha256:30c837bbff016549f659fcba6539ac351303f0fd7e713c89a040611072236e9d", size = 104951, upload-time = "2026-06-19T10:41:07.378Z" },
]
[[package]]
@@ -836,26 +814,26 @@ wheels = [
[[package]]
name = "prek"
version = "0.4.3"
version = "0.4.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/3b/a0ae60bbd4c4735f20aeddfbd3c50fb669cd8e99c078a3ed75a6a4a5c6d7/prek-0.4.3.tar.gz", hash = "sha256:e486307ea649e7300b3535fac52fe0ba0b80aebe23143b662659d16e6a7c8b47", size = 461800, upload-time = "2026-05-27T03:18:58.045Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ea/df/94ed29398576e03494c5aacda8bfed9536edf348bed29cd09f382f2b9b23/prek-0.4.9.tar.gz", hash = "sha256:f8b86441484a5756f3fdb6f3b201d3d448f8845902a84653d78cbf6f875c424f", size = 492711, upload-time = "2026-07-11T11:04:04.332Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/be/980a0512f7eec3469dd40574f4e35d9ce7b67b358fea58888d13a0625b0d/prek-0.4.3-py3-none-linux_armv6l.whl", hash = "sha256:c67109de8d9766c2afd6e7e64feb9e1a0d3eceb3b4123280c28344660c1a97cd", size = 5541730, upload-time = "2026-05-27T03:19:09.119Z" },
{ url = "https://files.pythonhosted.org/packages/ef/55/937d707cc01d311e5c856c7019bc7db2c5e1835728396bb1ea32a7ecfdfd/prek-0.4.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b43a85f5ddf7827a75491e79ca068a49c5e4efde8dbac844ecb89622a78458e4", size = 5906762, upload-time = "2026-05-27T03:19:21.651Z" },
{ url = "https://files.pythonhosted.org/packages/e1/6a/9a99ac481eb148dba55652df88b029ab6c1f90384bd51996026cdab2dafb/prek-0.4.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e99ee90a7b6e84dabef891ff7521eb59dae38953467bdb482f004ea522d3a64c", size = 5461541, upload-time = "2026-05-27T03:18:55.984Z" },
{ url = "https://files.pythonhosted.org/packages/a4/8d/9056b02a100cc18b101fc05ecc82635889f5f8cb1cce5d70b027e517a6d9/prek-0.4.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:f514ec0d95cd4578d74d4601058bd259f5baf91c937f2aaae942d4b070b8077f", size = 5720501, upload-time = "2026-05-27T03:18:52.424Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ea/efbe4523e53022d94272ddfdd3a198ace7de004dd8830a69318085a10393/prek-0.4.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03a4ac3c3023a76faa52ad7775720599b10241930be8902c471085b22572b4b0", size = 5452412, upload-time = "2026-05-27T03:19:17.801Z" },
{ url = "https://files.pythonhosted.org/packages/97/d6/8a48b2c6a5117110d688c2d8ca2526264ad9f0d3baed4587038ee85e4c2d/prek-0.4.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40173425ab82bf0a7267d672b3e3aae9dd425eaee3a3641c6a5f040da3ff95e4", size = 5849515, upload-time = "2026-05-27T03:19:05.545Z" },
{ url = "https://files.pythonhosted.org/packages/ec/66/ccce7a1b6c6b610a22b54092d523ea7d35709e42864dace3734c05dd5f98/prek-0.4.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1b8d99ee3277f8f3a3453a953120ee5c6c52f7ad89e459a25425cf62135f47b1", size = 6743978, upload-time = "2026-05-27T03:19:07.445Z" },
{ url = "https://files.pythonhosted.org/packages/1a/f8/7a441d780c42e858ad677c82bb54eb3f01b424b710a8db5b9a8782305326/prek-0.4.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e08595fe96d24c1fe13486b00d55ce73a7b37040a16e82365942606594c67a6b", size = 6108774, upload-time = "2026-05-27T03:19:03.565Z" },
{ url = "https://files.pythonhosted.org/packages/bf/38/fbb1afe14c7536109c68a1d9ca602f152f1929972d006c517c3b92140192/prek-0.4.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:8607d636ef9232675507d97d252e1dcca5628bff79cb069fa945fff09d7bbb43", size = 5723165, upload-time = "2026-05-27T03:18:59.558Z" },
{ url = "https://files.pythonhosted.org/packages/b7/b8/edafebce2bbd85f9e9de2781c225d690eb2b9897a06b224f5c24658fe398/prek-0.4.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:89484765304a779780f83489eb3aed5de5366f47fce7713fa5a917ebc281baa0", size = 5560557, upload-time = "2026-05-27T03:18:49.59Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2e/a85a40458ac50c452cae2ddd2eed0b70107fd2b4074d7a5003088ac508f1/prek-0.4.3-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:2d2b0c12e3d1c6d90646f9faa2d4c66f9861f3c6e577d7dbd25e733ed095ac56", size = 5417874, upload-time = "2026-05-27T03:19:01.681Z" },
{ url = "https://files.pythonhosted.org/packages/4c/be/106fb026646e1da65da6d2a5f3cfbda817e68a72429645351b7033c0b2b5/prek-0.4.3-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ca6802eaf191acb6166e9e013dd277ea193ba27c1dca896ab7debf6dca758b6d", size = 5710013, upload-time = "2026-05-27T03:18:54.143Z" },
{ url = "https://files.pythonhosted.org/packages/03/c4/edfff5f7d9b6c9e5860dfe05c9488e1b96de990b652db2e379d45af8ad2e/prek-0.4.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a46862d81078d2c8caa286c392f965ed72fb72eb1fed171910ba54fe8d546ed0", size = 6230160, upload-time = "2026-05-27T03:19:15.58Z" },
{ url = "https://files.pythonhosted.org/packages/15/d2/70adf26d5da0b7a66d8e284a661feddd5e8c69784b82084f40485fa321e4/prek-0.4.3-py3-none-win32.whl", hash = "sha256:f78e343584cfff106fc3c361109b87949ad8028dc5aa667e0fccd26db8170d7d", size = 5226844, upload-time = "2026-05-27T03:19:13.871Z" },
{ url = "https://files.pythonhosted.org/packages/32/5d/9f21aca8ccee6978db831dbf36c2e17461692c75dd291c9b3d170e39a82a/prek-0.4.3-py3-none-win_amd64.whl", hash = "sha256:798d04437d30d6b4e6c1d520fe6ca800c340c9246f0dc8900d8b365df54b71b6", size = 5616068, upload-time = "2026-05-27T03:19:19.653Z" },
{ url = "https://files.pythonhosted.org/packages/f5/ef/cad8f9c66bcc199e22d1ad82a50032067a4c8b4182306d3472ff99f64aa3/prek-0.4.3-py3-none-win_arm64.whl", hash = "sha256:70d9da5fc14ef41565ff7ba9f476fb53166bf719a954339b2e9f42ed494a2f71", size = 5448057, upload-time = "2026-05-27T03:19:11.118Z" },
{ url = "https://files.pythonhosted.org/packages/10/02/632446b72103daf92526203bf83cb76043ebce70588632aa2aea3741da07/prek-0.4.9-py3-none-linux_armv6l.whl", hash = "sha256:7b240ad6f679104309a944c4dd427ccc46d9aaf4f53ee07379c02bf7578c2750", size = 5637604, upload-time = "2026-07-11T11:03:38.985Z" },
{ url = "https://files.pythonhosted.org/packages/57/bf/89daefc85a1db9b8c525731c77121de0a8f57731e81c99d823e919e1f6a5/prek-0.4.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5cbb220d6d77cb047747dcabf421375fdfdd958e01a998a854758698d38fb239", size = 5960396, upload-time = "2026-07-11T11:03:40.953Z" },
{ url = "https://files.pythonhosted.org/packages/42/39/0e448da00671e77740be32cca96530ef64bcdbed178acce7f155b87f6057/prek-0.4.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fd85df4c186becdc47b2e6155de8cace99133c7517387e30f08ca15b93ead11f", size = 5524982, upload-time = "2026-07-11T11:03:42.605Z" },
{ url = "https://files.pythonhosted.org/packages/71/e5/1beceff9cfcf02817c06f38e726c9875cd003f62cbfa516f282fb3c3109b/prek-0.4.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:ba40511145e948d461d6641b556b6ff671b186b0c90743600b9dcb788dd5eb5c", size = 5793691, upload-time = "2026-07-11T11:03:44.106Z" },
{ url = "https://files.pythonhosted.org/packages/7e/17/99e4884c45c46be90e81e220d2bdd5020716963707ef6702361cc398dd91/prek-0.4.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0cc5c06ae448568d076bb5e7ce4d630879d6467b2a89fd79061c349a47826efa", size = 5544549, upload-time = "2026-07-11T11:03:45.564Z" },
{ url = "https://files.pythonhosted.org/packages/0c/4b/63f5fe22d867a6e07b12e8a7887a0f3b193c2ef0fa9ed80649f2d257f4ac/prek-0.4.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31af616c216ec7e47913802b082ca816d707d24738fa58eae0463ae296cbe71e", size = 5948798, upload-time = "2026-07-11T11:03:47.424Z" },
{ url = "https://files.pythonhosted.org/packages/1f/89/90d5005436afb6ab99d3ba6599820fe0f0fcb776f5e9e362b5a19e10cbea/prek-0.4.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a1ef842d7f19879fac2135c42ba15508f2677346ba800bc8bb82e5f43fe6a6ec", size = 6696938, upload-time = "2026-07-11T11:03:49Z" },
{ url = "https://files.pythonhosted.org/packages/2a/62/068dd25e1106e262b0ce69ffcdc594cf52d85aa13f64628f851e458980d4/prek-0.4.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:442ecf6a454c692bc8a2d5a16936a0fe8e3419727ff208e15818932acca9923a", size = 6170870, upload-time = "2026-07-11T11:03:50.407Z" },
{ url = "https://files.pythonhosted.org/packages/8e/42/bb1f3f3d84af4d12bb675ff071305fa776d3525c10626465d9960ad93c21/prek-0.4.9-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5b3c590252e3d5724ebed3774695712ff7cb554743b25184808aa5f42b06bd4c", size = 5800355, upload-time = "2026-07-11T11:03:51.882Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ca/dfc8312b4a7ce8fa100ce37a843279d108eec7e7fe2ddbe069448acd1642/prek-0.4.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dd283b15dec4da29caa3910bd72c8c9d7df93209770503465ad109d6370cb8e", size = 5655126, upload-time = "2026-07-11T11:03:53.388Z" },
{ url = "https://files.pythonhosted.org/packages/25/8a/b19413cb64b81c18502ea7bbef32897f9126b8e53b58cd656996573dc53c/prek-0.4.9-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:cc25e30e1700a5c7dd9bad665c321e5589b51502bb1bbc6ada45e326d08b428b", size = 5517839, upload-time = "2026-07-11T11:03:54.884Z" },
{ url = "https://files.pythonhosted.org/packages/94/af/900df3f7535e87045df331646c6a01bef6e77dba7f2bdb53483f30cbb988/prek-0.4.9-py3-none-musllinux_1_1_i686.whl", hash = "sha256:4ff5947deeb9a92e6508dfba8b27962dfb927bf60fd36472c9ae862df96fb38c", size = 5802556, upload-time = "2026-07-11T11:03:56.787Z" },
{ url = "https://files.pythonhosted.org/packages/e9/7b/80e560cbe396d0f8687012769cb2d7d7f3428dd019549225ebf205dea806/prek-0.4.9-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:8320ca167d41855d9c4fed66df599f31f96307cbb0da1311a9fe465152e20bd5", size = 6285747, upload-time = "2026-07-11T11:03:58.099Z" },
{ url = "https://files.pythonhosted.org/packages/a3/d4/01ae3b99d09559a69befd128859d0036c604608dd9c6c99986592dde3c21/prek-0.4.9-py3-none-win32.whl", hash = "sha256:b1e8d3bc88ddce6414853468ed8126f45d4ae20f2f4677801ade20ad67a826fa", size = 5320862, upload-time = "2026-07-11T11:03:59.803Z" },
{ url = "https://files.pythonhosted.org/packages/28/68/d038b14f0220fed197be8bc93229e6ea7ad460803ff8a26e4b14a8c81f66/prek-0.4.9-py3-none-win_amd64.whl", hash = "sha256:ed1b4f87a13d1565e8731c60db7fa058966049cbb4d8872d160add510a286558", size = 5706850, upload-time = "2026-07-11T11:04:01.207Z" },
{ url = "https://files.pythonhosted.org/packages/02/4a/57d04de49f591088901794cc22f36563a102681e3512ae17ee6085cd2f30/prek-0.4.9-py3-none-win_arm64.whl", hash = "sha256:7eab3900d9ea614c8ea0d0d55a8b708f0c88e43c966dc8b13a4e36c1e398dd16", size = 5540477, upload-time = "2026-07-11T11:04:02.815Z" },
]
[[package]]
@@ -995,15 +973,15 @@ wheels = [
[[package]]
name = "pymdown-extensions"
version = "10.21.3"
version = "11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/26/d1015444da4d952a1ca487a236b522eb979766f0295a0bd0c5fc089989a9/pymdown_extensions-10.21.3.tar.gz", hash = "sha256:72cfcf55f07aea0d4af2c4f11dd4e52466ddfb1bb819673146398e0bd3a77354", size = 854140, upload-time = "2026-05-13T12:57:32.267Z" }
sdist = { url = "https://files.pythonhosted.org/packages/47/67/f1e79672a5f91985577c7984c9709ca110e4fd37fe7fd167b60422e6ccc2/pymdown_extensions-11.0.tar.gz", hash = "sha256:8269cef0247f9e2d0a62fcea10860aba05c1cbab5470fd4b63230b96434dc589", size = 857049, upload-time = "2026-06-23T02:27:45.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" },
{ url = "https://files.pythonhosted.org/packages/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl", hash = "sha256:fbc4acb641814fa9d17521bbd21a5240ef739a662f11c06330c4b78c93e954d6", size = 269415, upload-time = "2026-06-23T02:27:43.826Z" },
]
[[package]]
@@ -1017,7 +995,7 @@ wheels = [
[[package]]
name = "pytest"
version = "9.0.3"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -1026,9 +1004,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
@@ -1165,27 +1143,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.15"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
{ url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
{ url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
{ url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
{ url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
{ url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
{ url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
{ url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
{ url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
{ url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
{ url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
{ url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
{ url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
{ url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
{ url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
{ url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
{ url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" },
{ url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" },
{ url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" },
{ url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" },
{ url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" },
{ url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" },
{ url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" },
{ url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" },
{ url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" },
{ url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" },
{ url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" },
{ url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" },
{ url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" },
{ url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" },
]
[[package]]
@@ -1262,15 +1240,15 @@ asyncio = [
[[package]]
name = "starlette"
version = "1.0.1"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" }
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" },
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]
[[package]]
@@ -1329,32 +1307,32 @@ wheels = [
[[package]]
name = "ty"
version = "0.0.44"
version = "0.0.64"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/f4/fbb120226e4f239652525a664bad976a23fea58c646d1323f2296fee8a61/ty-0.0.44.tar.gz", hash = "sha256:5886229830ab77022842a1c55d2ef57405621a91fc465969fa6d538661898173", size = 5803665, upload-time = "2026-06-05T03:33:48.612Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/aa/14c9965d3b173105692473897cc89c34cd91241368b2044e43167e1c17ff/ty-0.0.64.tar.gz", hash = "sha256:d12ddbb05f15158bb518af619378b385486450def95fb06f8ab98037febe9f2c", size = 6350966, upload-time = "2026-07-27T18:32:45.403Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/c6/b5b8c4762efb4d85401652658786506867553ecfc2beac3bcf361a15937f/ty-0.0.44-py3-none-linux_armv6l.whl", hash = "sha256:272d31e7ad49b1dc5e8465a9fe700354e14c755b40d9c75f08f031d786903df3", size = 11607267, upload-time = "2026-06-05T03:33:27.154Z" },
{ url = "https://files.pythonhosted.org/packages/1c/5c/f4b405570737f44ab0fc4214117fe43353f8f0825a1823d9e99e9c8e57be/ty-0.0.44-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b92c4ddd7a3daf2049715edec9dc70cf6fd31a5a318ee647258f90dd75495eed", size = 11382826, upload-time = "2026-06-05T03:33:54.374Z" },
{ url = "https://files.pythonhosted.org/packages/9d/aa/fb9835aa492b148d7754cb4c3db07f31a7e2e09f0d8e0e8e297f01125dd2/ty-0.0.44-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4d42cfd84a690f6654b2a4f0515027c21b692cf2512d32e6433f754893a95609", size = 10809741, upload-time = "2026-06-05T03:33:33.22Z" },
{ url = "https://files.pythonhosted.org/packages/47/f5/0b20ba6b66837a5a37bab7f74ac0732c66e766b5f0b2d55b30816b15f348/ty-0.0.44-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc47ae87e4cb7db2a9166bb23b78a905c3626e523296ec5bccf36b5e89bda6b", size = 11318153, upload-time = "2026-06-05T03:34:09.403Z" },
{ url = "https://files.pythonhosted.org/packages/ca/bb/b82ea730774a4f950f06d355fbc120d51eac7da23b57fc79ef6ff7c79cbb/ty-0.0.44-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46d867e80f16f421ac72c9a85240dbf050d62d9b3fbd10a8b5b082fb21679e0b", size = 11403108, upload-time = "2026-06-05T03:33:57.745Z" },
{ url = "https://files.pythonhosted.org/packages/8b/41/e2c83856165291049c702eda4e2ef3d3ebd875e8a0a77b8cc4ef3156aa1c/ty-0.0.44-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:411f5de0f96a4e4e5cccc3e0d55954c41f6a99ee6ca1fe5a7226cbc68406e053", size = 11944815, upload-time = "2026-06-05T03:34:15.793Z" },
{ url = "https://files.pythonhosted.org/packages/66/95/1fa6a101eb9d5bec042b87e5ca9c8fc349b75961beca6306f95af5cd5539/ty-0.0.44-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b15f01ecb4e2b46c05a1769293f9d32c3d4a1e4e7dfccf37c604d705dc3e3f4", size = 12476121, upload-time = "2026-06-05T03:33:51.529Z" },
{ url = "https://files.pythonhosted.org/packages/72/6a/da4b45b1229d39207c6140681c2aaf4f5691bcb1dc830b84450ca25c8f57/ty-0.0.44-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:edd32b7467af509c99c0244c2226a4e4c03400699003ec33373282ab931654d9", size = 12091340, upload-time = "2026-06-05T03:33:36.289Z" },
{ url = "https://files.pythonhosted.org/packages/16/c7/e1c9260ea5188195962ff1214ace418b5d69187e8fa7c0a1ec4994b8071b/ty-0.0.44-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:503a585f4007387c3afc58bae23a7ca1b9f236cbdb1a881dc36110655ceb1937", size = 11986201, upload-time = "2026-06-05T03:34:00.624Z" },
{ url = "https://files.pythonhosted.org/packages/92/f9/312bb112da9b1a7da295bb0426be85e72ad48da4e4266c36d77256b4058d/ty-0.0.44-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d28bcfa83243d77c2316944e8cf197f73597bf17d1ddc047d0b10a762531252", size = 12168475, upload-time = "2026-06-05T03:33:30.386Z" },
{ url = "https://files.pythonhosted.org/packages/02/de/64978d603f6c3e5dd7cb97eca2214567d8ad0c85fa4a7435b7852ae4b779/ty-0.0.44-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:56fd2dd0192def189715b25f5338f6222fb827884dc34111e50aa1c4e061cee5", size = 11292937, upload-time = "2026-06-05T03:34:06.448Z" },
{ url = "https://files.pythonhosted.org/packages/64/63/a625d8a3c71dcaa01988d330f849c465fe72ead4b0bbab44fe4bd6e672b5/ty-0.0.44-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7f8d990489032de1984e73c159f3e760d754cf83a602b874827d943821f63595", size = 11421560, upload-time = "2026-06-05T03:33:23.995Z" },
{ url = "https://files.pythonhosted.org/packages/99/96/61aeba0e629b0c91bd316ff94d00e38817ec493ae4316f39508988daa287/ty-0.0.44-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f61ffe72996a755432922fe90b28db593f572eb5cbf48e3ef4e67b282533d1b0", size = 11580282, upload-time = "2026-06-05T03:34:03.308Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f7/256e1538ce21cab67b381201444c42454de69d310059c4929d92a0ee9c48/ty-0.0.44-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2b237a143bac4f30cec9257d45f01e72da97030a80a09a2b69cfef065f09c37f", size = 12085723, upload-time = "2026-06-05T03:33:45.953Z" },
{ url = "https://files.pythonhosted.org/packages/d3/76/ec3957c10872643a98db7a7895101ad89c5b7cba4bc6c4aebbbfc91756cc/ty-0.0.44-py3-none-win32.whl", hash = "sha256:6a24586c65419223ac5bab4822d49ab493a5d19ea2a897514284c232b9d6166a", size = 10892978, upload-time = "2026-06-05T03:34:12.603Z" },
{ url = "https://files.pythonhosted.org/packages/a5/7d/ba24050432196e7d7f03945e5c379951593c48e04e5c5d5275cfc4624791/ty-0.0.44-py3-none-win_amd64.whl", hash = "sha256:8cccb27e348c89a9733fbad1b2efadfbad79b107c7e52adb52dfd8a70156a38d", size = 11987058, upload-time = "2026-06-05T03:33:42.692Z" },
{ url = "https://files.pythonhosted.org/packages/71/34/16ec3f1fec75292d9c56a8b5fef037ceaba85a5c30562206c1a245a00a67/ty-0.0.44-py3-none-win_arm64.whl", hash = "sha256:58049504e7a12bf1957f24a5384a332c94d5590127083a80db5e5a1bed34190b", size = 11329961, upload-time = "2026-06-05T03:33:39.427Z" },
{ url = "https://files.pythonhosted.org/packages/ae/4c/c54937e4ff3fa7b34a99ea3387ec766bf0ad98dc8df8d792e89b388e658e/ty-0.0.64-py3-none-linux_armv6l.whl", hash = "sha256:3830a6675ab43635ced1c4c557f380ac4a49e9414e03975e4e4e8db644c64944", size = 12118357, upload-time = "2026-07-27T18:32:08.702Z" },
{ url = "https://files.pythonhosted.org/packages/ce/aa/a839ee2bc78e943d079e6abe199a97b4eeffb7e5c9a57326d69de452186a/ty-0.0.64-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3ff07d7bc32a2135f58afe57393789a32ea2fed1a66216129a8e535e76043903", size = 11790882, upload-time = "2026-07-27T18:32:10.997Z" },
{ url = "https://files.pythonhosted.org/packages/4c/de/19f14357888a7198438926303753cf749428e3d62e8980ff1e9a72a78402/ty-0.0.64-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4f6d1c7f897cca05d12bacbf1435150d5ffa496099515aa6ed303c8b29e1d0bb", size = 11317394, upload-time = "2026-07-27T18:32:13.162Z" },
{ url = "https://files.pythonhosted.org/packages/08/2f/f54462300535ab99b551eda733177be2eef5dbc2997d3fdb357c4ddd760a/ty-0.0.64-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:138d6c37ad4bf8583aa7a9b29d90954151d7856f9910a03eae3eb34b34c57215", size = 11863042, upload-time = "2026-07-27T18:32:15.307Z" },
{ url = "https://files.pythonhosted.org/packages/5e/95/dbecf745520ebe8bd7b02fc55eee6441c9be312ebf6addce605eb52740dd/ty-0.0.64-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ed9719d1b7b66fb8efe073d860208a44c40af1f6cd5c2364aa9b323a1e579b4", size = 11910730, upload-time = "2026-07-27T18:32:17.467Z" },
{ url = "https://files.pythonhosted.org/packages/3b/26/12cfd40028e51ceed7b3cb645281c61c02eb64ff9fb0c09231d65c30ff25/ty-0.0.64-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68b23e5169e2137b5f1de7169ab0cebecab6c8eda374c34c1f6394308f58242", size = 12631936, upload-time = "2026-07-27T18:32:19.533Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d0/65ffc2b0a686347193c6f98e9421a7fc2a96fc3cd0b1001cf7cf284baff9/ty-0.0.64-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f41cb07d89d32626fcaf3ed4d262778fcb28b2628b6ed1e172cd7b18820668d8", size = 13171049, upload-time = "2026-07-27T18:32:22.026Z" },
{ url = "https://files.pythonhosted.org/packages/f6/a4/975a5961842dcd6fa60f0770a102c0bba7509da909ab652919bfcdcd4fc7/ty-0.0.64-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5f24e1504ab9e212f92356b82fe088fdeb3a39f9a2f4ff25e505d2e1d0db9056", size = 12826438, upload-time = "2026-07-27T18:32:24.178Z" },
{ url = "https://files.pythonhosted.org/packages/af/ef/dfb9b7f9bcc032d3b540b0d1f55f532a336e2fb41b1bd539c05ae81a151a/ty-0.0.64-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86db830cb914bb33bb8247b66ccea58de4496c2391bd42658aba744b439f3290", size = 12440880, upload-time = "2026-07-27T18:32:26.341Z" },
{ url = "https://files.pythonhosted.org/packages/ee/d0/bedac20505e8a8f5501ad73d7d15d8e421a563fef59993909a23036929a4/ty-0.0.64-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:652ee3d6d03bea76cd2fe8949c78bb5970394ebd7c5fd270e9518c0dee1b931d", size = 12782439, upload-time = "2026-07-27T18:32:28.642Z" },
{ url = "https://files.pythonhosted.org/packages/79/d5/795733f13ceff1378f08b3de0c49d0f518df220ed856b0dfac869f3b7c81/ty-0.0.64-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4838768295774a86e95f9ec5633e739d016adbbb69dcbdc62f3549578a11f624", size = 11814821, upload-time = "2026-07-27T18:32:30.632Z" },
{ url = "https://files.pythonhosted.org/packages/52/3e/9d99cd1e1831003434f508ed9f258a56543194afc3bbe051eba2545fa676/ty-0.0.64-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5a3d700669868599edf39ce5125196682f15a8880cc8c669bc2a83b99984d99b", size = 11928678, upload-time = "2026-07-27T18:32:32.888Z" },
{ url = "https://files.pythonhosted.org/packages/b8/3d/448f49a3503fb119a34348a5714bb92001f252fadbbb12d645f2e744b557/ty-0.0.64-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b161f0a82a8e2f2432db3bf7702b4d3924fa9486ba0014f6710a160fc157df0d", size = 12202249, upload-time = "2026-07-27T18:32:34.905Z" },
{ url = "https://files.pythonhosted.org/packages/dd/9b/75768e562cec990d189dc05807ae72890b20ffbd1e1f43597bb98db63c60/ty-0.0.64-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:39b9dd42908df47c2dc57dda87e656fab97097ffd2618474bbdea986af0d6a9d", size = 12548817, upload-time = "2026-07-27T18:32:36.995Z" },
{ url = "https://files.pythonhosted.org/packages/34/89/44cc276ea6ca0245495014758ffeadde1798fb0b5840c9cf36d8d2ed3250/ty-0.0.64-py3-none-win32.whl", hash = "sha256:d0676ab0e0935795e5843baa28dd5e366dc343d7c0945a996df9eab8e0644885", size = 11545474, upload-time = "2026-07-27T18:32:39.389Z" },
{ url = "https://files.pythonhosted.org/packages/01/7e/d1c8a871a38d17c8f168b9a6975f6247f7660f8334e517656a5e4b4a4858/ty-0.0.64-py3-none-win_amd64.whl", hash = "sha256:dcb9bd31f54097e362b776c26ab4564d4564cdd1355cb883481167a26c03cc3f", size = 12542987, upload-time = "2026-07-27T18:32:41.412Z" },
{ url = "https://files.pythonhosted.org/packages/35/4d/6d18640d0204cacd69abbaca95ad6a34c6d7e9169e9051d0117b17b827ec/ty-0.0.64-py3-none-win_arm64.whl", hash = "sha256:82cc34c1ad9a8feb6059aef193bebcecc656e548f6fab3d518bcd8b57d198d39", size = 11899263, upload-time = "2026-07-27T18:32:43.366Z" },
]
[[package]]
name = "typer"
version = "0.26.7"
version = "0.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@@ -1362,9 +1340,9 @@ dependencies = [
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/ed/ef06584ccdd5c410df0837951ecd7e15d9a6144ea1bd4c73cecab1a89891/typer-0.26.7.tar.gz", hash = "sha256:e314a34c617e419c091b2830dda3ea1f257134ff593061a8f5b9717ab8dddb3a", size = 201709, upload-time = "2026-06-03T07:18:06.843Z" }
sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/2201973529af2c954de0bb725323c3aaed6d7f0ceee8f550dec9185df013/typer-0.26.7-py3-none-any.whl", hash = "sha256:5c87cfbc5d34491c5346ebf49c23e18d56ccb863268d3a8d592b26087c2f5e58", size = 122456, upload-time = "2026-06-03T07:18:05.732Z" },
{ url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" },
]
[[package]]
@@ -1426,7 +1404,7 @@ wheels = [
[[package]]
name = "zensical"
version = "0.0.43"
version = "0.0.51"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@@ -1438,18 +1416,18 @@ dependencies = [
{ name = "pyyaml" },
{ name = "tomli" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d4/85/ec45162e7824a8f879d887ef0774ee65926bf7d1064e2eebccc7eaee3378/zensical-0.0.43.tar.gz", hash = "sha256:dc2d3804ff562795c1024130e0c3ce79736467930729dda314f096d0e35b98c8", size = 3932396, upload-time = "2026-05-19T09:44:07.418Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/f7/d07ffb268ca86afb26b7f32dbabe25dec03d3aa63ba4d876720c84681d33/zensical-0.0.51.tar.gz", hash = "sha256:de25de067bedfa18f916d7f366fd64a7fbf09bfcc615b44d1ddbe3b5fe02ab49", size = 3979640, upload-time = "2026-07-17T18:08:03.445Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/c2/55e0709607ae41c266987c3b91a1a9702b37fbbef0d07eddfe5e25c2d823/zensical-0.0.43-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:17c335362b6bac3a50178181694a964f6d9f0c516fc532129ba5a0a5c4103fb6", size = 12706531, upload-time = "2026-05-19T09:43:32.729Z" },
{ url = "https://files.pythonhosted.org/packages/2c/64/ce8627bc5ea30556162b29b041fe97d6a6aef2a87b51f12def628e4fa608/zensical-0.0.43-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8fe97f185194215f6193af45a17d2b30ebd72c8113e3650f2d7d6767b9c2206", size = 12563012, upload-time = "2026-05-19T09:43:35.962Z" },
{ url = "https://files.pythonhosted.org/packages/66/d1/533bc9454f0e06b3d9d8bd2e7ac405308c3d4dee6572acab98f0ed6d1c07/zensical-0.0.43-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c4c85978c765b3e7f347e8102dfe1373d4bbe4229d7008b6bdbf352f1fbcd7f", size = 12947599, upload-time = "2026-05-19T09:43:38.754Z" },
{ url = "https://files.pythonhosted.org/packages/75/a0/94f47d6fb592997be7ab9526938c929f0199adf2637c3c2b2b9b2101b28e/zensical-0.0.43-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90d7c06ffd07b2bdf78bef041d541baba8a3ea51fd2dd84dbdbc5b0229076524", size = 12904911, upload-time = "2026-05-19T09:43:42.434Z" },
{ url = "https://files.pythonhosted.org/packages/96/fb/1db3ad9a86ff772f74a8bc60ad5b447aa02a158e70f94adacf50bdd5c40f/zensical-0.0.43-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:60022f4a6b95e46ec0023f51052fcd491743b3ebd08c0066b22a5cf1e741fecd", size = 13269386, upload-time = "2026-05-19T09:43:45.387Z" },
{ url = "https://files.pythonhosted.org/packages/31/ee/b24fd0f94885519d851c35615b086d069a1077b0198021a56755395a4633/zensical-0.0.43-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e278eb948a0b7545d50609d713c7c27e366dade4523ff73a311a5d5f136518a", size = 12999364, upload-time = "2026-05-19T09:43:48.549Z" },
{ url = "https://files.pythonhosted.org/packages/28/78/401ccd7afd9d2690f81b5319b7f1eed05108154ce20e4207053914518c1c/zensical-0.0.43-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b85e5ab99fbda13823e67c43a4be6e5ebda6600602969c6575e143f20ac203fd", size = 13124392, upload-time = "2026-05-19T09:43:50.965Z" },
{ url = "https://files.pythonhosted.org/packages/98/b3/9af6eba5826b0ef143fc8308bd1e219e221441e307a958e39f824ba9ab53/zensical-0.0.43-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:751385accc92cccfd4560dabed7c423870686ef6ede244a67e5c96286af25e8f", size = 13177538, upload-time = "2026-05-19T09:43:53.964Z" },
{ url = "https://files.pythonhosted.org/packages/be/6b/cd090bd6659d32692487206469988ee84d41aa6de4cdf9e380f847da90e2/zensical-0.0.43-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:dd3ff5bfa6e65cf3d2550dc639c3da2a3bfa11087b83d57e06623c4c1607d583", size = 13327086, upload-time = "2026-05-19T09:43:56.8Z" },
{ url = "https://files.pythonhosted.org/packages/79/5b/ac2555354b5a53cb9c2c942811905c47be0b9f5603d3c1328ee8564333eb/zensical-0.0.43-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85055a115b12f49c6ab194dcf04f966fc06b690ed6a8ddddd819929fc5f340e6", size = 13284645, upload-time = "2026-05-19T09:43:59.329Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c6/1688ec6e5be15e3ab367d7804753291bfbdff3109b06e20c19ce30a7129c/zensical-0.0.43-cp310-abi3-win32.whl", hash = "sha256:8a75ddd4bb3cd3c4a8e71d2ebae44c5611fd636c1d355c6124dd96e2f9c52838", size = 12256740, upload-time = "2026-05-19T09:44:02.102Z" },
{ url = "https://files.pythonhosted.org/packages/ca/a8/d967e70eac810a7e9eb8c5150d6d02848a1f42260f42977c71debed3cb02/zensical-0.0.43-cp310-abi3-win_amd64.whl", hash = "sha256:03a9d1744a6394ad66c355d6f1de04cfd92efa525b0b94bf6dbf6971c5cd2c6b", size = 12496166, upload-time = "2026-05-19T09:44:04.915Z" },
{ url = "https://files.pythonhosted.org/packages/48/21/02db3e1fb3904016bfac310037c95b9f1eaaf0ffe7b4a84f14263a7d95df/zensical-0.0.51-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:134d776afa526098e05e34713e2f577c075e57a232e01b97842bb0206716afce", size = 12791154, upload-time = "2026-07-17T18:07:20.748Z" },
{ url = "https://files.pythonhosted.org/packages/a2/35/b0d96f58253514cb3d08f5779020ab01ee5472334fb984b92e3fc9e9c9ac/zensical-0.0.51-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:e97ab39668ae3b452c550634e921a0336443743aae5e1fe031c7bb57d049e535", size = 12692190, upload-time = "2026-07-17T18:07:24.553Z" },
{ url = "https://files.pythonhosted.org/packages/2e/90/7a60e126a10c37c6b789938ff17e73fe76bba707fa029cb40ac659aeaa82/zensical-0.0.51-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c9579809f88608e7aa2cff516fff9d267d74a843cf6088a5f4227de2f092bb5", size = 13139337, upload-time = "2026-07-17T18:07:27.885Z" },
{ url = "https://files.pythonhosted.org/packages/ae/c3/9101c97b90d4713ef2816db03366a45ae4762efebffd296737a2dd2df325/zensical-0.0.51-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296dc7a14aa28b81a58eb57df2d5c9c9a4b0de7e90c11d99c943354287952925", size = 13069851, upload-time = "2026-07-17T18:07:31.814Z" },
{ url = "https://files.pythonhosted.org/packages/d2/79/0474df9e15a2c18f6281a786e10177c1b6e16feac1c568e7f36ad39b339c/zensical-0.0.51-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f779d2d87b4bf228cf2e279bc0ae6bcf3b36a9335ff283a317d01f7c15ae46b2", size = 13451083, upload-time = "2026-07-17T18:07:35.543Z" },
{ url = "https://files.pythonhosted.org/packages/fe/6f/91bbf78f704d5fd4c0c9be27d6bce3b6e4c2c339e4dcd6e7cf19ecda643c/zensical-0.0.51-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f813a1514a90890ca86248a8d54b81b2164bcbff11a6bcf11b01e1c01a1454", size = 13110446, upload-time = "2026-07-17T18:07:38.783Z" },
{ url = "https://files.pythonhosted.org/packages/d9/89/aa9a95f81771614c37bdc52b8ab21fcdef4c8de7c9cedf34e9bf62674281/zensical-0.0.51-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:186ef37e0eee0e969e2cfae47b1b97775e3164e2cba95c71faa4dd6ef47ed009", size = 13315871, upload-time = "2026-07-17T18:07:42.43Z" },
{ url = "https://files.pythonhosted.org/packages/08/11/1bf6e9ded29d376f8c12644cc4de04676b010fee8caa17f682606b1f16d5/zensical-0.0.51-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5d91ce246ed930224603083cef02ae8947132fc7c52901d72015ea03526fa58", size = 13344382, upload-time = "2026-07-17T18:07:46.066Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ec/663f16ff82d08b212e7c3236a88bd332f73331f94ddac1c91aaf882bbd1e/zensical-0.0.51-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:b1108eae82c6e8ffc33026f60b485c1512647a5333be4f547166b7c8877b98af", size = 13499628, upload-time = "2026-07-17T18:07:49.196Z" },
{ url = "https://files.pythonhosted.org/packages/60/b4/7f1b6c3cf06d9f6ff5216523168a5d6ccc693444d5ceeb911eca97b30d98/zensical-0.0.51-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6fa0ecaf14f56841bfc595fa141396350c72aafbec73a016ebe3c824ed21ac72", size = 13451420, upload-time = "2026-07-17T18:07:52.563Z" },
{ url = "https://files.pythonhosted.org/packages/0a/44/be4bc09ec8f69e7be1b07b875887961c4e0e478b03a10d2cc624ef28fbe6/zensical-0.0.51-cp310-abi3-win32.whl", hash = "sha256:fb7ff4946b72168759c6af0a29cf5de4c38aebe633a83292e8cd4145b5213cc2", size = 12375639, upload-time = "2026-07-17T18:07:56.081Z" },
{ url = "https://files.pythonhosted.org/packages/1f/85/aa827c244ed4f404e99a91c3ecf5e5adb62eca806a9e9c8e3333bbad8660/zensical-0.0.51-cp310-abi3-win_amd64.whl", hash = "sha256:12529d3d3991b63820952111dc1d1edc29b2c9b3a3abb16c243bcb649631ebf2", size = 12628965, upload-time = "2026-07-17T18:07:59.741Z" },
]
+1 -2
View File
@@ -121,7 +121,6 @@ Modules = [
{Models = "module/models.md"},
{Pytest = "module/pytest.md"},
{Schemas = "module/schemas.md"},
{Security = "module/security.md"},
]
[[project.nav]]
@@ -137,7 +136,6 @@ Reference = [
{Models = "reference/models.md"},
{Pytest = "reference/pytest.md"},
{Schemas = "reference/schemas.md"},
{Security = "reference/security.md"},
]
[[project.nav]]
@@ -147,6 +145,7 @@ Examples = [
[[project.nav]]
Migration = [
{"v5.0" = "migration/v5.md"},
{"v4.0" = "migration/v4.md"},
{"v3.0" = "migration/v3.md"},
{"v2.0" = "migration/v2.md"},