mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-05 08:04:08 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
651326d54b | ||
|
|
169bf710f0 | ||
|
|
adc0ff14c1 | ||
|
|
19a823dc8b | ||
|
|
c15aee22b0 | ||
|
|
7da08ffd9a | ||
|
|
51acf61d0f | ||
|
|
436caa3141 | ||
|
|
a0d51e043d | ||
|
|
507f3d99d1 | ||
|
|
a49d256f17 | ||
|
|
03433f53b1 | ||
|
|
3eed5f01d4 | ||
|
|
61dc4cf9b0 | ||
|
|
9eb7d006db | ||
|
|
31f8ad96f7 | ||
|
|
953d58e5cc | ||
|
|
498e75c624 | ||
|
|
541bfeb1a4 | ||
|
|
c16909f585 | ||
|
|
c256e58d64 | ||
|
|
040c023b8a | ||
|
|
06d49d5dc4
|
||
|
|
de06839a16 | ||
|
|
ff367e4281 | ||
|
|
9cb4c1474f | ||
|
|
44ba5bdd4b | ||
|
|
fe2c0f3eff | ||
|
|
70e0b3b9d5 | ||
|
|
1e021005bc |
@@ -11,7 +11,7 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
name: Lint (Ruff)
|
name: Lint (Ruff)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
@@ -39,7 +39,7 @@ jobs:
|
|||||||
name: Type Check (ty)
|
name: Type Check (ty)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v7
|
uses: astral-sh/setup-uv@v7
|
||||||
@@ -96,7 +96,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
if: matrix.python-version == '3.14'
|
if: matrix.python-version == '3.14'
|
||||||
uses: codecov/codecov-action@v6
|
uses: codecov/codecov-action@v7
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
report_type: coverage
|
report_type: coverage
|
||||||
@@ -105,7 +105,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload test results to Codecov
|
- name: Upload test results to Codecov
|
||||||
if: matrix.python-version == '3.14'
|
if: matrix.python-version == '3.14'
|
||||||
uses: codecov/codecov-action@v6
|
uses: codecov/codecov-action@v7
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
report_type: test_results
|
report_type: test_results
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ Install only the extras you need:
|
|||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]"
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]"
|
uv add "fastapi-toolsets[metrics]"
|
||||||
uv add "fastapi-toolsets[security]"
|
|
||||||
uv add "fastapi-toolsets[pytest]"
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Optional
|
### 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
|
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||||
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ Install only the extras you need:
|
|||||||
```bash
|
```bash
|
||||||
uv add "fastapi-toolsets[cli]"
|
uv add "fastapi-toolsets[cli]"
|
||||||
uv add "fastapi-toolsets[metrics]"
|
uv add "fastapi-toolsets[metrics]"
|
||||||
uv add "fastapi-toolsets[security]"
|
|
||||||
uv add "fastapi-toolsets[pytest]"
|
uv add "fastapi-toolsets[pytest]"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,7 +56,6 @@ uv add "fastapi-toolsets[all]"
|
|||||||
|
|
||||||
### Optional
|
### 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
|
- **CLI**: Django-like command-line interface with fixture management and custom commands support
|
||||||
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
- **Metrics**: Prometheus metrics endpoint with provider/collector registry
|
||||||
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
- **Pytest Helpers**: Async test client, database session management, `pytest-xdist` support, and table cleanup utilities
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ The function creates and manages its own **dedicated session** internally, yield
|
|||||||
user.balance += 100
|
user.balance += 100
|
||||||
|
|
||||||
# With a custom lock mode
|
# 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)
|
await process_order(session, order_id)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -35,6 +35,6 @@ The function creates and manages its own **dedicated session** internally, yield
|
|||||||
user.balance += 100
|
user.balance += 100
|
||||||
|
|
||||||
# With a custom lock mode
|
# 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)
|
await process_order(session, order_id)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
# 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 ...`.
|
||||||
+16
-2
@@ -24,7 +24,7 @@ Configure the CLI in your `pyproject.toml`:
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[tool.fastapi-toolsets]
|
[tool.fastapi-toolsets]
|
||||||
cli = "myapp.cli:cli" # Custom Typer app
|
custom_cli = "myapp.cli:cli" # Custom Typer app
|
||||||
fixtures = "myapp.fixtures:registry" # FixtureRegistry instance
|
fixtures = "myapp.fixtures:registry" # FixtureRegistry instance
|
||||||
db_context = "myapp.db:db_context" # Async context manager for sessions
|
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
|
## 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:
|
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:
|
||||||
@@ -85,7 +99,7 @@ def hello():
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[tool.fastapi-toolsets]
|
[tool.fastapi-toolsets]
|
||||||
cli = "myapp.cli:cli"
|
custom_cli = "myapp.cli:cli"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -491,6 +491,16 @@ The distinct values for each facet field are returned in the `filter_attributes`
|
|||||||
!!! info "Key format uses `__` as a separator for relationship chains."
|
!!! 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).
|
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
|
## Sorting
|
||||||
|
|
||||||
!!! info "Added in `v1.3`"
|
!!! info "Added in `v1.3`"
|
||||||
|
|||||||
@@ -31,6 +31,25 @@ 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 `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
|
## 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.
|
[`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.
|
||||||
|
|||||||
+23
-7
@@ -65,6 +65,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.
|
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
|
### Custom contexts
|
||||||
|
|
||||||
Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is expected.
|
Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is expected.
|
||||||
@@ -80,6 +87,7 @@ class AppContext(str, Enum):
|
|||||||
def staging_data():
|
def staging_data():
|
||||||
return [Config(key="feature_x", enabled=True)]
|
return [Config(key="feature_x", enabled=True)]
|
||||||
|
|
||||||
|
# loads staging_data plus any Context.BASE fixtures
|
||||||
await load_fixtures_by_context(session, fixtures, AppContext.STAGING)
|
await load_fixtures_by_context(session, fixtures, AppContext.STAGING)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -108,8 +116,8 @@ def users():
|
|||||||
def users():
|
def users():
|
||||||
return [User(id=2, username="tester")]
|
return [User(id=2, username="tester")]
|
||||||
|
|
||||||
# loads both admin and tester
|
# loads both admin and tester (Context.BASE is included automatically)
|
||||||
await load_fixtures_by_context(session, fixtures, Context.BASE, Context.TESTING)
|
await load_fixtures_by_context(session, fixtures, Context.TESTING)
|
||||||
```
|
```
|
||||||
|
|
||||||
Registering two variants with overlapping context sets raises `ValueError`.
|
Registering two variants with overlapping context sets raises `ValueError`.
|
||||||
@@ -147,18 +155,26 @@ Fixtures with the same name are allowed as long as their context sets do not ove
|
|||||||
|
|
||||||
## Looking up fixture instances
|
## 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
|
```python
|
||||||
from fastapi_toolsets.fixtures import get_obj_by_attr
|
|
||||||
|
|
||||||
@fixtures.register(depends_on=["roles"])
|
@fixtures.register(depends_on=["roles"])
|
||||||
def users():
|
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)]
|
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
|
## Pytest integration
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -12,7 +12,6 @@ from fastapi_toolsets.fixtures import (
|
|||||||
FixtureRegistry,
|
FixtureRegistry,
|
||||||
load_fixtures,
|
load_fixtures,
|
||||||
load_fixtures_by_context,
|
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
|
||||||
|
|
||||||
## ::: fastapi_toolsets.fixtures.utils.load_fixtures_by_context
|
## ::: fastapi_toolsets.fixtures.utils.load_fixtures_by_context
|
||||||
|
|
||||||
## ::: fastapi_toolsets.fixtures.utils.get_obj_by_attr
|
|
||||||
|
|||||||
@@ -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
-6
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "5.0.0b1"
|
version = "5.0.0"
|
||||||
description = "Production-ready utilities for FastAPI applications"
|
description = "Production-ready utilities for FastAPI applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -50,17 +50,13 @@ cli = [
|
|||||||
metrics = [
|
metrics = [
|
||||||
"prometheus_client>=0.20.0",
|
"prometheus_client>=0.20.0",
|
||||||
]
|
]
|
||||||
security = [
|
|
||||||
"async-lru>=1.0",
|
|
||||||
"httpx>=0.25.0",
|
|
||||||
]
|
|
||||||
pytest = [
|
pytest = [
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
"pytest-xdist>=3.0.0",
|
"pytest-xdist>=3.0.0",
|
||||||
"pytest>=8.0.0",
|
"pytest>=8.0.0",
|
||||||
]
|
]
|
||||||
all = [
|
all = [
|
||||||
"fastapi-toolsets[cli,metrics,pytest,security]",
|
"fastapi-toolsets[cli,metrics,pytest]",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ Example usage:
|
|||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "5.0.0b1"
|
__version__ = "5.0.0"
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import typer
|
|||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
from rich.table import Table
|
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 ..config import get_db_context, get_fixtures_registry
|
||||||
from ..utils import async_command
|
from ..utils import async_command
|
||||||
|
|
||||||
@@ -16,13 +17,14 @@ fixture_cli = typer.Typer(
|
|||||||
no_args_is_help=True,
|
no_args_is_help=True,
|
||||||
)
|
)
|
||||||
console = Console()
|
console = Console()
|
||||||
|
logger = get_logger()
|
||||||
|
|
||||||
|
|
||||||
@fixture_cli.command("list")
|
@fixture_cli.command("list")
|
||||||
def list_fixtures(
|
def list_fixtures(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
context: Annotated[
|
context: Annotated[
|
||||||
Context | None,
|
str | None,
|
||||||
typer.Option(
|
typer.Option(
|
||||||
"--context",
|
"--context",
|
||||||
"-c",
|
"-c",
|
||||||
@@ -32,10 +34,10 @@ def list_fixtures(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""List all registered fixtures."""
|
"""List all registered fixtures."""
|
||||||
registry = get_fixtures_registry()
|
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:
|
if not fixtures:
|
||||||
print("No fixtures found.")
|
logger.info("No fixtures found.")
|
||||||
return
|
return
|
||||||
|
|
||||||
table = Table("Name", "Contexts", "Dependencies")
|
table = Table("Name", "Contexts", "Dependencies")
|
||||||
@@ -46,7 +48,7 @@ def list_fixtures(
|
|||||||
table.add_row(fixture.name, contexts, deps)
|
table.add_row(fixture.name, contexts, deps)
|
||||||
|
|
||||||
console.print(table)
|
console.print(table)
|
||||||
print(f"\nTotal: {len(fixtures)} fixture(s)")
|
logger.info("Total: %d fixture(s)", len(fixtures))
|
||||||
|
|
||||||
|
|
||||||
@fixture_cli.command("load")
|
@fixture_cli.command("load")
|
||||||
@@ -54,8 +56,8 @@ def list_fixtures(
|
|||||||
async def load(
|
async def load(
|
||||||
ctx: typer.Context,
|
ctx: typer.Context,
|
||||||
contexts: Annotated[
|
contexts: Annotated[
|
||||||
list[Context] | None,
|
list[str] | None,
|
||||||
typer.Argument(help="Contexts to load."),
|
typer.Argument(help="Contexts to load.", envvar="FIXTURES_CONTEXT"),
|
||||||
] = None,
|
] = None,
|
||||||
strategy: Annotated[
|
strategy: Annotated[
|
||||||
LoadStrategy,
|
LoadStrategy,
|
||||||
@@ -69,26 +71,27 @@ async def load(
|
|||||||
] = False,
|
] = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Load fixtures into the database."""
|
"""Load fixtures into the database."""
|
||||||
|
from ...fixtures import load_fixtures_by_context
|
||||||
|
|
||||||
registry = get_fixtures_registry()
|
registry = get_fixtures_registry()
|
||||||
db_context = get_db_context()
|
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)
|
ordered = registry.resolve_context_dependencies(*context_list)
|
||||||
|
|
||||||
if not ordered:
|
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
|
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:
|
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
|
return
|
||||||
|
|
||||||
async with db_context() as session:
|
async with db_context() as session:
|
||||||
@@ -97,4 +100,4 @@ async def load(
|
|||||||
)
|
)
|
||||||
|
|
||||||
total = sum(len(items) for items in result.values())
|
total = sum(len(items) for items in result.values())
|
||||||
print(f"\nLoaded {total} record(s) successfully.")
|
logger.info("Loaded %d record(s) successfully.", total)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import importlib
|
import importlib
|
||||||
import sys
|
import sys
|
||||||
from typing import TYPE_CHECKING, Any, Literal, overload
|
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ from .pyproject import find_pyproject, load_pyproject
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from ..fixtures import FixtureRegistry
|
from ..fixtures import FixtureRegistry
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
def _ensure_project_in_path():
|
def _ensure_project_in_path():
|
||||||
"""Add project root to sys.path if not installed in editable mode."""
|
"""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
|
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:
|
def get_fixtures_registry() -> FixtureRegistry:
|
||||||
"""Import and return the fixtures registry from config."""
|
"""Import and return the fixtures registry from config."""
|
||||||
from ..fixtures import FixtureRegistry
|
from ..fixtures import FixtureRegistry
|
||||||
|
|
||||||
import_path = get_config_value("fixtures", required=True)
|
return _import_typed("fixtures", FixtureRegistry, 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
|
|
||||||
|
|
||||||
|
|
||||||
def get_db_context() -> Any:
|
def get_db_context() -> Any:
|
||||||
@@ -111,15 +133,4 @@ def get_db_context() -> Any:
|
|||||||
|
|
||||||
def get_custom_cli() -> typer.Typer | None:
|
def get_custom_cli() -> typer.Typer | None:
|
||||||
"""Import and return the custom CLI Typer instance from config."""
|
"""Import and return the custom CLI Typer instance from config."""
|
||||||
import_path = get_config_value("custom_cli")
|
return _import_typed("custom_cli", typer.Typer, required=False)
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""CLI utility functions."""
|
"""CLI utility functions."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import functools
|
import functools
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
from typing import Any, ParamSpec, TypeVar
|
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)
|
@functools.wraps(func)
|
||||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
return asyncio.run(func(*args, **kwargs))
|
return asyncio.run(func(*args, **kwargs))
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ from ..types import (
|
|||||||
)
|
)
|
||||||
from .search import (
|
from .search import (
|
||||||
SearchConfig,
|
SearchConfig,
|
||||||
|
apply_search_joins,
|
||||||
build_facets,
|
build_facets,
|
||||||
build_filter_by,
|
build_filter_by,
|
||||||
build_search_filters,
|
build_search_filters,
|
||||||
@@ -51,7 +52,6 @@ from .search import (
|
|||||||
search_field_keys,
|
search_field_keys,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
_ForUpdateMode: TypeAlias = bool | Literal["nowait", "skip_locked"]
|
_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
|
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]):
|
class AsyncCrud(Generic[ModelType]):
|
||||||
"""Generic async CRUD operations for SQLAlchemy models.
|
"""Generic async CRUD operations for SQLAlchemy models.
|
||||||
|
|
||||||
@@ -184,16 +173,30 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
return cls.default_load_options
|
return cls.default_load_options
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _reload_with_options(
|
def _capture_pk_values(cls: type[Self], instance: ModelType) -> dict[str, Any]:
|
||||||
cls: type[Self], session: AsyncSession, instance: ModelType
|
"""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:
|
) -> ModelType:
|
||||||
"""Re-query instance by PK with default_load_options applied."""
|
"""Re-query by previously captured PK values, with default_load_options applied."""
|
||||||
mapper = cls.model.__mapper__
|
# Only called when cls.default_load_options is set (see call sites).
|
||||||
pk_filters = [
|
pk_filters = [
|
||||||
getattr(cls.model, col.key) == getattr(instance, col.key)
|
getattr(cls.model, key) == value for key, value in pk_values.items()
|
||||||
for col in mapper.primary_key
|
|
||||||
]
|
]
|
||||||
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
|
@classmethod
|
||||||
async def _resolve_m2m(
|
async def _resolve_m2m(
|
||||||
@@ -264,12 +267,12 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
cls: type[Self],
|
cls: type[Self],
|
||||||
filter_by: dict[str, Any] | BaseModel | None,
|
filter_by: dict[str, Any] | BaseModel | None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None,
|
facet_fields: Sequence[FacetFieldType] | None,
|
||||||
) -> tuple[list[Any], list[Any]]:
|
) -> tuple[dict[str, Any], list[Any]]:
|
||||||
"""Normalize filter_by and return (filters, joins) to apply to the query."""
|
"""Normalize filter_by and return ({facet_key: filter}, joins) to apply to the query."""
|
||||||
if isinstance(filter_by, BaseModel):
|
if isinstance(filter_by, BaseModel):
|
||||||
filter_by = filter_by.model_dump(exclude_none=True)
|
filter_by = filter_by.model_dump(exclude_none=True)
|
||||||
if not filter_by:
|
if not filter_by:
|
||||||
return [], []
|
return {}, []
|
||||||
resolved = cls._resolve_facet_fields(facet_fields)
|
resolved = cls._resolve_facet_fields(facet_fields)
|
||||||
return build_filter_by(filter_by, resolved or [])
|
return build_filter_by(filter_by, resolved or [])
|
||||||
|
|
||||||
@@ -280,8 +283,13 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
facet_fields: Sequence[FacetFieldType] | None,
|
facet_fields: Sequence[FacetFieldType] | None,
|
||||||
filters: list[Any],
|
filters: list[Any],
|
||||||
search_joins: list[Any],
|
search_joins: list[Any],
|
||||||
|
*,
|
||||||
|
include_facets: bool = True,
|
||||||
|
own_filters: dict[str, Any] | None = None,
|
||||||
) -> dict[str, list[Any]] | 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)
|
resolved = cls._resolve_facet_fields(facet_fields)
|
||||||
if not resolved:
|
if not resolved:
|
||||||
return None
|
return None
|
||||||
@@ -291,6 +299,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
resolved,
|
resolved,
|
||||||
base_filters=filters,
|
base_filters=filters,
|
||||||
base_joins=search_joins,
|
base_joins=search_joins,
|
||||||
|
own_filters=own_filters,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -474,6 +483,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
default_page_size: int = 20,
|
default_page_size: int = 20,
|
||||||
max_page_size: int = 100,
|
max_page_size: int = 100,
|
||||||
include_total: bool = True,
|
include_total: bool = True,
|
||||||
|
include_facets: bool = True,
|
||||||
search: bool = True,
|
search: bool = True,
|
||||||
filter: bool = True,
|
filter: bool = True,
|
||||||
order: bool = True,
|
order: bool = True,
|
||||||
@@ -489,6 +499,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
default_page_size: Default ``items_per_page`` value.
|
default_page_size: Default ``items_per_page`` value.
|
||||||
max_page_size: Maximum ``items_per_page`` value.
|
max_page_size: Maximum ``items_per_page`` value.
|
||||||
include_total: Whether to include total count (not a query param).
|
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.
|
search: Enable search query parameters.
|
||||||
filter: Enable facet filter query parameters.
|
filter: Enable facet filter query parameters.
|
||||||
order: Enable order query parameters.
|
order: Enable order query parameters.
|
||||||
@@ -518,7 +529,10 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
]
|
]
|
||||||
return cls._build_paginate_params(
|
return cls._build_paginate_params(
|
||||||
pagination_params=pagination_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",
|
dep_name=f"{cls.model.__name__}OffsetPaginateParams",
|
||||||
search=search,
|
search=search,
|
||||||
filter=filter,
|
filter=filter,
|
||||||
@@ -536,6 +550,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
*,
|
*,
|
||||||
default_page_size: int = 20,
|
default_page_size: int = 20,
|
||||||
max_page_size: int = 100,
|
max_page_size: int = 100,
|
||||||
|
include_facets: bool = True,
|
||||||
search: bool = True,
|
search: bool = True,
|
||||||
filter: bool = True,
|
filter: bool = True,
|
||||||
order: bool = True,
|
order: bool = True,
|
||||||
@@ -550,6 +565,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
Args:
|
Args:
|
||||||
default_page_size: Default ``items_per_page`` value.
|
default_page_size: Default ``items_per_page`` value.
|
||||||
max_page_size: Maximum ``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.
|
search: Enable search query parameters.
|
||||||
filter: Enable facet filter query parameters.
|
filter: Enable facet filter query parameters.
|
||||||
order: Enable order query parameters.
|
order: Enable order query parameters.
|
||||||
@@ -581,7 +597,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
]
|
]
|
||||||
return cls._build_paginate_params(
|
return cls._build_paginate_params(
|
||||||
pagination_params=pagination_params,
|
pagination_params=pagination_params,
|
||||||
pagination_fixed={},
|
pagination_fixed={"include_facets": include_facets},
|
||||||
dep_name=f"{cls.model.__name__}CursorPaginateParams",
|
dep_name=f"{cls.model.__name__}CursorPaginateParams",
|
||||||
search=search,
|
search=search,
|
||||||
filter=filter,
|
filter=filter,
|
||||||
@@ -601,6 +617,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
max_page_size: int = 100,
|
max_page_size: int = 100,
|
||||||
default_pagination_type: PaginationType = PaginationType.OFFSET,
|
default_pagination_type: PaginationType = PaginationType.OFFSET,
|
||||||
include_total: bool = True,
|
include_total: bool = True,
|
||||||
|
include_facets: bool = True,
|
||||||
search: bool = True,
|
search: bool = True,
|
||||||
filter: bool = True,
|
filter: bool = True,
|
||||||
order: bool = True,
|
order: bool = True,
|
||||||
@@ -617,6 +634,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
max_page_size: Maximum ``items_per_page`` value.
|
max_page_size: Maximum ``items_per_page`` value.
|
||||||
default_pagination_type: Default pagination strategy.
|
default_pagination_type: Default pagination strategy.
|
||||||
include_total: Whether to include total count (not a query param).
|
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.
|
search: Enable search query parameters.
|
||||||
filter: Enable facet filter query parameters.
|
filter: Enable facet filter query parameters.
|
||||||
order: Enable order query parameters.
|
order: Enable order query parameters.
|
||||||
@@ -665,7 +683,10 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
]
|
]
|
||||||
return cls._build_paginate_params(
|
return cls._build_paginate_params(
|
||||||
pagination_params=pagination_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",
|
dep_name=f"{cls.model.__name__}PaginateParams",
|
||||||
search=search,
|
search=search,
|
||||||
filter=filter,
|
filter=filter,
|
||||||
@@ -729,9 +750,14 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
setattr(db_model, rel_attr, related_instances)
|
setattr(db_model, rel_attr, related_instances)
|
||||||
|
|
||||||
session.add(db_model)
|
session.add(db_model)
|
||||||
await session.refresh(db_model)
|
pk_values: dict[str, Any] | None = None
|
||||||
if cls.default_load_options:
|
if cls.default_load_options:
|
||||||
db_model = await cls._reload_with_options(session, db_model)
|
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)
|
result = cast(ModelType, db_model)
|
||||||
if schema:
|
if schema:
|
||||||
return Response(data=schema.model_validate(result))
|
return Response(data=schema.model_validate(result))
|
||||||
@@ -1096,9 +1122,15 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
m2m_resolved = await cls._resolve_m2m(session, obj, only_set=True)
|
m2m_resolved = await cls._resolve_m2m(session, obj, only_set=True)
|
||||||
for rel_attr, related_instances in m2m_resolved.items():
|
for rel_attr, related_instances in m2m_resolved.items():
|
||||||
setattr(db_model, rel_attr, related_instances)
|
setattr(db_model, rel_attr, related_instances)
|
||||||
await session.refresh(db_model)
|
|
||||||
|
pk_values: dict[str, Any] | None = None
|
||||||
if cls.default_load_options:
|
if cls.default_load_options:
|
||||||
db_model = await cls._reload_with_options(session, db_model)
|
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:
|
if schema:
|
||||||
return Response(data=schema.model_validate(db_model))
|
return Response(data=schema.model_validate(db_model))
|
||||||
return db_model
|
return db_model
|
||||||
@@ -1270,6 +1302,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column: str | None = None,
|
search_column: str | None = None,
|
||||||
order_fields: Sequence[OrderFieldType] | None = None,
|
order_fields: Sequence[OrderFieldType] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
|
include_facets: bool = True,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
) -> OffsetPaginatedResponse[Any]:
|
) -> OffsetPaginatedResponse[Any]:
|
||||||
@@ -1291,6 +1324,9 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column: Restrict search to a single column key.
|
search_column: Restrict search to a single column key.
|
||||||
order_fields: Fields allowed for sorting (overrides class default).
|
order_fields: Fields allowed for sorting (overrides class default).
|
||||||
facet_fields: Columns to compute distinct values for (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.
|
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,
|
Keys must match the column.key of a facet field. Scalar → equality,
|
||||||
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
|
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
|
||||||
@@ -1303,7 +1339,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
offset = (page - 1) * items_per_page
|
offset = (page - 1) * items_per_page
|
||||||
|
|
||||||
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
|
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
|
||||||
filters.extend(fb_filters)
|
|
||||||
|
|
||||||
# Build search filters
|
# Build search filters
|
||||||
if search:
|
if search:
|
||||||
@@ -1317,6 +1352,11 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filters.extend(search_filters)
|
filters.extend(search_filters)
|
||||||
search_joins.extend(new_search_joins)
|
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
|
# Build query with joins
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
|
|
||||||
@@ -1324,11 +1364,11 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
q = _apply_joins(q, joins, outer_join)
|
q = _apply_joins(q, joins, outer_join)
|
||||||
|
|
||||||
# Apply search joins (always outer joins for search)
|
# 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)
|
# Apply order joins (relation joins required for order_by field)
|
||||||
if order_joins:
|
if order_joins:
|
||||||
q = _apply_search_joins(q, order_joins)
|
q = apply_search_joins(q, order_joins)
|
||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
@@ -1351,7 +1391,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
count_q = _apply_joins(count_q, joins, outer_join)
|
count_q = _apply_joins(count_q, joins, outer_join)
|
||||||
|
|
||||||
# Apply search joins to count query
|
# 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:
|
if filters:
|
||||||
count_q = count_q.where(and_(*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]
|
items: list[Any] = [schema.model_validate(item) for item in raw_items]
|
||||||
|
|
||||||
filter_attributes = await cls._build_filter_attributes(
|
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)
|
search_columns = cls._resolve_search_columns(search_fields)
|
||||||
order_columns = cls._resolve_order_columns(order_fields)
|
order_columns = cls._resolve_order_columns(order_fields)
|
||||||
@@ -1407,6 +1452,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column: str | None = None,
|
search_column: str | None = None,
|
||||||
order_fields: Sequence[OrderFieldType] | None = None,
|
order_fields: Sequence[OrderFieldType] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
|
include_facets: bool = True,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
) -> CursorPaginatedResponse[Any]:
|
) -> CursorPaginatedResponse[Any]:
|
||||||
@@ -1429,6 +1475,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column: Restrict search to a single column key.
|
search_column: Restrict search to a single column key.
|
||||||
order_fields: Fields allowed for sorting (overrides class default).
|
order_fields: Fields allowed for sorting (overrides class default).
|
||||||
facet_fields: Columns to compute distinct values for (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.
|
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,
|
Keys must match the column.key of a facet field. Scalar → equality,
|
||||||
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
|
list → IN clause. Raises InvalidFacetFilterError for unknown keys.
|
||||||
@@ -1440,7 +1488,6 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filters = list(filters) if filters else []
|
filters = list(filters) if filters else []
|
||||||
|
|
||||||
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
|
fb_filters, search_joins = cls._prepare_filter_by(filter_by, facet_fields)
|
||||||
filters.extend(fb_filters)
|
|
||||||
|
|
||||||
if cls.cursor_column is None:
|
if cls.cursor_column is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -1472,6 +1519,11 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
filters.extend(search_filters)
|
filters.extend(search_filters)
|
||||||
search_joins.extend(new_search_joins)
|
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
|
# Build query
|
||||||
q = select(cls.model)
|
q = select(cls.model)
|
||||||
|
|
||||||
@@ -1479,11 +1531,11 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
q = _apply_joins(q, joins, outer_join)
|
q = _apply_joins(q, joins, outer_join)
|
||||||
|
|
||||||
# Apply search joins (always outer joins)
|
# 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)
|
# Apply order joins (relation joins required for order_by field)
|
||||||
if order_joins:
|
if order_joins:
|
||||||
q = _apply_search_joins(q, order_joins)
|
q = apply_search_joins(q, order_joins)
|
||||||
|
|
||||||
if filters:
|
if filters:
|
||||||
q = q.where(and_(*filters))
|
q = q.where(and_(*filters))
|
||||||
@@ -1540,7 +1592,12 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
items: list[Any] = [schema.model_validate(item) for item in items_page]
|
items: list[Any] = [schema.model_validate(item) for item in items_page]
|
||||||
|
|
||||||
filter_attributes = await cls._build_filter_attributes(
|
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)
|
search_columns = cls._resolve_search_columns(search_fields)
|
||||||
order_columns = cls._resolve_order_columns(order_fields)
|
order_columns = cls._resolve_order_columns(order_fields)
|
||||||
@@ -1580,6 +1637,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column: str | None = ...,
|
search_column: str | None = ...,
|
||||||
order_fields: Sequence[OrderFieldType] | None = ...,
|
order_fields: Sequence[OrderFieldType] | None = ...,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = ...,
|
facet_fields: Sequence[FacetFieldType] | None = ...,
|
||||||
|
include_facets: bool = ...,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = ...,
|
filter_by: dict[str, Any] | BaseModel | None = ...,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
) -> OffsetPaginatedResponse[Any]: ...
|
) -> OffsetPaginatedResponse[Any]: ...
|
||||||
@@ -1606,6 +1664,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column: str | None = ...,
|
search_column: str | None = ...,
|
||||||
order_fields: Sequence[OrderFieldType] | None = ...,
|
order_fields: Sequence[OrderFieldType] | None = ...,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = ...,
|
facet_fields: Sequence[FacetFieldType] | None = ...,
|
||||||
|
include_facets: bool = ...,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = ...,
|
filter_by: dict[str, Any] | BaseModel | None = ...,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
) -> CursorPaginatedResponse[Any]: ...
|
) -> CursorPaginatedResponse[Any]: ...
|
||||||
@@ -1631,6 +1690,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column: str | None = None,
|
search_column: str | None = None,
|
||||||
order_fields: Sequence[OrderFieldType] | None = None,
|
order_fields: Sequence[OrderFieldType] | None = None,
|
||||||
facet_fields: Sequence[FacetFieldType] | None = None,
|
facet_fields: Sequence[FacetFieldType] | None = None,
|
||||||
|
include_facets: bool = True,
|
||||||
filter_by: dict[str, Any] | BaseModel | None = None,
|
filter_by: dict[str, Any] | BaseModel | None = None,
|
||||||
schema: type[BaseModel],
|
schema: type[BaseModel],
|
||||||
) -> OffsetPaginatedResponse[Any] | CursorPaginatedResponse[Any]:
|
) -> OffsetPaginatedResponse[Any] | CursorPaginatedResponse[Any]:
|
||||||
@@ -1661,6 +1721,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
order_fields: Fields allowed for sorting (overrides class default).
|
order_fields: Fields allowed for sorting (overrides class default).
|
||||||
facet_fields: Columns to compute distinct values for (overrides
|
facet_fields: Columns to compute distinct values for (overrides
|
||||||
class default).
|
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
|
filter_by: Dict of ``{column_key: value}`` to filter by declared
|
||||||
facet fields. Keys must match the ``column.key`` of a facet
|
facet fields. Keys must match the ``column.key`` of a facet
|
||||||
field. Scalar → equality, list → IN clause. Raises
|
field. Scalar → equality, list → IN clause. Raises
|
||||||
@@ -1691,6 +1753,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column=search_column,
|
search_column=search_column,
|
||||||
order_fields=order_fields,
|
order_fields=order_fields,
|
||||||
facet_fields=facet_fields,
|
facet_fields=facet_fields,
|
||||||
|
include_facets=include_facets,
|
||||||
filter_by=filter_by,
|
filter_by=filter_by,
|
||||||
schema=schema,
|
schema=schema,
|
||||||
)
|
)
|
||||||
@@ -1713,6 +1776,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
search_column=search_column,
|
search_column=search_column,
|
||||||
order_fields=order_fields,
|
order_fields=order_fields,
|
||||||
facet_fields=facet_fields,
|
facet_fields=facet_fields,
|
||||||
|
include_facets=include_facets,
|
||||||
filter_by=filter_by,
|
filter_by=filter_by,
|
||||||
schema=schema,
|
schema=schema,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"""Search utilities for AsyncCrud."""
|
"""Search utilities for AsyncCrud."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import functools
|
import functools
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from typing import TYPE_CHECKING, Any, Literal
|
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.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
@@ -159,8 +159,11 @@ def build_search_filters(
|
|||||||
else:
|
else:
|
||||||
column = field
|
column = field
|
||||||
|
|
||||||
# Build the filter (cast to String for non-text columns)
|
# Build the filter (cast to String only when needed, to preserve
|
||||||
column_as_string = column.cast(String)
|
# 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:
|
if config.case_sensitive:
|
||||||
filters.append(column_as_string.like(f"%{query}%"))
|
filters.append(column_as_string.like(f"%{query}%"))
|
||||||
else:
|
else:
|
||||||
@@ -181,6 +184,21 @@ def search_field_keys(fields: Sequence[SearchFieldType]) -> list[str]:
|
|||||||
return facet_keys(fields)
|
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]:
|
def facet_keys(facet_fields: Sequence[FacetFieldType]) -> list[str]:
|
||||||
"""Return a key for each facet field.
|
"""Return a key for each facet field.
|
||||||
|
|
||||||
@@ -207,6 +225,7 @@ async def build_facets(
|
|||||||
*,
|
*,
|
||||||
base_filters: "list[ColumnElement[bool]] | None" = None,
|
base_filters: "list[ColumnElement[bool]] | None" = None,
|
||||||
base_joins: list[InstrumentedAttribute[Any]] | None = None,
|
base_joins: list[InstrumentedAttribute[Any]] | None = None,
|
||||||
|
own_filters: "dict[str, ColumnElement[bool]] | None" = None,
|
||||||
) -> dict[str, list[Any]]:
|
) -> dict[str, list[Any]]:
|
||||||
"""Return distinct values for each facet field, respecting current filters.
|
"""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
|
facet_fields: Columns or relationship tuples to facet on
|
||||||
base_filters: Filter conditions already applied to the main query (search + caller filters)
|
base_filters: Filter conditions already applied to the main query (search + caller filters)
|
||||||
base_joins: Relationship joins already applied to the main query
|
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:
|
Returns:
|
||||||
Dict mapping column key to sorted list of distinct non-None values
|
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)
|
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):
|
if isinstance(field, tuple):
|
||||||
# Relationship chain: (User.role, Role.name) — last element is the column
|
# Relationship chain: (User.role, Role.name) — last element is the column
|
||||||
rels = field[:-1]
|
rels = field[:-1]
|
||||||
@@ -235,51 +263,48 @@ async def build_facets(
|
|||||||
|
|
||||||
col_type = column.property.columns[0].type
|
col_type = column.property.columns[0].type
|
||||||
is_array = isinstance(col_type, ARRAY)
|
is_array = isinstance(col_type, ARRAY)
|
||||||
|
enum_classes[key] = getattr(col_type, "enum_class", None)
|
||||||
|
|
||||||
if is_array:
|
filters = [
|
||||||
unnested = func.unnest(column).label(column.key)
|
*(base_filters or []),
|
||||||
q = select(unnested).select_from(model).distinct()
|
*(f for k, f in own_filters.items() if k != key),
|
||||||
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
|
|
||||||
]
|
]
|
||||||
return key, values
|
joins = [*(base_joins or []), *rels]
|
||||||
|
|
||||||
pairs = await asyncio.gather(
|
if is_array:
|
||||||
*[_query_facet(f, k) for f, k in zip(facet_fields, keys)]
|
unnested = apply_search_joins(
|
||||||
|
select(func.unnest(column).label("v")).select_from(model), joins
|
||||||
)
|
)
|
||||||
return dict(pairs)
|
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)
|
_EQUALITY_TYPES = (String, Integer, Numeric, Date, DateTime, Time, Enum, Uuid)
|
||||||
@@ -301,7 +326,7 @@ def _coerce_bool(value: Any) -> bool:
|
|||||||
def build_filter_by(
|
def build_filter_by(
|
||||||
filter_by: dict[str, Any],
|
filter_by: dict[str, Any],
|
||||||
facet_fields: Sequence[FacetFieldType],
|
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.
|
"""Translate a {column_key: value} dict into SQLAlchemy filter conditions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -309,7 +334,9 @@ def build_filter_by(
|
|||||||
facet_fields: Declared facet fields to validate keys against
|
facet_fields: Declared facet fields to validate keys against
|
||||||
|
|
||||||
Returns:
|
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:
|
Raises:
|
||||||
InvalidFacetFilterError: If a key in filter_by is not a declared facet field
|
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)
|
index[key] = (column, rels)
|
||||||
|
|
||||||
valid_keys = set(index)
|
valid_keys = set(index)
|
||||||
filters: list[ColumnElement[bool]] = []
|
filters: dict[str, ColumnElement[bool]] = {}
|
||||||
joins: list[InstrumentedAttribute[Any]] = []
|
joins: list[InstrumentedAttribute[Any]] = []
|
||||||
added_join_keys: set[str] = set()
|
added_join_keys: set[str] = set()
|
||||||
|
|
||||||
@@ -347,14 +374,14 @@ def build_filter_by(
|
|||||||
if isinstance(col_type, Boolean):
|
if isinstance(col_type, Boolean):
|
||||||
coerce = _coerce_bool
|
coerce = _coerce_bool
|
||||||
if isinstance(value, list):
|
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:
|
else:
|
||||||
filters.append(column == coerce(value))
|
filters[key] = column == coerce(value)
|
||||||
elif isinstance(col_type, ARRAY):
|
elif isinstance(col_type, ARRAY):
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
filters.append(column.overlap(value))
|
filters[key] = column.overlap(value)
|
||||||
else:
|
else:
|
||||||
filters.append(column.any(value))
|
filters[key] = column.any(value)
|
||||||
elif isinstance(col_type, Enum):
|
elif isinstance(col_type, Enum):
|
||||||
enum_class = col_type.enum_class
|
enum_class = col_type.enum_class
|
||||||
if enum_class is not None:
|
if enum_class is not None:
|
||||||
@@ -365,19 +392,19 @@ def build_filter_by(
|
|||||||
return enum_class[v] # lookup by name: "PENDING", "RED"
|
return enum_class[v] # lookup by name: "PENDING", "RED"
|
||||||
|
|
||||||
if isinstance(value, list):
|
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:
|
else:
|
||||||
filters.append(column == _coerce_enum(value))
|
filters[key] = column == _coerce_enum(value)
|
||||||
else: # pragma: no cover
|
else: # pragma: no cover
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
filters.append(column.in_(value))
|
filters[key] = column.in_(value)
|
||||||
else:
|
else:
|
||||||
filters.append(column == value)
|
filters[key] = column == value
|
||||||
elif isinstance(col_type, _EQUALITY_TYPES):
|
elif isinstance(col_type, _EQUALITY_TYPES):
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
filters.append(column.in_(value))
|
filters[key] = column.in_(value)
|
||||||
else:
|
else:
|
||||||
filters.append(column == value)
|
filters[key] = column == value
|
||||||
else:
|
else:
|
||||||
raise UnsupportedFacetTypeError(key, type(col_type).__name__)
|
raise UnsupportedFacetTypeError(key, type(col_type).__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator
|
|||||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import PostgresDsn
|
||||||
from sqlalchemy import exc as sa_exc
|
from sqlalchemy import exc as sa_exc
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import (
|
||||||
AsyncEngine,
|
AsyncEngine,
|
||||||
@@ -84,18 +85,20 @@ class Database:
|
|||||||
untouched).
|
untouched).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: Database connection URL (e.g. ``"postgresql+asyncpg://..."``).
|
url: Database connection URL. Accepts a plain string or a Pydantic
|
||||||
|
:class:`~pydantic.PostgresDsn`.
|
||||||
engine: An existing :class:`AsyncEngine` to reuse instead of *url*.
|
engine: An existing :class:`AsyncEngine` to reuse instead of *url*.
|
||||||
session_class: Session class for the sessionmaker (e.g. ``EventSession``).
|
session_class: Session class for the sessionmaker (e.g. ``EventSession``).
|
||||||
expire_on_commit: Expire attributes after commit. Defaults to ``False``.
|
expire_on_commit: Expire attributes after commit. Defaults to ``False``.
|
||||||
autoflush: Autoflush the session before queries. Defaults to ``True``.
|
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
|
**engine_options: Extra keyword arguments forwarded to
|
||||||
:func:`create_async_engine` (URL mode only, e.g. ``pool_size``,
|
:func:`create_async_engine` (URL mode only).
|
||||||
``echo``, ``connect_args``).
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
TypeError: If neither or both of *url* and *engine* are given, or if
|
TypeError: If neither or both of *url* and *engine* are given, or if
|
||||||
*engine_options* are passed together with *engine*.
|
*connect_args*/*engine_options* are passed together with *engine*.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
@@ -115,12 +118,13 @@ class Database:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
url: str | None = None,
|
url: str | PostgresDsn | None = None,
|
||||||
*,
|
*,
|
||||||
engine: AsyncEngine | None = None,
|
engine: AsyncEngine | None = None,
|
||||||
session_class: type[AsyncSession] = AsyncSession,
|
session_class: type[AsyncSession] = AsyncSession,
|
||||||
expire_on_commit: bool = False,
|
expire_on_commit: bool = False,
|
||||||
autoflush: bool = True,
|
autoflush: bool = True,
|
||||||
|
connect_args: dict[str, Any] | None = None,
|
||||||
**engine_options: Any,
|
**engine_options: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
if (url is None) == (engine is None):
|
if (url is None) == (engine is None):
|
||||||
@@ -128,10 +132,10 @@ class Database:
|
|||||||
"Database requires exactly one of 'url' or 'engine' "
|
"Database requires exactly one of 'url' or 'engine' "
|
||||||
"(got both or neither)."
|
"(got both or neither)."
|
||||||
)
|
)
|
||||||
if engine is not None and engine_options:
|
if engine is not None and (engine_options or connect_args is not None):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
"engine_options are only valid in URL mode; configure the "
|
"connect_args/engine_options are only valid in URL mode; "
|
||||||
"engine you pass via 'engine=' yourself."
|
"configure the engine you pass via 'engine=' yourself."
|
||||||
)
|
)
|
||||||
|
|
||||||
if engine is not None:
|
if engine is not None:
|
||||||
@@ -140,7 +144,11 @@ class Database:
|
|||||||
else:
|
else:
|
||||||
assert url is not None # guaranteed by the XOR check above
|
assert url is not None # guaranteed by the XOR check above
|
||||||
self._owns_engine = True
|
self._owns_engine = True
|
||||||
self.engine = create_async_engine(url, **engine_options)
|
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._sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||||
self.engine,
|
self.engine,
|
||||||
class_=session_class,
|
class_=session_class,
|
||||||
|
|||||||
@@ -57,7 +57,20 @@ async def wait_for_row_change(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
instance = await session.get(model, pk_value)
|
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:
|
if instance is None:
|
||||||
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
|
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
|
||||||
|
|
||||||
@@ -79,12 +92,15 @@ async def wait_for_row_change(
|
|||||||
f"with pk={pk_value!r} within {timeout}s"
|
f"with pk={pk_value!r} within {timeout}s"
|
||||||
)
|
)
|
||||||
|
|
||||||
session.expunge(instance)
|
instance = await _reload()
|
||||||
instance = await session.get(model, pk_value)
|
|
||||||
|
|
||||||
if instance is None:
|
if instance is None:
|
||||||
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} was deleted")
|
raise NotFoundError(
|
||||||
|
f"{model.__name__} with pk={pk_value!r} was deleted"
|
||||||
|
)
|
||||||
|
|
||||||
current = {col: getattr(instance, col) for col in watch_cols}
|
current = {col: getattr(instance, col) for col in watch_cols}
|
||||||
if current != initial:
|
if current != initial:
|
||||||
return instance
|
return instance
|
||||||
|
finally:
|
||||||
|
await watcher.close()
|
||||||
|
|||||||
@@ -1,21 +1,34 @@
|
|||||||
"""Fixture system for seeding databases with dependency resolution."""
|
"""Fixture system for seeding databases with dependency resolution."""
|
||||||
|
|
||||||
from .enum import LoadStrategy
|
from typing import TYPE_CHECKING
|
||||||
from .registry import Context, FixtureRegistry
|
|
||||||
from .utils import (
|
from .enum import Context, LoadStrategy
|
||||||
get_field_by_attr,
|
|
||||||
get_obj_by_attr,
|
if TYPE_CHECKING:
|
||||||
load_fixtures,
|
from .registry import FixtureRegistry
|
||||||
load_fixtures_by_context,
|
from .utils import load_fixtures, load_fixtures_by_context
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Context",
|
"Context",
|
||||||
"FixtureRegistry",
|
"FixtureRegistry",
|
||||||
"LoadStrategy",
|
"LoadStrategy",
|
||||||
"get_field_by_attr",
|
|
||||||
"get_obj_by_attr",
|
|
||||||
"load_fixtures",
|
"load_fixtures",
|
||||||
"load_fixtures_by_context",
|
"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)
|
||||||
|
|||||||
@@ -7,11 +7,8 @@ from typing import Any, cast
|
|||||||
|
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from ..logger import get_logger
|
|
||||||
from .enum import Context
|
from .enum import Context
|
||||||
|
|
||||||
logger = get_logger()
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_contexts(
|
def _normalize_contexts(
|
||||||
contexts: list[str | Enum] | tuple[str | Enum, ...],
|
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]
|
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
|
@dataclass
|
||||||
class Fixture:
|
class Fixture:
|
||||||
"""A fixture definition with metadata."""
|
"""A fixture definition with metadata."""
|
||||||
@@ -70,8 +72,6 @@ class FixtureRegistry:
|
|||||||
@fixtures.register(contexts=[Context.TESTING])
|
@fixtures.register(contexts=[Context.TESTING])
|
||||||
def users():
|
def users():
|
||||||
return [User(id=2, username="tester")]
|
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
|
ValueError: If the fixture has multiple context variants — use
|
||||||
:meth:`get_variants` in that case.
|
:meth:`get_variants` in that case.
|
||||||
"""
|
"""
|
||||||
if name not in self._fixtures:
|
variants = self.get_variants(name)
|
||||||
raise KeyError(f"Fixture '{name}' not found")
|
|
||||||
variants = self._fixtures[name]
|
|
||||||
if len(variants) > 1:
|
if len(variants) > 1:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Fixture '{name}' has {len(variants)} context variants. "
|
f"Fixture '{name}' has {len(variants)} context variants. "
|
||||||
@@ -205,8 +203,9 @@ class FixtureRegistry:
|
|||||||
Args:
|
Args:
|
||||||
name: Fixture name.
|
name: Fixture name.
|
||||||
*contexts: If given, only return variants whose context set
|
*contexts: If given, only return variants whose context set
|
||||||
intersects with these values. Both :class:`Context` enum
|
intersects with these values (:class:`Context.BASE` variants
|
||||||
values and plain strings are accepted.
|
are always included). Both :class:`Context` enum values and
|
||||||
|
plain strings are accepted.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of matching :class:`Fixture` objects (may be empty when a
|
List of matching :class:`Fixture` objects (may be empty when a
|
||||||
@@ -220,16 +219,89 @@ class FixtureRegistry:
|
|||||||
variants = self._fixtures[name]
|
variants = self._fixtures[name]
|
||||||
if not contexts:
|
if not contexts:
|
||||||
return list(variants)
|
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]
|
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]:
|
def get_all(self) -> list[Fixture]:
|
||||||
"""Get all registered fixtures (all variants of all names)."""
|
"""Get all registered fixtures (all variants of all names)."""
|
||||||
return [f for variants in self._fixtures.values() for f in variants]
|
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]:
|
def get_by_context(self, *contexts: str | Enum) -> list[Fixture]:
|
||||||
"""Get fixtures for specific contexts."""
|
"""Get fixtures for specific contexts."""
|
||||||
context_values = set(_normalize_contexts(contexts))
|
context_values = _context_filter_values(contexts)
|
||||||
return [
|
return [
|
||||||
f
|
f
|
||||||
for variants in self._fixtures.values()
|
for variants in self._fixtures.values()
|
||||||
@@ -254,7 +326,6 @@ class FixtureRegistry:
|
|||||||
ValueError: If circular dependency detected
|
ValueError: If circular dependency detected
|
||||||
"""
|
"""
|
||||||
resolved: list[str] = []
|
resolved: list[str] = []
|
||||||
seen: set[str] = set()
|
|
||||||
visiting: set[str] = set()
|
visiting: set[str] = set()
|
||||||
|
|
||||||
def visit(name: str) -> None:
|
def visit(name: str) -> None:
|
||||||
@@ -264,25 +335,11 @@ class FixtureRegistry:
|
|||||||
raise ValueError(f"Circular dependency detected: {name}")
|
raise ValueError(f"Circular dependency detected: {name}")
|
||||||
|
|
||||||
visiting.add(name)
|
visiting.add(name)
|
||||||
variants = self._fixtures.get(name)
|
for dep in self.get_dependencies(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:
|
|
||||||
visit(dep)
|
visit(dep)
|
||||||
|
|
||||||
visiting.remove(name)
|
visiting.remove(name)
|
||||||
resolved.append(name)
|
resolved.append(name)
|
||||||
seen.add(name)
|
|
||||||
|
|
||||||
for name in names:
|
for name in names:
|
||||||
visit(name)
|
visit(name)
|
||||||
@@ -303,9 +360,4 @@ class FixtureRegistry:
|
|||||||
# appear multiple times if it has variants in different contexts).
|
# appear multiple times if it has variants in different contexts).
|
||||||
names = list(dict.fromkeys(f.name for f in context_fixtures))
|
names = list(dict.fromkeys(f.name for f in context_fixtures))
|
||||||
|
|
||||||
all_deps: set[str] = set()
|
return self.resolve_dependencies(*names)
|
||||||
for name in names:
|
|
||||||
deps = self.resolve_dependencies(name)
|
|
||||||
all_deps.update(deps)
|
|
||||||
|
|
||||||
return self.resolve_dependencies(*all_deps)
|
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
"""Fixture loading utilities for database seeding."""
|
"""Fixture loading utilities for database seeding."""
|
||||||
|
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Iterator
|
||||||
from enum import Enum
|
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 import inspect as sa_inspect
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 transaction
|
from ..db import transaction
|
||||||
from ..logger import get_logger
|
from ..logger import get_logger
|
||||||
from ..types import ModelType
|
|
||||||
from .enum import LoadStrategy
|
from .enum import LoadStrategy
|
||||||
from .registry import FixtureRegistry, _normalize_contexts
|
from .registry import FixtureRegistry, _normalize_contexts
|
||||||
|
|
||||||
@@ -93,17 +94,42 @@ def _group_by_column_set(
|
|||||||
return list(groups.values())
|
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(
|
async def _batch_insert(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
model_cls: type[DeclarativeBase],
|
model_cls: type[DeclarativeBase],
|
||||||
instances: list[DeclarativeBase],
|
instances: list[DeclarativeBase],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""INSERT all instances — raises on conflict (no duplicate handling)."""
|
"""INSERT all instances, raises on conflict."""
|
||||||
for cls in _get_table_chain(model_cls):
|
for cls, group_dicts, group_instances in _grouped_table_dicts(model_cls, instances):
|
||||||
dicts = [_instance_to_dict_for_cls(i, cls) for i in instances]
|
table = cast(Table, cls.__table__)
|
||||||
for group_dicts, _ in _group_by_column_set(dicts, instances):
|
missing_pk_cols = [
|
||||||
if group_dicts and group_dicts[0]: # pragma: no branch
|
col for col in table.primary_key.columns if col.key not in group_dicts[0]
|
||||||
await session.execute(pg_insert(cls).values(group_dicts))
|
]
|
||||||
|
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(
|
async def _batch_merge(
|
||||||
@@ -112,17 +138,14 @@ async def _batch_merge(
|
|||||||
instances: list[DeclarativeBase],
|
instances: list[DeclarativeBase],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""UPSERT: insert new rows, update existing ones with the provided values."""
|
"""UPSERT: insert new rows, update existing ones with the provided values."""
|
||||||
for cls in _get_table_chain(model_cls):
|
for cls, group_dicts, _ in _grouped_table_dicts(model_cls, instances):
|
||||||
pk_names = [col.name for col in cls.__table__.primary_key]
|
table = cast(Table, cls.__table__)
|
||||||
|
pk_names = [col.name for col in table.primary_key]
|
||||||
pk_names_set = set(pk_names)
|
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]
|
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]
|
stmt = pg_insert(table).values(group_dicts)
|
||||||
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)
|
|
||||||
|
|
||||||
inserted_keys = set(group_dicts[0])
|
inserted_keys = set(group_dicts[0])
|
||||||
update_cols = [col for col in non_pk_cols if col in inserted_keys]
|
update_cols = [col for col in non_pk_cols if col in inserted_keys]
|
||||||
@@ -169,8 +192,14 @@ async def _batch_skip_existing(
|
|||||||
loaded = list(no_pk)
|
loaded = list(no_pk)
|
||||||
if no_pk:
|
if no_pk:
|
||||||
no_pk_dicts = [_instance_to_dict(i) for i in 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):
|
for group_dicts, group_instances in _group_by_column_set(no_pk_dicts, no_pk):
|
||||||
await session.execute(pg_insert(model_cls).values(group_dicts))
|
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:
|
if with_pk_pairs:
|
||||||
with_pk = [i for i, _ in with_pk_pairs]
|
with_pk = [i for i, _ in with_pk_pairs]
|
||||||
@@ -196,6 +225,65 @@ async def _batch_skip_existing(
|
|||||||
return loaded
|
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(
|
async def _load_ordered(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
registry: FixtureRegistry,
|
registry: FixtureRegistry,
|
||||||
@@ -208,14 +296,11 @@ async def _load_ordered(
|
|||||||
|
|
||||||
for name in ordered_names:
|
for name in ordered_names:
|
||||||
variants = (
|
variants = (
|
||||||
registry.get_variants(name, *contexts)
|
registry.get_load_variants(name, *contexts)
|
||||||
if contexts is not None
|
if contexts is not None
|
||||||
else registry.get_variants(name)
|
else registry.get_variants(name)
|
||||||
)
|
)
|
||||||
|
|
||||||
if contexts is not None and not variants:
|
|
||||||
variants = registry.get_variants(name)
|
|
||||||
|
|
||||||
if not variants: # pragma: no cover
|
if not variants: # pragma: no cover
|
||||||
results[name] = []
|
results[name] = []
|
||||||
continue
|
continue
|
||||||
@@ -244,8 +329,10 @@ async def _load_ordered(
|
|||||||
case _: # pragma: no cover
|
case _: # pragma: no cover
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
loaded = await _refresh_loaded(session, loaded)
|
||||||
|
|
||||||
results[name] = 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
|
return results
|
||||||
|
|
||||||
@@ -264,56 +351,6 @@ def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
|||||||
return 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(
|
async def load_fixtures(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
registry: FixtureRegistry,
|
registry: FixtureRegistry,
|
||||||
@@ -348,8 +385,8 @@ async def load_fixtures_by_context(
|
|||||||
Args:
|
Args:
|
||||||
session: Database session
|
session: Database session
|
||||||
registry: Fixture registry
|
registry: Fixture registry
|
||||||
*contexts: Contexts to load (e.g., ``Context.BASE``, ``Context.TESTING``,
|
*contexts: Contexts to load (e.g., ``Context.TESTING``, or plain
|
||||||
or plain strings for custom contexts)
|
strings for custom contexts)
|
||||||
strategy: How to handle existing records
|
strategy: How to handle existing records
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from collections.abc import Callable
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import event
|
from sqlalchemy import event, select, tuple_
|
||||||
from sqlalchemy import inspect as sa_inspect
|
from sqlalchemy import inspect as sa_inspect
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm.attributes import set_committed_value as _sa_set_committed_value
|
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"
|
_SESSION_UPDATES = "_ft_updates"
|
||||||
_DEFERRED_STRATEGY_KEY = (("deferred", True), ("instrument", True))
|
_DEFERRED_STRATEGY_KEY = (("deferred", True), ("instrument", True))
|
||||||
_EVENT_HANDLERS: dict[tuple[type, ModelEvent], list[Callable[..., Any]]] = {}
|
_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]]] = {}
|
_HANDLER_CACHE: dict[tuple[type, ModelEvent], list[Callable[..., Any]]] = {}
|
||||||
|
|
||||||
|
|
||||||
def _invalidate_caches() -> None:
|
def _invalidate_caches() -> None:
|
||||||
"""Clear lookup caches after handler registration."""
|
"""Clear lookup caches after handler registration."""
|
||||||
_WATCHED_CACHE.clear()
|
|
||||||
_HANDLER_CACHE.clear()
|
_HANDLER_CACHE.clear()
|
||||||
|
|
||||||
|
|
||||||
@@ -56,24 +53,12 @@ def listens_for(
|
|||||||
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
|
||||||
for ev in evs:
|
for ev in evs:
|
||||||
_EVENT_HANDLERS.setdefault((model_class, ev), []).append(fn)
|
_EVENT_HANDLERS.setdefault((model_class, ev), []).append(fn)
|
||||||
_WATCHED_MODELS.add(model_class)
|
|
||||||
_invalidate_caches()
|
_invalidate_caches()
|
||||||
return fn
|
return fn
|
||||||
|
|
||||||
return decorator
|
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]]:
|
def _get_handlers(cls: type, ev: ModelEvent) -> list[Callable[..., Any]]:
|
||||||
"""Return registered handlers for *cls* and *ev*, walking the MRO."""
|
"""Return registered handlers for *cls* and *ev*, walking the MRO."""
|
||||||
key = (cls, ev)
|
key = (cls, ev)
|
||||||
@@ -144,18 +129,18 @@ def _upsert_changes(
|
|||||||
def _after_flush(session: Any, flush_context: Any) -> None:
|
def _after_flush(session: Any, flush_context: Any) -> None:
|
||||||
# New objects: capture reference. Attributes will be refreshed after commit.
|
# New objects: capture reference. Attributes will be refreshed after commit.
|
||||||
for obj in session.new:
|
for obj in session.new:
|
||||||
if _is_watched(obj):
|
if _get_handlers(type(obj), ModelEvent.CREATE):
|
||||||
session.info.setdefault(_SESSION_CREATES, []).append(obj)
|
session.info.setdefault(_SESSION_CREATES, []).append(obj)
|
||||||
|
|
||||||
# Deleted objects: snapshot now while attributes are still loaded.
|
# Deleted objects: snapshot now while attributes are still loaded.
|
||||||
for obj in session.deleted:
|
for obj in session.deleted:
|
||||||
if _is_watched(obj):
|
if _get_handlers(type(obj), ModelEvent.DELETE):
|
||||||
snapshot = _snapshot_column_attrs(obj)
|
snapshot = _snapshot_column_attrs(obj)
|
||||||
session.info.setdefault(_SESSION_DELETES, []).append((obj, snapshot))
|
session.info.setdefault(_SESSION_DELETES, []).append((obj, snapshot))
|
||||||
|
|
||||||
# Dirty objects: read old/new from SQLAlchemy attribute history.
|
# Dirty objects: read old/new from SQLAlchemy attribute history.
|
||||||
for obj in session.dirty:
|
for obj in session.dirty:
|
||||||
if not _is_watched(obj):
|
if not _get_handlers(type(obj), ModelEvent.UPDATE):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
watched = _get_watched_fields(type(obj))
|
watched = _get_watched_fields(type(obj))
|
||||||
@@ -204,6 +189,20 @@ async def _invoke_callback(
|
|||||||
await result
|
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).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):
|
class EventSession(AsyncSession):
|
||||||
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
|
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
|
||||||
|
|
||||||
@@ -245,15 +244,36 @@ class EventSession(AsyncSession):
|
|||||||
k: v for k, v in field_changes.items() if k not in create_ids
|
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:
|
for obj in creates:
|
||||||
try:
|
|
||||||
state = sa_inspect(obj, raiseerr=False)
|
state = sa_inspect(obj, raiseerr=False)
|
||||||
if (
|
if state is None or state.detached or state.transient: # pragma: no cover
|
||||||
state is None or state.detached or state.transient
|
|
||||||
): # pragma: no cover
|
|
||||||
continue
|
continue
|
||||||
await self.refresh(obj)
|
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:
|
||||||
for handler in _get_handlers(type(obj), ModelEvent.CREATE):
|
for handler in _get_handlers(type(obj), ModelEvent.CREATE):
|
||||||
await _invoke_callback(handler, obj, ModelEvent.CREATE, None)
|
await _invoke_callback(handler, obj, ModelEvent.CREATE, None)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -270,14 +290,8 @@ class EventSession(AsyncSession):
|
|||||||
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||||
|
|
||||||
# Dispatch UPDATE callbacks.
|
# Dispatch UPDATE callbacks.
|
||||||
for obj, changes in field_changes.values():
|
for obj, changes in update_items:
|
||||||
try:
|
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):
|
for handler in _get_handlers(type(obj), ModelEvent.UPDATE):
|
||||||
await _invoke_callback(handler, obj, ModelEvent.UPDATE, changes)
|
await _invoke_callback(handler, obj, ModelEvent.UPDATE, changes)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
"""Pytest plugin for using FixtureRegistry fixtures in tests."""
|
"""Pytest plugin for using FixtureRegistry fixtures in tests."""
|
||||||
|
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Sequence
|
||||||
from typing import Any, cast
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import DeclarativeBase, selectinload
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from sqlalchemy.orm.interfaces import ExecutableOption, ORMOption
|
|
||||||
|
|
||||||
from ..db import transaction
|
|
||||||
from ..fixtures import FixtureRegistry, LoadStrategy
|
from ..fixtures import FixtureRegistry, LoadStrategy
|
||||||
|
from ..fixtures.utils import _get_primary_key, _load_ordered, _refresh_loaded
|
||||||
|
|
||||||
|
|
||||||
def register_fixtures(
|
def register_fixtures(
|
||||||
@@ -57,7 +55,7 @@ def register_fixtures(
|
|||||||
|
|
||||||
# Build list of pytest fixture dependencies
|
# Build list of pytest fixture dependencies
|
||||||
pytest_deps = [session_fixture]
|
pytest_deps = [session_fixture]
|
||||||
for dep in fixture.depends_on:
|
for dep in registry.get_dependencies(fixture.name):
|
||||||
pytest_deps.append(f"{prefix}{dep}")
|
pytest_deps.append(f"{prefix}{dep}")
|
||||||
|
|
||||||
# Create the fixture function
|
# Create the fixture function
|
||||||
@@ -83,56 +81,38 @@ def _create_fixture_function(
|
|||||||
fixture_name: str,
|
fixture_name: str,
|
||||||
dependencies: list[str],
|
dependencies: list[str],
|
||||||
strategy: LoadStrategy,
|
strategy: LoadStrategy,
|
||||||
) -> Callable[..., Any]:
|
) -> Any:
|
||||||
"""Create a fixture function with the correct signature.
|
"""Create a fixture function with the correct signature.
|
||||||
|
|
||||||
The function signature must include all dependencies as parameters
|
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)
|
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]:
|
async def fixture_func(**kwargs: Any) -> Sequence[DeclarativeBase]:
|
||||||
# Get session from kwargs (first dependency)
|
|
||||||
session: AsyncSession = kwargs[dependencies[0]]
|
session: AsyncSession = kwargs[dependencies[0]]
|
||||||
|
result = (await _load_ordered(session, registry, [fixture_name], strategy))[
|
||||||
|
fixture_name
|
||||||
|
]
|
||||||
|
|
||||||
# Load the fixture data
|
if strategy is LoadStrategy.SKIP_EXISTING:
|
||||||
instances = list(fixture_def.func())
|
# _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 result
|
||||||
return []
|
|
||||||
|
|
||||||
loaded: list[DeclarativeBase] = []
|
|
||||||
|
|
||||||
async with 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
|
|
||||||
|
|
||||||
# Update function signature to include dependencies
|
# Update function signature to include dependencies
|
||||||
# This is needed for pytest to inject the right fixtures
|
# 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."
|
created_func.__doc__ = f"Load {fixture_name} fixture data."
|
||||||
|
|
||||||
return created_func
|
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,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",
|
|
||||||
]
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -476,3 +476,26 @@ async def db_session(engine):
|
|||||||
# Drop tables after test
|
# Drop tables after test
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
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
-1
@@ -277,6 +277,10 @@ class TestFixturesCli:
|
|||||||
'@registry.register(depends_on=["roles"], contexts=[Context.TESTING])\n'
|
'@registry.register(depends_on=["roles"], contexts=[Context.TESTING])\n'
|
||||||
"def users():\n"
|
"def users():\n"
|
||||||
' return [{"id": 1, "name": "alice", "role_id": 1}]\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
|
# Create db module
|
||||||
@@ -316,7 +320,7 @@ class TestFixturesCli:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "roles" in result.output
|
assert "roles" in result.output
|
||||||
assert "users" 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):
|
def test_fixtures_list_with_context(self, cli_env):
|
||||||
"""fixtures list --context filters by context."""
|
"""fixtures list --context filters by context."""
|
||||||
@@ -338,6 +342,27 @@ class TestFixturesCli:
|
|||||||
assert "roles" in result.output
|
assert "roles" in result.output
|
||||||
assert "[Dry run - no changes made]" 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):
|
def test_fixtures_load_invalid_strategy(self, cli_env):
|
||||||
"""fixtures load with invalid strategy shows error."""
|
"""fixtures load with invalid strategy shows error."""
|
||||||
tmp_path, cli = cli_env
|
tmp_path, cli = cli_env
|
||||||
|
|||||||
@@ -417,6 +417,55 @@ class TestDefaultLoadOptionsIntegration:
|
|||||||
assert updated.role is not None
|
assert updated.role is not None
|
||||||
assert updated.role.name == "admin"
|
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
|
@pytest.mark.anyio
|
||||||
async def test_load_options_overrides_default_load_options(
|
async def test_load_options_overrides_default_load_options(
|
||||||
self, db_session: AsyncSession
|
self, db_session: AsyncSession
|
||||||
|
|||||||
+151
-7
@@ -372,6 +372,22 @@ class TestBuildSearchFilters:
|
|||||||
|
|
||||||
assert len(joins) == 1
|
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:
|
class TestSearchConfig:
|
||||||
"""Tests for SearchConfig options."""
|
"""Tests for SearchConfig options."""
|
||||||
@@ -531,6 +547,15 @@ class TestFacetsNotSet:
|
|||||||
|
|
||||||
assert result.filter_attributes is None
|
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:
|
class TestFacetsDirectColumn:
|
||||||
"""Facets on direct model columns."""
|
"""Facets on direct model columns."""
|
||||||
@@ -606,6 +631,91 @@ class TestFacetsDirectColumn:
|
|||||||
assert "username" not in result.filter_attributes
|
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:
|
class TestFacetsRespectFilters:
|
||||||
"""Facets reflect the active filter conditions."""
|
"""Facets reflect the active filter conditions."""
|
||||||
|
|
||||||
@@ -630,6 +740,28 @@ class TestFacetsRespectFilters:
|
|||||||
assert result.filter_attributes is not None
|
assert result.filter_attributes is not None
|
||||||
assert result.filter_attributes["username"] == ["alice"]
|
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:
|
class TestFacetsRelationship:
|
||||||
"""Facets on relationship columns via tuple syntax."""
|
"""Facets on relationship columns via tuple syntax."""
|
||||||
@@ -785,8 +917,8 @@ class TestFilterBy:
|
|||||||
|
|
||||||
assert len(result.data) == 1
|
assert len(result.data) == 1
|
||||||
assert result.data[0].username == "alice"
|
assert result.data[0].username == "alice"
|
||||||
# facet also scoped to the filter
|
# facet excludes its own filter_by condition, so it isn't collapsed
|
||||||
assert result.filter_attributes == {"username": ["alice"]}
|
assert result.filter_attributes == {"username": ["alice", "bob"]}
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_list_filter_produces_in_clause(self, db_session: AsyncSession):
|
async def test_list_filter_produces_in_clause(self, db_session: AsyncSession):
|
||||||
@@ -924,7 +1056,7 @@ class TestFilterBy:
|
|||||||
|
|
||||||
assert len(result.data) == 1
|
assert len(result.data) == 1
|
||||||
assert result.data[0].username == "alice"
|
assert result.data[0].username == "alice"
|
||||||
assert result.filter_attributes == {"username": ["alice"]}
|
assert result.filter_attributes == {"username": ["alice", "bob"]}
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_basemodel_filter_by_offset_paginate(self, db_session: AsyncSession):
|
async def test_basemodel_filter_by_offset_paginate(self, db_session: AsyncSession):
|
||||||
@@ -1085,8 +1217,10 @@ class TestFilterBy:
|
|||||||
assert result.pagination.total_count == 2
|
assert result.pagination.total_count == 2
|
||||||
titles = {a.title for a in result.data}
|
titles = {a.title for a in result.data}
|
||||||
assert titles == {"Post 1", "Post 3"}
|
assert titles == {"Post 1", "Post 3"}
|
||||||
# facet returns individual unnested values, not whole arrays
|
# facet excludes its own filter_by condition (not collapsed to matching rows)
|
||||||
assert result.filter_attributes == {"labels": ["django", "fastapi", "python"]}
|
assert result.filter_attributes == {
|
||||||
|
"labels": ["axum", "django", "fastapi", "python", "rust"]
|
||||||
|
}
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_array_overlap_list_value(self, db_session: AsyncSession):
|
async def test_array_overlap_list_value(self, db_session: AsyncSession):
|
||||||
@@ -2179,7 +2313,12 @@ class TestOffsetPaginateParamsSchema:
|
|||||||
include_total=False, search=False, filter=False, order=False
|
include_total=False, search=False, filter=False, order=False
|
||||||
)
|
)
|
||||||
result = await dep(page=2, items_per_page=10)
|
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
|
@pytest.mark.anyio
|
||||||
async def test_integrates_with_offset_paginate(self, db_session: AsyncSession):
|
async def test_integrates_with_offset_paginate(self, db_session: AsyncSession):
|
||||||
@@ -2290,7 +2429,11 @@ class TestCursorPaginateParamsSchema:
|
|||||||
search=False, filter=False, order=False
|
search=False, filter=False, order=False
|
||||||
)
|
)
|
||||||
result = await dep(cursor=None, items_per_page=5)
|
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
|
@pytest.mark.anyio
|
||||||
async def test_integrates_with_cursor_paginate(self, db_session: AsyncSession):
|
async def test_integrates_with_cursor_paginate(self, db_session: AsyncSession):
|
||||||
@@ -2399,6 +2542,7 @@ class TestPaginateParamsSchema:
|
|||||||
"cursor": None,
|
"cursor": None,
|
||||||
"items_per_page": 10,
|
"items_per_page": 10,
|
||||||
"include_total": True,
|
"include_total": True,
|
||||||
|
"include_facets": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|||||||
+133
-1
@@ -3,12 +3,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from pydantic import PostgresDsn
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column,
|
Column,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
@@ -94,6 +95,33 @@ class TestDatabaseConstruction:
|
|||||||
with pytest.raises(TypeError):
|
with pytest.raises(TypeError):
|
||||||
Database(engine=engine, pool_size=5)
|
Database(engine=engine, pool_size=5)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_connect_args_with_engine_raises(self, engine):
|
||||||
|
"""connect_args are rejected in engine= mode."""
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
Database(engine=engine, connect_args={"server_settings": {}})
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_accepts_postgres_dsn(self):
|
||||||
|
"""A Pydantic PostgresDsn is coerced to a string URL."""
|
||||||
|
dsn = PostgresDsn(DATABASE_URL)
|
||||||
|
db = Database(dsn)
|
||||||
|
try:
|
||||||
|
assert db._owns_engine is True
|
||||||
|
assert str(db.engine.url) == str(create_async_engine(DATABASE_URL).url)
|
||||||
|
finally:
|
||||||
|
await db.engine.dispose()
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_connect_args_forwarded_to_engine(self):
|
||||||
|
"""connect_args are forwarded to create_async_engine in URL mode."""
|
||||||
|
connect_args = {"server_settings": {"application_name": "ft_test"}}
|
||||||
|
with patch("fastapi_toolsets.db.core.create_async_engine") as mocked:
|
||||||
|
mocked.return_value = MagicMock()
|
||||||
|
Database(DATABASE_URL, connect_args=connect_args)
|
||||||
|
_, kwargs = mocked.call_args
|
||||||
|
assert kwargs["connect_args"] == connect_args
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_url_mode_owns_engine(self):
|
async def test_url_mode_owns_engine(self):
|
||||||
"""URL mode builds and owns the engine."""
|
"""URL mode builds and owns the engine."""
|
||||||
@@ -661,6 +689,13 @@ class TestWaitForRowChange:
|
|||||||
with pytest.raises(NotFoundError, match="not found"):
|
with pytest.raises(NotFoundError, match="not found"):
|
||||||
await wait_for_row_change(db_session, Role, fake_id, interval=0.05)
|
await wait_for_row_change(db_session, Role, fake_id, interval=0.05)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_unbound_session_raises_type_error(self):
|
||||||
|
"""Raises TypeError when the session has no bind to open a watcher on."""
|
||||||
|
unbound = AsyncSession()
|
||||||
|
with pytest.raises(TypeError, match="requires a session bound to an engine"):
|
||||||
|
await wait_for_row_change(unbound, Role, uuid.uuid4())
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_timeout_raises(self, db_session: AsyncSession):
|
async def test_timeout_raises(self, db_session: AsyncSession):
|
||||||
"""Raises TimeoutError when no change is detected within timeout."""
|
"""Raises TimeoutError when no change is detected within timeout."""
|
||||||
@@ -673,6 +708,66 @@ class TestWaitForRowChange:
|
|||||||
db_session, Role, role.id, interval=0.05, timeout=0.2
|
db_session, Role, role.id, interval=0.05, timeout=0.2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_detects_update_under_repeatable_read(self, engine):
|
||||||
|
"""Detects external commits even when the watcher pins a snapshot."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
rr_engine = engine.execution_options(isolation_level="REPEATABLE READ")
|
||||||
|
factory = async_sessionmaker(rr_engine, expire_on_commit=False)
|
||||||
|
try:
|
||||||
|
async with factory() as setup:
|
||||||
|
role = Role(name="rr_role")
|
||||||
|
setup.add(role)
|
||||||
|
await setup.commit()
|
||||||
|
role_id = role.id
|
||||||
|
|
||||||
|
async def update_later():
|
||||||
|
await asyncio.sleep(0.15)
|
||||||
|
async with factory() as other:
|
||||||
|
r = await other.get(Role, role_id)
|
||||||
|
assert r is not None
|
||||||
|
r.name = "rr_updated"
|
||||||
|
await other.commit()
|
||||||
|
|
||||||
|
watcher = factory()
|
||||||
|
try:
|
||||||
|
# Pin a snapshot before the update lands.
|
||||||
|
await watcher.get(Role, role_id)
|
||||||
|
update_task = asyncio.create_task(update_later())
|
||||||
|
result = await wait_for_row_change(
|
||||||
|
watcher, Role, role_id, interval=0.05, timeout=2.0
|
||||||
|
)
|
||||||
|
await update_task
|
||||||
|
assert result.name == "rr_updated"
|
||||||
|
finally:
|
||||||
|
await watcher.close()
|
||||||
|
finally:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_stale_then_deleted_instance_raises_not_found(
|
||||||
|
self, db_session: AsyncSession, engine
|
||||||
|
):
|
||||||
|
"""A stale expired instance in the identity map yields NotFoundError."""
|
||||||
|
role = Role(name="stale_role")
|
||||||
|
db_session.add(role)
|
||||||
|
await db_session.commit()
|
||||||
|
role_id = role.id
|
||||||
|
|
||||||
|
# db_session still holds `role`; delete it from another committed session.
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with factory() as other:
|
||||||
|
r = await other.get(Role, role_id)
|
||||||
|
await other.delete(r)
|
||||||
|
await other.commit()
|
||||||
|
|
||||||
|
with pytest.raises(NotFoundError):
|
||||||
|
await wait_for_row_change(
|
||||||
|
db_session, Role, role_id, interval=0.05, timeout=0.5
|
||||||
|
)
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_deleted_row_raises(self, db_session: AsyncSession, engine):
|
async def test_deleted_row_raises(self, db_session: AsyncSession, engine):
|
||||||
"""Raises NotFoundError when the row is deleted during polling."""
|
"""Raises NotFoundError when the row is deleted during polling."""
|
||||||
@@ -693,6 +788,43 @@ class TestWaitForRowChange:
|
|||||||
await wait_for_row_change(db_session, Role, role.id, interval=0.05)
|
await wait_for_row_change(db_session, Role, role.id, interval=0.05)
|
||||||
await delete_task
|
await delete_task
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_does_not_disturb_ambient_transaction(
|
||||||
|
self, db_session: AsyncSession, engine
|
||||||
|
):
|
||||||
|
"""A read-only ambient transaction around the call survives untouched."""
|
||||||
|
role = Role(name="ambient_role")
|
||||||
|
db_session.add(role)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
async def update_later():
|
||||||
|
await asyncio.sleep(0.15)
|
||||||
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with factory() as other:
|
||||||
|
r = await other.get(Role, role.id)
|
||||||
|
assert r is not None
|
||||||
|
r.name = "ambient_updated"
|
||||||
|
await other.commit()
|
||||||
|
|
||||||
|
update_task = asyncio.create_task(update_later())
|
||||||
|
async with transaction(db_session):
|
||||||
|
# A read before the watch, establishing an ambient transaction
|
||||||
|
# that must remain usable once wait_for_row_change returns.
|
||||||
|
await db_session.get(Role, role.id)
|
||||||
|
result = await wait_for_row_change(
|
||||||
|
db_session, Role, role.id, interval=0.05, timeout=2.0
|
||||||
|
)
|
||||||
|
await update_task
|
||||||
|
assert result.name == "ambient_updated"
|
||||||
|
# The ambient transaction must still be open and usable here.
|
||||||
|
assert db_session.in_transaction()
|
||||||
|
other_role = Role(name="added_within_ambient_tx")
|
||||||
|
db_session.add(other_role)
|
||||||
|
|
||||||
|
# transaction() committed cleanly on exit; the write above landed.
|
||||||
|
check = await db_session.get(Role, other_role.id)
|
||||||
|
assert check is not None
|
||||||
|
|
||||||
|
|
||||||
class TestCreateDatabase:
|
class TestCreateDatabase:
|
||||||
"""Tests for create_database."""
|
"""Tests for create_database."""
|
||||||
|
|||||||
@@ -199,8 +199,9 @@ class TestOffsetPagination:
|
|||||||
resp = await client.get("/articles/offset?status=published")
|
resp = await client.get("/articles/offset?status=published")
|
||||||
|
|
||||||
body = resp.json()
|
body = resp.json()
|
||||||
# draft is filtered out → should not appear in filter_attributes
|
# a facet excludes its own filter_by condition, so filtering by
|
||||||
assert "draft" not in body["filter_attributes"]["status"]
|
# status=published still shows every status the facet offers
|
||||||
|
assert "draft" in body["filter_attributes"]["status"]
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_search_and_filter_combined(self, client: AsyncClient, ex_db_session):
|
async def test_search_and_filter_combined(self, client: AsyncClient, ex_db_session):
|
||||||
|
|||||||
+103
-22
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -10,8 +11,6 @@ from fastapi_toolsets.fixtures import (
|
|||||||
Context,
|
Context,
|
||||||
FixtureRegistry,
|
FixtureRegistry,
|
||||||
LoadStrategy,
|
LoadStrategy,
|
||||||
get_field_by_attr,
|
|
||||||
get_obj_by_attr,
|
|
||||||
load_fixtures,
|
load_fixtures,
|
||||||
load_fixtures_by_context,
|
load_fixtures_by_context,
|
||||||
)
|
)
|
||||||
@@ -267,7 +266,34 @@ class TestFixtureRegistry:
|
|||||||
|
|
||||||
testing_fixtures = registry.get_by_context(Context.TESTING)
|
testing_fixtures = registry.get_by_context(Context.TESTING)
|
||||||
names = {f.name for f in testing_fixtures}
|
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:
|
class TestIncludeRegistry:
|
||||||
@@ -812,6 +838,45 @@ class TestLoadFixtures:
|
|||||||
db_session, registry, "int_roles", strategy=LoadStrategy.SKIP_EXISTING
|
db_session, registry, "int_roles", strategy=LoadStrategy.SKIP_EXISTING
|
||||||
)
|
)
|
||||||
assert len(result["int_roles"]) == 1
|
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:
|
class TestLoadFixturesByContext:
|
||||||
@@ -891,8 +956,8 @@ class TestLoadFixturesByContext:
|
|||||||
assert await UserCrud.count(db_session) == 1
|
assert await UserCrud.count(db_session) == 1
|
||||||
|
|
||||||
|
|
||||||
class TestGetObjByAttr:
|
class TestRegistryObj:
|
||||||
"""Tests for get_obj_by_attr helper function."""
|
"""Tests for FixtureRegistry.obj."""
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
"""Set up test fixtures for each test."""
|
"""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):
|
def test_get_by_id(self):
|
||||||
"""Get an object by its id attribute."""
|
"""Get an object by its id attribute."""
|
||||||
role = get_obj_by_attr(self.roles, "id", self.role_id_1)
|
role = self.registry.obj("roles", "id", self.role_id_1)
|
||||||
assert role.name == "admin"
|
assert cast(Role, role).name == "admin"
|
||||||
|
|
||||||
def test_get_user_by_username(self):
|
def test_get_user_by_username(self):
|
||||||
"""Get a user by username."""
|
"""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.id == self.user_id_2
|
||||||
assert user.email == "bob@example.com"
|
assert user.email == "bob@example.com"
|
||||||
|
|
||||||
def test_returns_first_match(self):
|
def test_returns_first_match(self):
|
||||||
"""Returns the first matching object when multiple could match."""
|
"""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"
|
assert user.username == "alice"
|
||||||
|
|
||||||
def test_no_match_raises_stop_iteration(self):
|
def test_no_match_raises_stop_iteration(self):
|
||||||
@@ -959,16 +1021,37 @@ class TestGetObjByAttr:
|
|||||||
StopIteration,
|
StopIteration,
|
||||||
match="No object with name=nonexistent found in fixture 'roles'",
|
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):
|
def test_no_match_on_wrong_value_type(self):
|
||||||
"""Raises StopIteration when value type doesn't match."""
|
"""Raises StopIteration when value type doesn't match."""
|
||||||
with pytest.raises(StopIteration):
|
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:
|
class TestRegistryField:
|
||||||
"""Tests for get_field_by_attr helper function."""
|
"""Tests for FixtureRegistry.field."""
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
self.registry = FixtureRegistry()
|
self.registry = FixtureRegistry()
|
||||||
@@ -984,22 +1067,20 @@ class TestGetFieldByAttr:
|
|||||||
Role(id=role_id_2, name="user"),
|
Role(id=role_id_2, name="user"),
|
||||||
]
|
]
|
||||||
|
|
||||||
self.roles = roles
|
|
||||||
|
|
||||||
def test_returns_id_by_default(self):
|
def test_returns_id_by_default(self):
|
||||||
"""Returns the id field when no field is specified."""
|
"""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
|
assert result == self.role_id_1
|
||||||
|
|
||||||
def test_returns_specified_field(self):
|
def test_returns_specified_field(self):
|
||||||
"""Returns the requested field instead of id."""
|
"""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"
|
assert result == "user"
|
||||||
|
|
||||||
def test_no_match_raises_stop_iteration(self):
|
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"):
|
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:
|
class TestGetPrimaryKey:
|
||||||
|
|||||||
+72
-22
@@ -21,17 +21,15 @@ from fastapi_toolsets.models import (
|
|||||||
listens_for,
|
listens_for,
|
||||||
)
|
)
|
||||||
from fastapi_toolsets.models.watched import (
|
from fastapi_toolsets.models.watched import (
|
||||||
EventSession,
|
|
||||||
_EVENT_HANDLERS,
|
_EVENT_HANDLERS,
|
||||||
_SESSION_CREATES,
|
_SESSION_CREATES,
|
||||||
_SESSION_DELETES,
|
_SESSION_DELETES,
|
||||||
_SESSION_UPDATES,
|
_SESSION_UPDATES,
|
||||||
_WATCHED_MODELS,
|
EventSession,
|
||||||
_after_flush,
|
_after_flush,
|
||||||
_after_rollback,
|
_after_rollback,
|
||||||
_get_watched_fields,
|
_get_watched_fields,
|
||||||
_invalidate_caches,
|
_invalidate_caches,
|
||||||
_is_watched,
|
|
||||||
_snapshot_column_attrs,
|
_snapshot_column_attrs,
|
||||||
_upsert_changes,
|
_upsert_changes,
|
||||||
)
|
)
|
||||||
@@ -658,22 +656,6 @@ class TestWatchInheritance:
|
|||||||
assert "other" in _watch_inherit_events[0]["changes"]
|
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:
|
class TestUpsertChanges:
|
||||||
def test_inserts_new_entry(self):
|
def test_inserts_new_entry(self):
|
||||||
"""New key is inserted with the full changes dict."""
|
"""New key is inserted with the full changes dict."""
|
||||||
@@ -715,7 +697,10 @@ class TestAfterFlush:
|
|||||||
"""New watched objects are added to _SESSION_CREATES."""
|
"""New watched objects are added to _SESSION_CREATES."""
|
||||||
obj = object()
|
obj = object()
|
||||||
session = SimpleNamespace(new=[obj], deleted=[], dirty=[], info={})
|
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)
|
_after_flush(session, None)
|
||||||
assert session.info[_SESSION_CREATES] == [obj]
|
assert session.info[_SESSION_CREATES] == [obj]
|
||||||
|
|
||||||
@@ -731,7 +716,10 @@ class TestAfterFlush:
|
|||||||
obj = object()
|
obj = object()
|
||||||
session = SimpleNamespace(new=[], deleted=[obj], dirty=[], info={})
|
session = SimpleNamespace(new=[], deleted=[obj], dirty=[], info={})
|
||||||
with (
|
with (
|
||||||
patch("fastapi_toolsets.models.watched._is_watched", return_value=True),
|
patch(
|
||||||
|
"fastapi_toolsets.models.watched._get_handlers",
|
||||||
|
return_value=[lambda *a: None],
|
||||||
|
),
|
||||||
patch(
|
patch(
|
||||||
"fastapi_toolsets.models.watched._snapshot_column_attrs",
|
"fastapi_toolsets.models.watched._snapshot_column_attrs",
|
||||||
return_value={"id": 1},
|
return_value={"id": 1},
|
||||||
@@ -1001,6 +989,69 @@ class TestEventCallbacks:
|
|||||||
|
|
||||||
assert _test_events == []
|
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:
|
class TestTransientObject:
|
||||||
"""Create + delete within the same transaction should fire no events."""
|
"""Create + delete within the same transaction should fire no events."""
|
||||||
@@ -1370,7 +1421,6 @@ class TestListensFor:
|
|||||||
for key in list(_EVENT_HANDLERS):
|
for key in list(_EVENT_HANDLERS):
|
||||||
if key[0] is ListenerModel:
|
if key[0] is ListenerModel:
|
||||||
del _EVENT_HANDLERS[key]
|
del _EVENT_HANDLERS[key]
|
||||||
_WATCHED_MODELS.discard(ListenerModel)
|
|
||||||
_invalidate_caches()
|
_invalidate_caches()
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from fastapi_toolsets.pytest import (
|
|||||||
register_fixtures,
|
register_fixtures,
|
||||||
worker_database_url,
|
worker_database_url,
|
||||||
)
|
)
|
||||||
from fastapi_toolsets.pytest.plugin import (
|
from fastapi_toolsets.fixtures.utils import (
|
||||||
_get_primary_key,
|
_get_primary_key,
|
||||||
_relationship_load_options,
|
_relationship_load_options,
|
||||||
_reload_with_relationships,
|
_reload_with_relationships,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -192,101 +192,86 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coverage"
|
name = "coverage"
|
||||||
version = "7.14.1"
|
version = "7.15.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" }
|
||||||
wheels = [
|
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/ae/23/82e910835ef4b8391047025e1d53aa48d66029f444eb8b25373c849bf503/coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a", size = 220662, upload-time = "2026-07-02T13:08:39.205Z" },
|
||||||
{ 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/6d/0d/c7b213dde2f1579de5231062b386d8413f79c11667eb58c39319b25991da/coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118", size = 221168, upload-time = "2026-07-02T13:08:40.471Z" },
|
||||||
{ 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/33/77/d000aeedfac085088337b3c7becdad328474b1f8a9e4c9368a0c99605d68/coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2", size = 251587, upload-time = "2026-07-02T13:08:42.033Z" },
|
||||||
{ 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/cc/e0/86787c56b9df17afd370d5e293515dd4d9a107a561d13054873eefad8ecc/coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8", size = 253497, upload-time = "2026-07-02T13:08:43.387Z" },
|
||||||
{ 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/3f/02/181bc917359299c07dead6270f94e411151c8b60cec905c33499da69afe6/coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5", size = 255607, upload-time = "2026-07-02T13:08:44.897Z" },
|
||||||
{ 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/b9/35/ca5e7427699913da6788c4f910e73ab16c5f4b59ec5d3a999dce2a45112f/coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723", size = 257563, upload-time = "2026-07-02T13:08:46.334Z" },
|
||||||
{ 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/0b/4d/b8220bacc2fc3c4e9078e27c32e99fb411479a4718a72bdd00036a9891c8/coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735", size = 251726, upload-time = "2026-07-02T13:08:47.941Z" },
|
||||||
{ 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/c4/e4/2e145da1991d72189b9c3cf7eca05c716ee7080d099aaea6757cfc7df008/coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9", size = 253301, upload-time = "2026-07-02T13:08:49.5Z" },
|
||||||
{ 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/72/28/d2c841d698bf762e481f08bd4839d370246b6d9b61dab085a7b20b201a08/coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035", size = 251361, upload-time = "2026-07-02T13:08:51.304Z" },
|
||||||
{ 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/9d/ed/55d9ffde994fba3897c0c783f77a7d053b0c18787f6892ed5b0aed73f469/coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671", size = 255129, upload-time = "2026-07-02T13:08:52.661Z" },
|
||||||
{ 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/1d/c0/ecbf33b8c460ea2718aeb813e2df8140d0370e5f67261c31524ceb0a2a8d/coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695", size = 251081, upload-time = "2026-07-02T13:08:54.188Z" },
|
||||||
{ 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/a9/de/fb87b4261f54448dd2b9504ef19a58be42cef0d9520595fbfe1219b15234/coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540", size = 251988, upload-time = "2026-07-02T13:08:55.726Z" },
|
||||||
{ 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/df/27/3494d5f291b9a4cb868f73c11221a8bd2d5bd761a8f9acea61ff57128dd1/coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4", size = 222754, upload-time = "2026-07-02T13:08:57.091Z" },
|
||||||
{ 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/2a/ee/cd4847ebc9be6a9c0123d763645a6f1f3be6b8c58c962706368b79cbac07/coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6", size = 223225, upload-time = "2026-07-02T13:08:58.594Z" },
|
||||||
{ 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/57/37/5011581aa7f2be498b97dcc7c9902192442a42f4f9a748aeadb3d6506b42/coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640", size = 222774, upload-time = "2026-07-02T13:09:00.074Z" },
|
||||||
{ 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/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" },
|
||||||
{ 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/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" },
|
||||||
{ 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/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" },
|
||||||
{ 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/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" },
|
||||||
{ 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/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" },
|
||||||
{ 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/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" },
|
||||||
{ 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/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" },
|
||||||
{ 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/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" },
|
||||||
{ 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/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" },
|
||||||
{ 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/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" },
|
||||||
{ 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/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" },
|
||||||
{ 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/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" },
|
||||||
{ 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/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" },
|
||||||
{ 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/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" },
|
||||||
{ 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/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" },
|
||||||
{ 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/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" },
|
||||||
{ 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/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" },
|
||||||
{ 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/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" },
|
||||||
{ 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/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" },
|
||||||
{ 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/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" },
|
||||||
{ 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/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" },
|
||||||
{ 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/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" },
|
||||||
{ 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/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" },
|
||||||
{ 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/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" },
|
||||||
{ 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/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" },
|
||||||
{ 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/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" },
|
||||||
{ 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/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" },
|
||||||
{ 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/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" },
|
||||||
{ 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/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" },
|
||||||
{ 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/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" },
|
||||||
{ 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/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" },
|
||||||
{ 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/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" },
|
||||||
{ 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/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" },
|
||||||
{ 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/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" },
|
||||||
{ 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/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" },
|
||||||
{ 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/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" },
|
||||||
{ 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/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" },
|
||||||
{ 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/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" },
|
||||||
{ 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/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" },
|
||||||
{ 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/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" },
|
||||||
{ 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/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" },
|
||||||
{ 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/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" },
|
||||||
{ 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/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" },
|
||||||
{ 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/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" },
|
||||||
{ 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/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" },
|
||||||
{ 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/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" },
|
||||||
{ 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/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" },
|
||||||
{ 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/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" },
|
||||||
{ 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/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" },
|
||||||
{ 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/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" },
|
||||||
{ 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/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" },
|
||||||
{ 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/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" },
|
||||||
{ 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/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" },
|
||||||
{ 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/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" },
|
||||||
{ 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/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" },
|
||||||
{ 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/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" },
|
||||||
{ 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/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" },
|
||||||
{ 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/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" },
|
||||||
{ 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/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" },
|
||||||
{ 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/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" },
|
||||||
{ 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/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" },
|
||||||
{ 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" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -314,7 +299,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.136.3"
|
version = "0.139.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
@@ -323,14 +308,14 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "5.0.0b1"
|
version = "5.0.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
@@ -341,7 +326,6 @@ dependencies = [
|
|||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
all = [
|
all = [
|
||||||
{ name = "async-lru" },
|
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "prometheus-client" },
|
{ name = "prometheus-client" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
@@ -359,10 +343,6 @@ pytest = [
|
|||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-xdist" },
|
{ name = "pytest-xdist" },
|
||||||
]
|
]
|
||||||
security = [
|
|
||||||
{ name = "async-lru" },
|
|
||||||
{ name = "httpx" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -402,12 +382,10 @@ tests = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "async-lru", marker = "extra == 'security'", specifier = ">=1.0" },
|
|
||||||
{ name = "asyncpg", specifier = ">=0.29.0" },
|
{ name = "asyncpg", specifier = ">=0.29.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.100.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 == '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 = "prometheus-client", marker = "extra == 'metrics'", specifier = ">=0.20.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0" },
|
{ name = "pydantic", specifier = ">=2.0" },
|
||||||
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
{ name = "pytest", marker = "extra == 'pytest'", specifier = ">=8.0.0" },
|
||||||
@@ -415,7 +393,7 @@ requires-dist = [
|
|||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
||||||
{ name = "typer", marker = "extra == 'cli'", specifier = ">=0.9.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]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
@@ -786,16 +764,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mkdocstrings-python"
|
name = "mkdocstrings-python"
|
||||||
version = "2.0.3"
|
version = "2.0.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "griffelib" },
|
{ name = "griffelib" },
|
||||||
{ name = "mkdocs-autorefs" },
|
{ name = "mkdocs-autorefs" },
|
||||||
{ name = "mkdocstrings" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
@@ -836,26 +814,26 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prek"
|
name = "prek"
|
||||||
version = "0.4.3"
|
version = "0.4.9"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]]
|
[[package]]
|
||||||
@@ -1017,7 +995,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.0.3"
|
version = "9.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
@@ -1026,9 +1004,9 @@ dependencies = [
|
|||||||
{ name = "pluggy" },
|
{ name = "pluggy" },
|
||||||
{ name = "pygments" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
@@ -1165,27 +1143,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.15"
|
version = "0.15.21"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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/0f/36/6f65aa9989acdec45d417192d8f4e7921931d8a6cf87ac74bce3eed98a8e/ruff-0.15.21.tar.gz", hash = "sha256:d0cfc841c572283c36548f82664a54ce6565567f1b0d5b4cf2caac693d8b7500", size = 4769401, upload-time = "2026-07-09T20:01:34.005Z" }
|
||||||
wheels = [
|
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/d0/c6/ede15cac6839f3dbce52565c8f5164a8210e669c7bc4decb03e5bdf47d0d/ruff-0.15.21-py3-none-linux_armv6l.whl", hash = "sha256:63ea0e965e5d73c90e95b2434beeafc70820536717f561b32ab6e777cb9bdf5d", size = 10854342, upload-time = "2026-07-09T20:00:53.998Z" },
|
||||||
{ 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/28/9d/d825b07ee7ea9e2d61df92a860033c94e06e7300d50a1c2653aac27d24fe/ruff-0.15.21-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0f212c5d7d54c01bbfe6dcab02b724a39300f3e34ed7acbe995ccb320a2c58bd", size = 11139539, upload-time = "2026-07-09T20:00:57.809Z" },
|
||||||
{ 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/f5/de/3b107712e642f063c7a9e0887c427b22cb44097de5aab36c05f2e280670c/ruff-0.15.21-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e6312e41bc96791299614995ea3a977c5857c3b5662b1ecef6755b02b87cb646", size = 10595437, upload-time = "2026-07-09T20:01:00.006Z" },
|
||||||
{ 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/9a/6f/b4523cc90ba239ede441447a19d0c968846a3012e5a0b0c5b62831a3d5e3/ruff-0.15.21-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01d65b4831c6b2a4ba8ee6faa84049d44d982b7a706e622c4094c509e51673be", size = 10990053, upload-time = "2026-07-09T20:01:02.187Z" },
|
||||||
{ 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/92/cc/c6a9872a5375f0628875481cf2f66b13d7d865bf3ca2e57f91c7e762d976/ruff-0.15.21-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c5a913a589120ce67933d5d05fd6ddbcc2481c6a054980ee767f7414c72b4fd", size = 10666096, upload-time = "2026-07-09T20:01:04.299Z" },
|
||||||
{ 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/ab/97/c621f7a17e097f1790fa3af6374138823b330b2d03fc38337945daca212c/ruff-0.15.21-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef04b681d02ad4dc9620f00f83ac5c22f652d0e9a9cfe431d219b16ad5ccc41", size = 11537011, upload-time = "2026-07-09T20:01:06.771Z" },
|
||||||
{ 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/ea/51/d928727e476e25ccc57c6f449ffd80241a651a973ad949d39cfb2a771d28/ruff-0.15.21-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16d090c0740916594157e75b80d666eab8e78083b39b3b0e1d698f4670a17b86", size = 12347101, upload-time = "2026-07-09T20:01:08.859Z" },
|
||||||
{ 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/1e/88/8cd62026802b16018ad06931d87997cf795ba2a6239ab659606c87d96bf0/ruff-0.15.21-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a10e74757dd65004d779b73e2f3c5210156d9980b41224d50d2ebcf1db51e67", size = 11572001, upload-time = "2026-07-09T20:01:11.092Z" },
|
||||||
{ 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/b2/97/f63084cf55444fc110e8cb985ebfcc592af47f597d44453d778cb81bc156/ruff-0.15.21-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bab0905d2f29e0d9fbc3c373ed23db0095edaa3f71f1f4f519ec15134d9e85c8", size = 11549239, upload-time = "2026-07-09T20:01:13.27Z" },
|
||||||
{ 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/9d/77/f107da4a2874b7715914b03f09ba9c54424de3ff8a1cc5d015d3ee2ce0ac/ruff-0.15.21-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:00eca240af5789fec6fe7df74c088cc1f9644ed83027113468efba7c92b94075", size = 11535340, upload-time = "2026-07-09T20:01:15.206Z" },
|
||||||
{ 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/d5/e9/601deb322d3303a7bf212b0100ead6f2ee3f6a044d89c30f2f92bf83c731/ruff-0.15.21-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262ab31557a75141325e32d3357f3597645a7f084e732b6b054dde428ecd9341", size = 10964048, upload-time = "2026-07-09T20:01:17.723Z" },
|
||||||
{ 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/ea/2e/0f2176d1e99c15192caea19c8c3a0a955246b4cb4de795042eeb616345cd/ruff-0.15.21-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:659c4e7a4212f83306045ec7c5e5a356d16d9a6ef4ae0c7a4d872914fc655d9d", size = 10667055, upload-time = "2026-07-09T20:01:19.73Z" },
|
||||||
{ 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/48/60/abd74a02e0c4214f12a68becfd30af7165cfdcb0e661ecdc60bbb949c09a/ruff-0.15.21-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9e866eab611a5f959d36df2d10e446973a3610bc42b0c15b31dc27977d59c233", size = 11242043, upload-time = "2026-07-09T20:01:21.947Z" },
|
||||||
{ 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/b2/c6/583075d8ccabb4b229345edcaf1545eb3d8d6be90f686a479d7e94088bbf/ruff-0.15.21-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e89bc93c0d3803ba870b55c29671bad9dc6d94bb1eb181b056b52eb05b52854f", size = 11648064, upload-time = "2026-07-09T20:01:24.023Z" },
|
||||||
{ 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/3a/3c/37d0ecb729a7cc2d393ea7dce316fc585680f35d93b8d62139d7d0a3700c/ruff-0.15.21-py3-none-win32.whl", hash = "sha256:01f8d5be84823c172b389e123174f781f9daf86d6c58719d603f941932195cdd", size = 10896555, upload-time = "2026-07-09T20:01:26.941Z" },
|
||||||
{ 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/c0/b8/e43466b2a6067ce91e669068f6e28d6c719a920f014b070d5c8731725de3/ruff-0.15.21-py3-none-win_amd64.whl", hash = "sha256:d4b8d9a2f0f12b816b50447f6eccb9f4bb01a6b82c86b50fb3b5354b458dc6d3", size = 12038772, upload-time = "2026-07-09T20:01:29.497Z" },
|
||||||
{ 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/dd/75/e90ab9aeece218a9fc5a5bc3ec97d0ee6bb3c4ff95869463c1de58e29a1c/ruff-0.15.21-py3-none-win_arm64.whl", hash = "sha256:6e83115d4b9377c1cbc13abf0e051f069fab0ef815ea0504a8a008cee24dd0a8", size = 11375265, upload-time = "2026-07-09T20:01:31.772Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1329,32 +1307,32 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ty"
|
name = "ty"
|
||||||
version = "0.0.44"
|
version = "0.0.58"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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/09/4c/26c90732658903aeb1d289208f7b7b492fa21029e0c4d6c51bdd6f8f5e51/ty-0.0.58.tar.gz", hash = "sha256:8f22484174e65c630660a454bf81b80cae7a3a7e70479f19c170d6cd87949258", size = 6133665, upload-time = "2026-07-10T03:09:30.542Z" }
|
||||||
wheels = [
|
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/51/e1/5d1aa2a75829459834689f080e4be7a9d8828ce14b939ebed69161a35811/ty-0.0.58-py3-none-linux_armv6l.whl", hash = "sha256:47412850b6fbef61c42f244f6a51aa2f2c9e91f08cfbafd2d1e3730d2419d317", size = 11706915, upload-time = "2026-07-10T03:08:51.028Z" },
|
||||||
{ 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/96/e1/929eda9cc72a9afe39a03c76f946a503508d37343cc8ff2e64226afda105/ty-0.0.58-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79deb7bb4e5b3a1eee6ab9abc724d6ce3559d4977982707f310a139ee11fc703", size = 11532079, upload-time = "2026-07-10T03:08:53.771Z" },
|
||||||
{ 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/07/43/ebc58b3fc7d86a7abba2829f1674f7d4ae3a08f9794c1f31b707950c871f/ty-0.0.58-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a28af3187e661708a386d44a4fc32896a5f589fb07b734a11ab2f516e7572b7", size = 11092983, upload-time = "2026-07-10T03:08:56.025Z" },
|
||||||
{ 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/92/6e/9547dbb8e51e47749cfb721a02b4fc862f9a932fa0f66a34a4d6dc429bb1/ty-0.0.58-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21a6977e34bc362fb378add46e59d5d56331c1c36727e6904767217ca8479718", size = 11490492, upload-time = "2026-07-10T03:08:58.49Z" },
|
||||||
{ 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/d9/76/9f83b51b5e7795d6c8d76b4bb1bb7cebf4c10d609e846c02ab138e404556/ty-0.0.58-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3ac23e6bf6105ceca46632debf1b10b98125aaf60202aeae02f6abfeea242b3d", size = 11503696, upload-time = "2026-07-10T03:09:00.741Z" },
|
||||||
{ 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/47/9d/9fd48a0696c680f74e50f31cd54e524eeb58c97ea9bb1c3b8e04230ba215/ty-0.0.58-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bb6df6a8c6a21894807a49851370ec7fc64aa910296c78ada31db0ef19359112", size = 12158653, upload-time = "2026-07-10T03:09:02.998Z" },
|
||||||
{ 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/bc/ea/b5de845d2d8edae04d901c7585af7f04a057e18b26311f7fa4ca62b2da30/ty-0.0.58-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9df5847eebc026cde088b44420a03f7c6c169a7db747176bdf0a656eb1144713", size = 12723019, upload-time = "2026-07-10T03:09:05.291Z" },
|
||||||
{ 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/17/1c/54083b23eeff1e101f50b6df6a2c7f1e14b31abe0577c91bcae9e2c9395d/ty-0.0.58-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53a331a7f1f85872c810676a0f16096ef98c1b95c8c9a573fe7fde64d0a93e7c", size = 12275715, upload-time = "2026-07-10T03:09:07.564Z" },
|
||||||
{ 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/22/88/16925434b06faa49d36aa7e7508a6821ec6feacb429ca2fd80a3d52716b4/ty-0.0.58-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb0b05cd479fdcedc2e6781d376ee1a33569f37ae7f58357004635f615c4374c", size = 12033075, upload-time = "2026-07-10T03:09:10.222Z" },
|
||||||
{ 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/58/2b/b55708dd483982ae03d14da3667e3f0346cd384e11cca3dd3674a8b598c1/ty-0.0.58-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9a0012786077e5becbb6add9fe51eda1d4d36d249afd3ae6cd141d554af15ae3", size = 12367729, upload-time = "2026-07-10T03:09:12.338Z" },
|
||||||
{ 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/91/a3/99ad66652956408f7e9ac3db6b4a416199d773b6c073cf95b0eea126d340/ty-0.0.58-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4ede96d7f6d149156da254e784b062b817736b68d4c6d660555a3d02d96966fb", size = 11439798, upload-time = "2026-07-10T03:09:14.518Z" },
|
||||||
{ 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/ee/23/344ceed4fe02ed498711e1f4a47b6e311fb1e9c4fecc19d31894be7a3472/ty-0.0.58-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:47608e58f73901b989402e6f283249cda4c2314282ec368aa74ab61761c62bd5", size = 11512695, upload-time = "2026-07-10T03:09:16.852Z" },
|
||||||
{ 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/55/cf/3801831812c468f3fd0b3043a80f557a9aa90e6c27375763d7c3121e03f2/ty-0.0.58-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9757de17cc17e4c6bc26e18d4e26dea52ffee53d41a10d9087c6465e6ab12e2b", size = 11812253, upload-time = "2026-07-10T03:09:19.151Z" },
|
||||||
{ 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/7b/80/bce4f245787b77d1ec9feec7d9161eade5e01a77dbc132e016b24df83b0d/ty-0.0.58-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f3776d54c1d935fcb8f814bd58efba402c86c555f93e1144c59d087e7aa8b906", size = 12123918, upload-time = "2026-07-10T03:09:21.636Z" },
|
||||||
{ 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/46/00/bab3d6268e7e88c792bf7cdde81bd11b31aa587eaba75196502b729747c8/ty-0.0.58-py3-none-win32.whl", hash = "sha256:8f50ec0ac3b42baa4c75895dc367071dc86b00ab440d29fdc72c633286a94815", size = 11230897, upload-time = "2026-07-10T03:09:23.871Z" },
|
||||||
{ 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/9c/68/d9504c895864aaa84a840dce6ac7f8e681f6938be1882c8d4f60832dbe57/ty-0.0.58-py3-none-win_amd64.whl", hash = "sha256:de8847b3a65475ae4773bddd3126bfcf29f017e88967c4dcc9c75d743a4d3e5c", size = 12299376, upload-time = "2026-07-10T03:09:26.292Z" },
|
||||||
{ 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/26/04/c9847cb680b5fe8e1f7d7b483edd5cedcc29394496e7b8ed40d96be796ba/ty-0.0.58-py3-none-win_arm64.whl", hash = "sha256:7334bb38789878f60677f2eb9c1de4bfdf4583e2443790989c77da9b10fe0989", size = 11710495, upload-time = "2026-07-10T03:09:28.478Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typer"
|
name = "typer"
|
||||||
version = "0.26.7"
|
version = "0.26.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
@@ -1362,9 +1340,9 @@ dependencies = [
|
|||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "shellingham" },
|
{ 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/7c/f7/68adc395201b20b872d68e975386832e8005ffeacedd43a1d837a32815be/typer-0.26.8.tar.gz", hash = "sha256:c244a6bd558886fe3f8780efb6bdd28bb9aff005a94eedebaa5cb32926fe2f7e", size = 202097, upload-time = "2026-06-26T09:22:45.705Z" }
|
||||||
wheels = [
|
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/80/87/b9fd69c92c6102a066e1b86a35243f53e70bd4c709f2a26d9f4fee4f4dc0/typer-0.26.8-py3-none-any.whl", hash = "sha256:3512ca79ac5c11113414b36e80281b872884477722440691c89d1112e321a49c", size = 122564, upload-time = "2026-06-26T09:22:44.72Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1426,7 +1404,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zensical"
|
name = "zensical"
|
||||||
version = "0.0.43"
|
version = "0.0.50"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
@@ -1438,18 +1416,18 @@ dependencies = [
|
|||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "tomli" },
|
{ 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/14/79/f7959b13c766a831f1248779bb718555f70f40c5b8e68db7de1de9936662/zensical-0.0.50.tar.gz", hash = "sha256:7040e52ebe5e6a275e4edeb351bf2bc314d007f3fb5750f178a38d840723e69c", size = 3979246, upload-time = "2026-07-09T13:52:11.908Z" }
|
||||||
wheels = [
|
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/c2/d2/e126d56642a5fd437eeb5a067b7c0d1f766c08e22d6a708bb923986f1d44/zensical-0.0.50-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:26dda9dbacab84db2743726f83202798e1893fbfddecb6a845812d7a331043ad", size = 12817706, upload-time = "2026-07-09T13:51:29.624Z" },
|
||||||
{ 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/bd/9b/e1a22fb4ea8fc3e8377d7b394ee06cf0349e7f9a64ecb4c9873dee79b102/zensical-0.0.50-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:376887def6470c57509b48519870f74e2b987e30509cf5c819fcf372771f9403", size = 12691061, upload-time = "2026-07-09T13:51:33.194Z" },
|
||||||
{ 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/38/f5/16c087635680efb39e1d7b7aa3c2cca548305aa29ad51dee04f37b32b65f/zensical-0.0.50-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bbf3034a2cb1a9d2a72a5ee7047688717c93da0d8a90d25dd871db8d5f33a6d", size = 13129505, upload-time = "2026-07-09T13:51:36.383Z" },
|
||||||
{ 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/65/30/50d02402e53aa01a5c5c116a3522c7bb18d6ae4da964d8b236173b6fa086/zensical-0.0.50-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26fe4167663d544ca385ef420a49d3b738c7606a79d7a62a6bedcbf74cd383e7", size = 13055300, upload-time = "2026-07-09T13:51:39.748Z" },
|
||||||
{ 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/cf/0f/45f4f3e56a6483ea9f88478b20d6b434718e7c46579239589202e5135ec1/zensical-0.0.50-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8813d1f8895b19520d4a982d6bfcfd06372d73d96e6a63f0d2cf8f3b6036d5a1", size = 13445447, upload-time = "2026-07-09T13:51:43.106Z" },
|
||||||
{ 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/33/9b/1f5065c664afde6a63e0ada8c14eae75f9e6960df597a5ca28c07340f938/zensical-0.0.50-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a64f957f94f7d8e8847a7f1e8205509ba4b188c93c157c5e7be49bcb8556a127", size = 13098775, upload-time = "2026-07-09T13:51:46.437Z" },
|
||||||
{ 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/46/65/1e42e18d8f9a0cb9fcc8b871852810c7fe196237b79aaa831e863325091e/zensical-0.0.50-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:781d333bcb42c6a6b92360e05eadc944e2674794e1a95537971de1bf64cb9f89", size = 13305922, upload-time = "2026-07-09T13:51:51.107Z" },
|
||||||
{ 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/50/36/9cace194ba13aef3bdde8ca2d327f3a05cdf5c456e94f6d896852e2cbd38/zensical-0.0.50-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5d0f86eec76c23efb04566cd444bf025845ac8a02e75c8a6b4af13a39a2a9955", size = 13325488, upload-time = "2026-07-09T13:51:54.883Z" },
|
||||||
{ 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/b1/80/2891a9316e486794dae43151561b49f3313499cfb2c95c7886abad7bcf2d/zensical-0.0.50-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:392a7299a3c4d1ac7ff288bbed181d77a9fd23e6614180476e01630aaee0d67e", size = 13496866, upload-time = "2026-07-09T13:51:58.257Z" },
|
||||||
{ 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/7e/09/fbd3af58081a0d399fa913ef1f2fd6a007f2f06f7026c8d6a6096e34b996/zensical-0.0.50-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cac3448d8c8a76dfb43e55908b4ca9ad17596aee89a9287a184c1a0ba7dce2a2", size = 13437565, upload-time = "2026-07-09T13:52:01.766Z" },
|
||||||
{ 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/d3/79/709311ff202326260ff223fb6e9ddefa3100474c428e3c70a04036cefe0e/zensical-0.0.50-cp310-abi3-win32.whl", hash = "sha256:32b244f96a930f68e049b2e03d50790c120ef701b6277ddd854905a33040c75f", size = 12371966, upload-time = "2026-07-09T13:52:05.207Z" },
|
||||||
{ 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/e3/e1/4babb30544d7465c95a6a93abc0680b1c51752411a225c2b7f2cb44e80c7/zensical-0.0.50-cp310-abi3-win_amd64.whl", hash = "sha256:2f054769bf96ae46353de3eca2463ad4db6df1da9fde515c6d7fc54c3608e615", size = 12604832, upload-time = "2026-07-09T13:52:09.013Z" },
|
||||||
]
|
]
|
||||||
|
|||||||
+1
-2
@@ -121,7 +121,6 @@ Modules = [
|
|||||||
{Models = "module/models.md"},
|
{Models = "module/models.md"},
|
||||||
{Pytest = "module/pytest.md"},
|
{Pytest = "module/pytest.md"},
|
||||||
{Schemas = "module/schemas.md"},
|
{Schemas = "module/schemas.md"},
|
||||||
{Security = "module/security.md"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[project.nav]]
|
[[project.nav]]
|
||||||
@@ -137,7 +136,6 @@ Reference = [
|
|||||||
{Models = "reference/models.md"},
|
{Models = "reference/models.md"},
|
||||||
{Pytest = "reference/pytest.md"},
|
{Pytest = "reference/pytest.md"},
|
||||||
{Schemas = "reference/schemas.md"},
|
{Schemas = "reference/schemas.md"},
|
||||||
{Security = "reference/security.md"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[project.nav]]
|
[[project.nav]]
|
||||||
@@ -147,6 +145,7 @@ Examples = [
|
|||||||
|
|
||||||
[[project.nav]]
|
[[project.nav]]
|
||||||
Migration = [
|
Migration = [
|
||||||
|
{"v5.0" = "migration/v5.md"},
|
||||||
{"v4.0" = "migration/v4.md"},
|
{"v4.0" = "migration/v4.md"},
|
||||||
{"v3.0" = "migration/v3.md"},
|
{"v3.0" = "migration/v3.md"},
|
||||||
{"v2.0" = "migration/v2.md"},
|
{"v2.0" = "migration/v2.md"},
|
||||||
|
|||||||
Reference in New Issue
Block a user