mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-05 08:04:08 +00:00
Compare commits
21
Commits
v4.1.3
...
3eed5f01d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eed5f01d4 | ||
|
|
61dc4cf9b0 | ||
|
|
9eb7d006db | ||
|
|
31f8ad96f7 | ||
|
|
953d58e5cc | ||
|
|
498e75c624 | ||
|
|
541bfeb1a4 | ||
|
|
c16909f585 | ||
|
|
c256e58d64 | ||
|
|
040c023b8a | ||
|
|
06d49d5dc4
|
||
|
|
de06839a16 | ||
|
|
ff367e4281 | ||
|
|
9cb4c1474f | ||
|
|
44ba5bdd4b | ||
|
|
fe2c0f3eff | ||
|
|
70e0b3b9d5 | ||
|
|
1e021005bc | ||
|
|
025f1907fd
|
||
|
|
9698a0743b | ||
|
|
22f307d0fc |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+1
-1
@@ -167,7 +167,7 @@ user = await UserCrud.update(session, UserUpdate(credits=10), [User.id == user_i
|
|||||||
```
|
```
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
`with_for_update` requires an open transaction. Wrap your call in `async with session.begin()` or use the `get_transaction` helper if you are not already inside one.
|
`with_for_update` requires an open transaction. Wrap your call in `async with session.begin()` or use the `transaction` helper if you are not already inside one.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
`NOWAIT` raises `sqlalchemy.exc.OperationalError` immediately if the row is locked rather than waiting.
|
`NOWAIT` raises `sqlalchemy.exc.OperationalError` immediately if the row is locked rather than waiting.
|
||||||
|
|||||||
+114
-58
@@ -7,96 +7,156 @@ SQLAlchemy async session management with transactions, table locking, advisory l
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The `db` module provides helpers to create FastAPI dependencies and context managers for `AsyncSession`, along with utilities for nested transactions, table locks, advisory locks, and polling for row changes.
|
The `db` module is built around one object, [`Database`](../reference/db.md#fastapi_toolsets.db.Database), which owns the engine and sessionmaker and exposes the FastAPI dependency, a commit-before-response middleware, session/transaction context managers, and table locking. Free helpers cover savepoint-aware transactions, advisory locks, many-to-many association tables, and row-change polling.
|
||||||
|
|
||||||
## Session dependency
|
## Setup
|
||||||
|
|
||||||
Use [`create_db_dependency`](../reference/db.md#fastapi_toolsets.db.create_db_dependency) to create a FastAPI dependency that yields a session and auto-commits on success:
|
Create one `Database` for your app. Provide a **URL** (the facade builds and disposes the engine) or pass an existing **`engine=`** you own (e.g. for Alembic or `event.listen`). The session factory is built internally with `expire_on_commit=False`.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
from fastapi import Depends, FastAPI
|
||||||
from fastapi_toolsets.db import create_db_dependency
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
engine = create_async_engine(url="postgresql+asyncpg://...", future=True)
|
from fastapi_toolsets.db import Database
|
||||||
session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
|
|
||||||
|
|
||||||
get_db = create_db_dependency(session_maker=session_maker)
|
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
|
||||||
|
|
||||||
@router.get("/users")
|
app = FastAPI()
|
||||||
async def list_users(session: AsyncSession = Depends(get_db)):
|
db.install(app) # commit middleware + engine disposal on shutdown
|
||||||
|
|
||||||
|
@app.get("/users")
|
||||||
|
async def list_users(session: AsyncSession = Depends(db)):
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `Database` instance **is** the dependency: use it directly as `Depends(db)`. The whole request runs as a single transaction (CRUD writes use savepoints under it).
|
||||||
|
|
||||||
|
The **URL** may be a plain string or a Pydantic [`PostgresDsn`](https://docs.pydantic.dev/latest/api/networks/#pydantic.networks.PostgresDsn). In URL mode you can tune the engine: pass `connect_args` for DBAPI-level options and any other keyword for `create_async_engine` (e.g. `pool_size`, `echo`, `pool_pre_ping`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from pydantic import PostgresDsn
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
database_url: PostgresDsn
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
db = Database(
|
||||||
|
settings.database_url,
|
||||||
|
pool_size=20,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
connect_args={"server_settings": {"application_name": "myapp"}},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Committing before the response
|
||||||
|
|
||||||
|
[`db.install(app)`](../reference/db.md#fastapi_toolsets.db.Database) adds a middleware that commits the request's session when the response starts, after the endpoint returns and before the body is sent. With the middleware installed, the dependency does not commit again.
|
||||||
|
|
||||||
|
The request is committed as a single transaction:
|
||||||
|
|
||||||
|
- **Read-after-write**: a follow-up request sees the write.
|
||||||
|
- **Atomicity**: multi-write endpoints roll back as a unit on failure.
|
||||||
|
- **Errors roll back**: on a raised exception the session rolls back and nothing is committed.
|
||||||
|
|
||||||
|
Without `install`, the session commits in the dependency teardown, which runs after the response has been sent.
|
||||||
|
|
||||||
|
!!! warning "Streaming / SSE endpoints"
|
||||||
|
For a `StreamingResponse` / `EventSourceResponse`, the commit fires at the **start** of the stream. A stream that **writes** must open a short-lived session per write with [`db.session()`](#session-context-manager); the start-time commit will not flush writes made later during the stream.
|
||||||
|
|
||||||
|
## Lifespan
|
||||||
|
|
||||||
|
`db.install(app)` disposes the engine on shutdown, composing around your own lifespan:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app):
|
||||||
|
await warm_cache() # your startup
|
||||||
|
yield
|
||||||
|
await flush_metrics() # your shutdown
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
db.install(app) # your shutdown runs first, then the engine is disposed
|
||||||
|
```
|
||||||
|
|
||||||
|
If you have no lifespan of your own, [`db.lifespan`](../reference/db.md#fastapi_toolsets.db.Database) works standalone as `FastAPI(lifespan=db.lifespan)`. Engine disposal is idempotent and is a no-op when you passed your own `engine=`.
|
||||||
|
|
||||||
## Session context manager
|
## Session context manager
|
||||||
|
|
||||||
Use [`create_db_context`](../reference/db.md#fastapi_toolsets.db.create_db_context) for sessions outside request handlers (e.g. background tasks, CLI commands):
|
Use [`db.session()`](../reference/db.md#fastapi_toolsets.db.Database) for sessions outside request handlers (e.g. background tasks, CLI commands). It commits on clean exit and rolls back on exception:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import create_db_context
|
|
||||||
|
|
||||||
db_context = create_db_context(session_maker=session_maker)
|
|
||||||
|
|
||||||
async def seed():
|
async def seed():
|
||||||
async with db_context() as session:
|
async with db.session() as session:
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Nested transactions
|
## Transactions
|
||||||
|
|
||||||
[`get_transaction`](../reference/db.md#fastapi_toolsets.db.get_transaction) handles savepoints automatically, allowing safe nesting:
|
[`transaction`](../reference/db.md#fastapi_toolsets.db.transaction) opens a transaction on a session, using a savepoint when one is already open so it nests safely:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import get_transaction
|
from fastapi_toolsets.db import transaction
|
||||||
|
|
||||||
async def create_user_with_role(session=session):
|
async def create_user_with_role(session):
|
||||||
async with get_transaction(session=session):
|
async with transaction(session):
|
||||||
...
|
...
|
||||||
async with get_transaction(session=session): # uses savepoint
|
async with transaction(session): # uses a savepoint
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When you have a `Database`, [`db.begin()`](../reference/db.md#fastapi_toolsets.db.Database) opens a session already inside a transaction:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async with db.begin() as session:
|
||||||
|
session.add(User(name="ada")) # commits on exit, rolls back on exception
|
||||||
|
```
|
||||||
|
|
||||||
## Table locking
|
## Table locking
|
||||||
|
|
||||||
[`lock_tables`](../reference/db.md#fastapi_toolsets.db.lock_tables) acquires PostgreSQL table-level locks before executing critical sections. It opens a **dedicated session** internally and yields it to the caller, so the lock is guaranteed to be released when the context exits:
|
[`db.lock_tables`](../reference/db.md#fastapi_toolsets.db.Database) acquires PostgreSQL table-level locks for a critical section. It opens a dedicated session internally and releases the lock when the context exits:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import lock_tables, LockMode
|
from fastapi_toolsets.db import LockMode
|
||||||
|
|
||||||
async with lock_tables(session_maker=session_maker, tables=[User], mode=LockMode.EXCLUSIVE) as session:
|
async with db.lock_tables([User], mode=LockMode.EXCLUSIVE) as session:
|
||||||
# No other transaction can modify User until this block exits
|
# No other transaction can modify User until this block exits
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
Available lock modes are defined in [`LockMode`](../reference/db.md#fastapi_toolsets.db.LockMode): `ACCESS_SHARE`, `ROW_SHARE`, `ROW_EXCLUSIVE`, `SHARE_UPDATE_EXCLUSIVE`, `SHARE`, `SHARE_ROW_EXCLUSIVE`, `EXCLUSIVE`, `ACCESS_EXCLUSIVE`.
|
Available lock modes are defined in [`LockMode`](../reference/db.md#fastapi_toolsets.db.LockMode): `ACCESS_SHARE`, `ROW_SHARE`, `ROW_EXCLUSIVE`, `SHARE_UPDATE_EXCLUSIVE`, `SHARE`, `SHARE_ROW_EXCLUSIVE`, `EXCLUSIVE`, `ACCESS_EXCLUSIVE`.
|
||||||
|
|
||||||
Pass `timeout` to limit how long the lock waits before giving up. On timeout, a [`LockTimeoutError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.LockTimeoutError) is raised instead of a raw database error:
|
Pass `timeout` to limit how long the lock waits. On timeout, a [`LockTimeoutError`](../reference/exceptions.md#fastapi_toolsets.exceptions.exceptions.LockTimeoutError) is raised instead of a raw database error:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
async with lock_tables(session_maker, [Order], timeout="2s") as session:
|
async with db.lock_tables([Order], timeout="2s") as session:
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Advisory locking
|
## Advisory locking
|
||||||
|
|
||||||
[`advisory_lock`](../reference/db.md#fastapi_toolsets.db.advisory_lock) acquires a PostgreSQL session-level advisory lock. The lock is released explicitly when the context exits, regardless of whether the transaction has committed.
|
[`advisory_lock`](../reference/db.md#fastapi_toolsets.db.advisory_lock) acquires a PostgreSQL session-level advisory lock on a session you provide. The lock is released when the context exits:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import advisory_lock
|
from fastapi_toolsets.db import advisory_lock
|
||||||
|
|
||||||
# Blocking exclusive lock — waits until the lock is free
|
# Blocking exclusive lock: waits until the lock is free
|
||||||
async with advisory_lock(session=session, key=42):
|
async with advisory_lock(session=session, key=42):
|
||||||
...
|
...
|
||||||
|
|
||||||
# Non-blocking — yields False immediately if already held
|
# Non-blocking: yields False immediately if already held
|
||||||
async with advisory_lock(session=session, key=42, nowait=True) as acquired:
|
async with advisory_lock(session=session, key=42, nowait=True) as acquired:
|
||||||
if not acquired:
|
if not acquired:
|
||||||
raise HTTPException(409, "Resource is locked")
|
raise HTTPException(409, "Resource is locked")
|
||||||
|
|
||||||
# Blocking with a timeout — raises LockTimeoutError if not acquired in time
|
# Blocking with a timeout: raises LockTimeoutError if not acquired in time
|
||||||
async with advisory_lock(session=session, key=42, timeout="5s"):
|
async with advisory_lock(session=session, key=42, timeout="5s"):
|
||||||
...
|
...
|
||||||
|
|
||||||
# Shared — multiple readers allowed simultaneously, blocks exclusive writers
|
# Shared lock: multiple readers allowed simultaneously, blocks exclusive writers
|
||||||
async with advisory_lock(session=session, key=42, shared=True):
|
async with advisory_lock(session=session, key=42, shared=True):
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -106,11 +166,11 @@ async with advisory_lock(session=session, key=(1, user_id)):
|
|||||||
```
|
```
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
Advisory locks use PostgreSQL session-level functions (`pg_advisory_lock` / `pg_advisory_unlock`). The lock is tied to the database connection, not the SQLAlchemy transaction — it is released when the context exits, even if the surrounding transaction is still open.
|
Advisory locks use PostgreSQL session-level functions (`pg_advisory_lock` / `pg_advisory_unlock`). The lock is tied to the database connection, not the SQLAlchemy transaction, so it is released when the context exits even if the surrounding transaction is still open.
|
||||||
|
|
||||||
## Row-change polling
|
## Row-change polling
|
||||||
|
|
||||||
[`wait_for_row_change`](../reference/db.md#fastapi_toolsets.db.wait_for_row_change) polls a row until a specific column changes value, useful for waiting on async side effects:
|
[`wait_for_row_change`](../reference/db.md#fastapi_toolsets.db.wait_for_row_change) polls a row until a specific column changes value:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import wait_for_row_change
|
from fastapi_toolsets.db import wait_for_row_change
|
||||||
@@ -120,7 +180,7 @@ await wait_for_row_change(
|
|||||||
session=session,
|
session=session,
|
||||||
model=Order,
|
model=Order,
|
||||||
pk_value=order_id,
|
pk_value=order_id,
|
||||||
columns=[Order.status],
|
columns=["status"],
|
||||||
interval=1.0,
|
interval=1.0,
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
)
|
)
|
||||||
@@ -128,28 +188,24 @@ await wait_for_row_change(
|
|||||||
|
|
||||||
## Creating a database
|
## Creating a database
|
||||||
|
|
||||||
!!! info "Added in `v2.1`"
|
[`create_database`](../reference/db.md#fastapi_toolsets.db.testing.create_database) (in `fastapi_toolsets.db.testing`) connects to *server_url* and issues a `CREATE DATABASE` statement:
|
||||||
|
|
||||||
[`create_database`](../reference/db.md#fastapi_toolsets.db.create_database) creates a database at a given URL. It connects to *server_url* and issues a `CREATE DATABASE` statement:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import create_database
|
from fastapi_toolsets.db.testing import create_database
|
||||||
|
|
||||||
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
||||||
|
|
||||||
await create_database(db_name="myapp_test", server_url=SERVER_URL)
|
await create_database(db_name="myapp_test", server_url=SERVER_URL)
|
||||||
```
|
```
|
||||||
|
|
||||||
For test isolation with automatic cleanup, use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) from the `pytest` module instead — it handles drop-before, create, and drop-after automatically.
|
For test isolation with automatic cleanup, use [`create_worker_database`](../reference/pytest.md#fastapi_toolsets.pytest.utils.create_worker_database) from the `pytest` module, which handles drop-before, create, and drop-after.
|
||||||
|
|
||||||
## Cleaning up tables
|
## Cleaning up tables
|
||||||
|
|
||||||
!!! info "Added in `v2.1`"
|
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.testing.cleanup_tables) (in `fastapi_toolsets.db.testing`) truncates all tables:
|
||||||
|
|
||||||
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables) truncates all tables:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import cleanup_tables
|
from fastapi_toolsets.db.testing import cleanup_tables
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
async def clean(db_session):
|
async def clean(db_session):
|
||||||
@@ -159,50 +215,50 @@ async def clean(db_session):
|
|||||||
|
|
||||||
## Many-to-Many helpers
|
## Many-to-Many helpers
|
||||||
|
|
||||||
SQLAlchemy's ORM collection API triggers lazy-loads when you append to a relationship inside a savepoint (e.g. inside `lock_tables` or a nested `get_transaction`). The three `m2m_*` helpers bypass the ORM collection entirely and issue direct SQL against the association table.
|
The three `m2m_*` helpers modify a many-to-many association table with direct SQL, without loading the ORM collection.
|
||||||
|
|
||||||
### `m2m_add` — insert associations
|
### `m2m_add`: insert associations
|
||||||
|
|
||||||
[`m2m_add`](../reference/db.md#fastapi_toolsets.db.m2m_add) inserts one or more rows into a secondary table without touching the ORM collection:
|
[`m2m_add`](../reference/db.md#fastapi_toolsets.db.m2m_add) inserts one or more rows into a secondary table:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import lock_tables, m2m_add
|
from fastapi_toolsets.db import m2m_add
|
||||||
|
|
||||||
async with lock_tables(session_maker, [Tag]) as session:
|
async with db.lock_tables([Tag]) as session:
|
||||||
tag = await TagCrud.create(session, TagCreate(name="python"))
|
tag = await TagCrud.create(session, TagCreate(name="python"))
|
||||||
await m2m_add(session, post, Post.tags, tag)
|
await m2m_add(session, post, Post.tags, tag)
|
||||||
```
|
```
|
||||||
|
|
||||||
Pass `ignore_conflicts=True` to silently skip associations that already exist:
|
Pass `ignore_conflicts=True` to skip associations that already exist:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
await m2m_add(session, post, Post.tags, tag, ignore_conflicts=True)
|
await m2m_add(session, post, Post.tags, tag, ignore_conflicts=True)
|
||||||
```
|
```
|
||||||
|
|
||||||
### `m2m_remove` — delete associations
|
### `m2m_remove`: delete associations
|
||||||
|
|
||||||
[`m2m_remove`](../reference/db.md#fastapi_toolsets.db.m2m_remove) deletes specific association rows. Removing a non-existent association is a no-op:
|
[`m2m_remove`](../reference/db.md#fastapi_toolsets.db.m2m_remove) deletes specific association rows. Removing a non-existent association is a no-op:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import get_transaction, m2m_remove
|
from fastapi_toolsets.db import m2m_remove, transaction
|
||||||
|
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
await m2m_remove(session, post, Post.tags, tag1, tag2)
|
await m2m_remove(session, post, Post.tags, tag1, tag2)
|
||||||
```
|
```
|
||||||
|
|
||||||
### `m2m_set` — replace the full set
|
### `m2m_set`: replace the full set
|
||||||
|
|
||||||
[`m2m_set`](../reference/db.md#fastapi_toolsets.db.m2m_set) atomically replaces all associations: it deletes every existing row for the owner instance then inserts the new set. Passing no related instances clears the association entirely:
|
[`m2m_set`](../reference/db.md#fastapi_toolsets.db.m2m_set) replaces all associations: it deletes every existing row for the owner instance then inserts the new set. Passing no related instances clears the association:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import get_transaction, m2m_set
|
from fastapi_toolsets.db import m2m_set, transaction
|
||||||
|
|
||||||
# Replace all tags
|
# Replace all tags
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
await m2m_set(session, post, Post.tags, tag_a, tag_b)
|
await m2m_set(session, post, Post.tags, tag_a, tag_b)
|
||||||
|
|
||||||
# Clear all tags
|
# Clear all tags
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
await m2m_set(session, post, Post.tags)
|
await m2m_set(session, post, Post.tags)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+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
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=EventSe
|
|||||||
```
|
```
|
||||||
|
|
||||||
!!! info "Callbacks fire on `session.commit()` only — not on savepoints."
|
!!! info "Callbacks fire on `session.commit()` only — not on savepoints."
|
||||||
Savepoints created by [`get_transaction`](db.md) or `begin_nested()` do **not**
|
Savepoints created by [`transaction`](db.md) or `begin_nested()` do **not**
|
||||||
trigger callbacks. All events accumulated across flushes are dispatched once
|
trigger callbacks. All events accumulated across flushes are dispatched once
|
||||||
when the outermost `commit()` is called.
|
when the outermost `commit()` is called.
|
||||||
|
|
||||||
|
|||||||
@@ -107,10 +107,10 @@ url = worker_database_url("postgresql+asyncpg://user:pass@localhost/myapp", defa
|
|||||||
|
|
||||||
## Manual table cleanup
|
## Manual table cleanup
|
||||||
|
|
||||||
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.cleanup_tables) truncates all tables in a single statement and can be called directly when you need more control:
|
[`cleanup_tables`](../reference/db.md#fastapi_toolsets.db.testing.cleanup_tables) truncates all tables in a single statement and can be called directly when you need more control:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import cleanup_tables
|
from fastapi_toolsets.pytest import cleanup_tables
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
async def clean(db_session):
|
async def clean(db_session):
|
||||||
|
|||||||
+20
-18
@@ -1,46 +1,48 @@
|
|||||||
# `db`
|
# `db`
|
||||||
|
|
||||||
Here's the reference for all database session utilities, transaction helpers, and locking functions.
|
Here's the reference for the `Database` facade, the transaction helper, locking
|
||||||
|
functions, many-to-many helpers, and row-watching utilities.
|
||||||
|
|
||||||
You can import them directly from `fastapi_toolsets.db`:
|
You can import them directly from `fastapi_toolsets.db`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from fastapi_toolsets.db import (
|
from fastapi_toolsets.db import (
|
||||||
|
Database,
|
||||||
LockMode,
|
LockMode,
|
||||||
advisory_lock,
|
advisory_lock,
|
||||||
cleanup_tables,
|
|
||||||
create_database,
|
|
||||||
create_db_dependency,
|
|
||||||
create_db_context,
|
|
||||||
get_transaction,
|
|
||||||
lock_tables,
|
lock_tables,
|
||||||
m2m_add,
|
m2m_add,
|
||||||
m2m_remove,
|
m2m_remove,
|
||||||
m2m_set,
|
m2m_set,
|
||||||
|
transaction,
|
||||||
wait_for_row_change,
|
wait_for_row_change,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.Database
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.transaction
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.LockMode
|
## ::: fastapi_toolsets.db.LockMode
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.create_db_dependency
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.create_db_context
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.get_transaction
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.lock_tables
|
## ::: fastapi_toolsets.db.lock_tables
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.advisory_lock
|
## ::: fastapi_toolsets.db.advisory_lock
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.wait_for_row_change
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.create_database
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.cleanup_tables
|
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.m2m_add
|
## ::: fastapi_toolsets.db.m2m_add
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.m2m_remove
|
## ::: fastapi_toolsets.db.m2m_remove
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.m2m_set
|
## ::: fastapi_toolsets.db.m2m_set
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.wait_for_row_change
|
||||||
|
|
||||||
|
Admin and test helpers live in `fastapi_toolsets.db.testing`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db.testing import cleanup_tables, create_database
|
||||||
|
```
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.testing.create_database
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.testing.cleanup_tables
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ from fastapi import FastAPI
|
|||||||
|
|
||||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||||
|
|
||||||
|
from .db import db
|
||||||
from .routes import router
|
from .routes import router
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
db.install(app=app)
|
||||||
init_exceptions_handlers(app=app)
|
init_exceptions_handlers(app=app)
|
||||||
app.include_router(router=router)
|
app.include_router(router=router)
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from fastapi_toolsets.db import create_db_context, create_db_dependency
|
from fastapi_toolsets.db import Database
|
||||||
|
|
||||||
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres"
|
DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/postgres"
|
||||||
|
|
||||||
engine = create_async_engine(url=DATABASE_URL, future=True)
|
db = Database(url=DATABASE_URL)
|
||||||
async_session_maker = async_sessionmaker(bind=engine, expire_on_commit=False)
|
|
||||||
|
|
||||||
get_db = create_db_dependency(session_maker=async_session_maker)
|
get_db = db
|
||||||
get_db_context = create_db_context(session_maker=async_session_maker)
|
|
||||||
|
|
||||||
|
SessionDep = Annotated[AsyncSession, Depends(db)]
|
||||||
SessionDep = Annotated[AsyncSession, Depends(get_db)]
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "4.1.3"
|
version = "5.0.0b2"
|
||||||
description = "Production-ready utilities for FastAPI applications"
|
description = "Production-ready utilities for FastAPI applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -7,18 +7,21 @@ Example usage:
|
|||||||
from fastapi import FastAPI, Depends
|
from fastapi import FastAPI, Depends
|
||||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||||
from fastapi_toolsets.crud import CrudFactory
|
from fastapi_toolsets.crud import CrudFactory
|
||||||
from fastapi_toolsets.db import create_db_dependency
|
from fastapi_toolsets.db import Database
|
||||||
from fastapi_toolsets.schemas import Response
|
from fastapi_toolsets.schemas import Response
|
||||||
|
|
||||||
|
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
db.install(app)
|
||||||
init_exceptions_handlers(app)
|
init_exceptions_handlers(app)
|
||||||
|
|
||||||
UserCrud = CrudFactory(User)
|
UserCrud = CrudFactory(User)
|
||||||
|
|
||||||
@app.get("/users/{user_id}", response_model=Response[dict])
|
@app.get("/users/{user_id}", response_model=Response[dict])
|
||||||
async def get_user(user_id: int, session = Depends(get_db)):
|
async def get_user(user_id: int, session = Depends(db)):
|
||||||
user = await UserCrud.get(session, [User.id == user_id])
|
user = await UserCrud.get(session, [User.id == user_id])
|
||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "4.1.3"
|
__version__ = "5.0.0b2"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from sqlalchemy.orm import DeclarativeBase, QueryableAttribute, selectinload
|
|||||||
from sqlalchemy.sql.base import ExecutableOption
|
from sqlalchemy.sql.base import ExecutableOption
|
||||||
from sqlalchemy.sql.roles import WhereHavingRole
|
from sqlalchemy.sql.roles import WhereHavingRole
|
||||||
|
|
||||||
from ..db import get_transaction
|
from ..db import transaction
|
||||||
from ..exceptions import InvalidOrderFieldError, NotFoundError
|
from ..exceptions import InvalidOrderFieldError, NotFoundError
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
CursorPaginatedResponse,
|
CursorPaginatedResponse,
|
||||||
@@ -190,7 +190,8 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
"""Re-query instance by PK with default_load_options applied."""
|
"""Re-query instance by PK with default_load_options applied."""
|
||||||
mapper = cls.model.__mapper__
|
mapper = cls.model.__mapper__
|
||||||
pk_filters = [
|
pk_filters = [
|
||||||
getattr(cls.model, col.key) == getattr(instance, col.key)
|
getattr(cls.model, cast(str, col.key))
|
||||||
|
== getattr(instance, cast(str, col.key))
|
||||||
for col in mapper.primary_key
|
for col in mapper.primary_key
|
||||||
]
|
]
|
||||||
return await cls.get(session, filters=pk_filters)
|
return await cls.get(session, filters=pk_filters)
|
||||||
@@ -716,7 +717,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
Returns:
|
Returns:
|
||||||
Created model instance, or ``Response[schema]`` when ``schema`` is given.
|
Created model instance, or ``Response[schema]`` when ``schema`` is given.
|
||||||
"""
|
"""
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
m2m_exclude = cls._m2m_schema_fields()
|
m2m_exclude = cls._m2m_schema_fields()
|
||||||
data = (
|
data = (
|
||||||
obj.model_dump(exclude=m2m_exclude) if m2m_exclude else obj.model_dump()
|
obj.model_dump(exclude=m2m_exclude) if m2m_exclude else obj.model_dump()
|
||||||
@@ -1067,7 +1068,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
Raises:
|
Raises:
|
||||||
NotFoundError: If no record found
|
NotFoundError: If no record found
|
||||||
"""
|
"""
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
m2m_exclude = cls._m2m_schema_fields()
|
m2m_exclude = cls._m2m_schema_fields()
|
||||||
|
|
||||||
# Eagerly load M2M relationships that will be updated so that
|
# Eagerly load M2M relationships that will be updated so that
|
||||||
@@ -1127,7 +1128,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
Returns:
|
Returns:
|
||||||
Model instance
|
Model instance
|
||||||
"""
|
"""
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
values = obj.model_dump(exclude_unset=True)
|
values = obj.model_dump(exclude_unset=True)
|
||||||
q = insert(cls.model).values(**values)
|
q = insert(cls.model).values(**values)
|
||||||
if set_:
|
if set_:
|
||||||
@@ -1189,7 +1190,7 @@ class AsyncCrud(Generic[ModelType]):
|
|||||||
Returns:
|
Returns:
|
||||||
``None``, or ``Response[None]`` when ``return_response=True``.
|
``None``, or ``Response[None]`` when ``return_response=True``.
|
||||||
"""
|
"""
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
result = await session.execute(select(cls.model).where(and_(*filters)))
|
result = await session.execute(select(cls.model).where(and_(*filters)))
|
||||||
objects = result.scalars().all()
|
objects = result.scalars().all()
|
||||||
for obj in objects:
|
for obj in objects:
|
||||||
|
|||||||
@@ -1,591 +0,0 @@
|
|||||||
"""Database utilities: sessions, transactions, and locks."""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from collections.abc import AsyncGenerator, Callable
|
|
||||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Any, TypeVar, cast
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
from sqlalchemy import Table, delete, text, tuple_
|
|
||||||
from sqlalchemy import exc as sa_exc
|
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
||||||
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
|
|
||||||
from sqlalchemy.orm.relationships import RelationshipProperty
|
|
||||||
|
|
||||||
from .exceptions import LockTimeoutError, NotFoundError, PoolExhaustedError
|
|
||||||
|
|
||||||
|
|
||||||
def _is_lock_not_available(e: sa_exc.DBAPIError) -> bool:
|
|
||||||
return e.orig is not None and isinstance(
|
|
||||||
e.orig.__cause__, asyncpg.exceptions.LockNotAvailableError
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"LockMode",
|
|
||||||
"advisory_lock",
|
|
||||||
"cleanup_tables",
|
|
||||||
"create_database",
|
|
||||||
"create_db_context",
|
|
||||||
"create_db_dependency",
|
|
||||||
"get_transaction",
|
|
||||||
"lock_tables",
|
|
||||||
"m2m_add",
|
|
||||||
"m2m_remove",
|
|
||||||
"m2m_set",
|
|
||||||
"wait_for_row_change",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
_SessionT = TypeVar("_SessionT", bound=AsyncSession)
|
|
||||||
|
|
||||||
|
|
||||||
def create_db_dependency(
|
|
||||||
session_maker: async_sessionmaker[_SessionT],
|
|
||||||
) -> Callable[[], AsyncGenerator[_SessionT, None]]:
|
|
||||||
"""Create a FastAPI dependency for database sessions.
|
|
||||||
|
|
||||||
Creates a dependency function that yields a session and auto-commits
|
|
||||||
if a transaction is active when the request completes.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session_maker: Async session factory from create_session_factory()
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
An async generator function usable with FastAPI's Depends()
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi import Depends
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
|
||||||
from fastapi_toolsets.db import create_db_dependency
|
|
||||||
|
|
||||||
engine = create_async_engine("postgresql+asyncpg://...")
|
|
||||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
get_db = create_db_dependency(SessionLocal)
|
|
||||||
|
|
||||||
@app.get("/users")
|
|
||||||
async def list_users(session: AsyncSession = Depends(get_db)):
|
|
||||||
...
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def get_db() -> AsyncGenerator[_SessionT, None]:
|
|
||||||
async with session_maker() as session:
|
|
||||||
try:
|
|
||||||
await session.connection()
|
|
||||||
except sa_exc.TimeoutError as e:
|
|
||||||
raise PoolExhaustedError() from e
|
|
||||||
yield session
|
|
||||||
if session.in_transaction():
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
return get_db
|
|
||||||
|
|
||||||
|
|
||||||
def create_db_context(
|
|
||||||
session_maker: async_sessionmaker[_SessionT],
|
|
||||||
) -> Callable[[], AbstractAsyncContextManager[_SessionT]]:
|
|
||||||
"""Create a context manager for database sessions.
|
|
||||||
|
|
||||||
Creates a context manager for use outside of FastAPI request handlers,
|
|
||||||
such as in background tasks, CLI commands, or tests.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session_maker: Async session factory from create_session_factory()
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
An async context manager function
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
|
|
||||||
from fastapi_toolsets.db import create_db_context
|
|
||||||
|
|
||||||
engine = create_async_engine("postgresql+asyncpg://...")
|
|
||||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
get_db_context = create_db_context(SessionLocal)
|
|
||||||
|
|
||||||
async def background_task():
|
|
||||||
async with get_db_context() as session:
|
|
||||||
user = await UserCrud.get(session, [User.id == 1])
|
|
||||||
...
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
get_db = create_db_dependency(session_maker)
|
|
||||||
return asynccontextmanager(get_db)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def get_transaction(
|
|
||||||
session: AsyncSession,
|
|
||||||
) -> AsyncGenerator[AsyncSession, None]:
|
|
||||||
"""Get a transaction context, handling nested transactions.
|
|
||||||
|
|
||||||
If already in a transaction, creates a savepoint (nested transaction).
|
|
||||||
Otherwise, starts a new transaction.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: AsyncSession instance
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
The session within the transaction context
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
async with get_transaction(session):
|
|
||||||
session.add(model)
|
|
||||||
# Auto-commits on exit, rolls back on exception
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
if session.in_transaction():
|
|
||||||
async with session.begin_nested():
|
|
||||||
yield session
|
|
||||||
else:
|
|
||||||
async with session.begin():
|
|
||||||
yield session
|
|
||||||
|
|
||||||
|
|
||||||
class LockMode(str, Enum):
|
|
||||||
"""PostgreSQL table lock modes.
|
|
||||||
|
|
||||||
See: https://www.postgresql.org/docs/current/explicit-locking.html
|
|
||||||
"""
|
|
||||||
|
|
||||||
ACCESS_SHARE = "ACCESS SHARE"
|
|
||||||
ROW_SHARE = "ROW SHARE"
|
|
||||||
ROW_EXCLUSIVE = "ROW EXCLUSIVE"
|
|
||||||
SHARE_UPDATE_EXCLUSIVE = "SHARE UPDATE EXCLUSIVE"
|
|
||||||
SHARE = "SHARE"
|
|
||||||
SHARE_ROW_EXCLUSIVE = "SHARE ROW EXCLUSIVE"
|
|
||||||
EXCLUSIVE = "EXCLUSIVE"
|
|
||||||
ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE"
|
|
||||||
|
|
||||||
|
|
||||||
def lock_tables(
|
|
||||||
session_maker: async_sessionmaker[_SessionT],
|
|
||||||
tables: list[type[DeclarativeBase]],
|
|
||||||
*,
|
|
||||||
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
|
|
||||||
timeout: str = "5s",
|
|
||||||
) -> AbstractAsyncContextManager[_SessionT]:
|
|
||||||
"""Lock PostgreSQL tables for the duration of a transaction.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session_maker: Async session factory used to create the dedicated
|
|
||||||
session.
|
|
||||||
tables: List of SQLAlchemy model classes to lock.
|
|
||||||
mode: Lock mode (default: SHARE UPDATE EXCLUSIVE).
|
|
||||||
timeout: Lock timeout (default: "5s").
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
The dedicated session, open within the locked transaction.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
SQLAlchemyError: If the lock cannot be acquired within *timeout*.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import lock_tables, LockMode
|
|
||||||
|
|
||||||
async with lock_tables(session_maker, [User, Account]) as session:
|
|
||||||
# Tables are locked; changes are committed when the context exits.
|
|
||||||
user = await UserCrud.get(session, [User.id == 1])
|
|
||||||
user.balance += 100
|
|
||||||
|
|
||||||
# With custom lock mode
|
|
||||||
async with lock_tables(session_maker, [Order], mode=LockMode.EXCLUSIVE) as session:
|
|
||||||
await process_order(session, order_id)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
table_names = ",".join(table.__tablename__ for table in tables)
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def _lock() -> AsyncGenerator[_SessionT, None]:
|
|
||||||
async with session_maker() as session:
|
|
||||||
try:
|
|
||||||
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
|
||||||
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
|
|
||||||
yield session
|
|
||||||
await session.commit()
|
|
||||||
except sa_exc.TimeoutError as e:
|
|
||||||
await session.rollback()
|
|
||||||
raise PoolExhaustedError(
|
|
||||||
f"Connection pool exhausted while locking '{table_names}'. "
|
|
||||||
) from e
|
|
||||||
except sa_exc.DBAPIError as e:
|
|
||||||
await session.rollback()
|
|
||||||
if _is_lock_not_available(e):
|
|
||||||
raise LockTimeoutError(
|
|
||||||
f"Lock on '{table_names}' could not be acquired within {timeout}."
|
|
||||||
) from e
|
|
||||||
raise # pragma: no cover
|
|
||||||
except BaseException:
|
|
||||||
await session.rollback()
|
|
||||||
raise
|
|
||||||
|
|
||||||
return _lock()
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def advisory_lock(
|
|
||||||
session: AsyncSession,
|
|
||||||
key: int | tuple[int, int],
|
|
||||||
*,
|
|
||||||
shared: bool = False,
|
|
||||||
nowait: bool = False,
|
|
||||||
timeout: str | None = None,
|
|
||||||
) -> AsyncGenerator[bool, None]:
|
|
||||||
"""Acquire a PostgreSQL session-level advisory lock.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: AsyncSession instance.
|
|
||||||
key: Lock key — a single ``int`` (bigint) or a ``(int, int)`` pair for namespacing.
|
|
||||||
shared: Acquire a shared lock (multiple holders allowed). Default is exclusive.
|
|
||||||
nowait: Return ``False`` immediately if the lock is unavailable instead of waiting.
|
|
||||||
timeout: Maximum wait time (e.g. ``"5s"``, ``"500ms"``). Raises ``DBAPIError``
|
|
||||||
if exceeded. Ignored when *nowait* is ``True``.
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
``True`` if the lock was acquired, ``False`` if *nowait* is ``True`` and the lock
|
|
||||||
is already held.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
LockTimeoutError: If *timeout* is set and the lock cannot be acquired in time.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import advisory_lock
|
|
||||||
|
|
||||||
async with advisory_lock(session, 42):
|
|
||||||
...
|
|
||||||
|
|
||||||
async with advisory_lock(session, 42, nowait=True) as acquired:
|
|
||||||
if not acquired:
|
|
||||||
raise HTTPException(409, "Resource is locked")
|
|
||||||
|
|
||||||
async with advisory_lock(session, 42, timeout="5s"):
|
|
||||||
...
|
|
||||||
|
|
||||||
async with advisory_lock(session, (1, user_id), shared=True):
|
|
||||||
...
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
suffix = "_shared" if shared else ""
|
|
||||||
acquire_fn = f"{'pg_try_advisory_lock' if nowait else 'pg_advisory_lock'}{suffix}"
|
|
||||||
release_fn = f"pg_advisory_unlock{suffix}"
|
|
||||||
|
|
||||||
if isinstance(key, tuple):
|
|
||||||
k1, k2 = key
|
|
||||||
args = "CAST(:k1 AS integer), CAST(:k2 AS integer)"
|
|
||||||
params: dict[str, int] = {"k1": k1, "k2": k2}
|
|
||||||
else:
|
|
||||||
args = ":k"
|
|
||||||
params = {"k": key}
|
|
||||||
|
|
||||||
acquire_sql = text(f"SELECT {acquire_fn}({args})")
|
|
||||||
release_sql = text(f"SELECT {release_fn}({args})")
|
|
||||||
|
|
||||||
if timeout is not None and not nowait:
|
|
||||||
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await session.execute(acquire_sql, params)
|
|
||||||
except sa_exc.DBAPIError as e:
|
|
||||||
if _is_lock_not_available(e):
|
|
||||||
raise LockTimeoutError(
|
|
||||||
f"Advisory lock {key!r} could not be acquired within {timeout}."
|
|
||||||
) from e
|
|
||||||
raise # pragma: no cover
|
|
||||||
acquired = result.scalar() if nowait else True
|
|
||||||
try:
|
|
||||||
yield acquired
|
|
||||||
finally:
|
|
||||||
if acquired:
|
|
||||||
await session.execute(release_sql, params)
|
|
||||||
|
|
||||||
|
|
||||||
async def create_database(
|
|
||||||
db_name: str,
|
|
||||||
*,
|
|
||||||
server_url: str,
|
|
||||||
) -> None:
|
|
||||||
"""Create a database.
|
|
||||||
|
|
||||||
Connects to *server_url* using ``AUTOCOMMIT`` isolation and issues a
|
|
||||||
``CREATE DATABASE`` statement for *db_name*.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_name: Name of the database to create.
|
|
||||||
server_url: URL used for server-level DDL (must point to an existing
|
|
||||||
database on the same server).
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import create_database
|
|
||||||
|
|
||||||
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
|
||||||
await create_database("myapp_test", server_url=SERVER_URL)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
engine = create_async_engine(server_url, isolation_level="AUTOCOMMIT")
|
|
||||||
try:
|
|
||||||
async with engine.connect() as conn:
|
|
||||||
await conn.execute(text(f"CREATE DATABASE {db_name}"))
|
|
||||||
finally:
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_tables(
|
|
||||||
session: AsyncSession,
|
|
||||||
base: type[DeclarativeBase],
|
|
||||||
) -> None:
|
|
||||||
"""Truncate all tables for fast between-test cleanup.
|
|
||||||
|
|
||||||
Executes a single ``TRUNCATE … RESTART IDENTITY CASCADE`` statement
|
|
||||||
across every table in *base*'s metadata, which is significantly faster
|
|
||||||
than dropping and re-creating tables between tests.
|
|
||||||
|
|
||||||
This is a no-op when the metadata contains no tables.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: An active async database session.
|
|
||||||
base: SQLAlchemy DeclarativeBase class containing model metadata.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
@pytest.fixture
|
|
||||||
async def db_session(worker_db_url):
|
|
||||||
async with create_db_session(worker_db_url, Base) as session:
|
|
||||||
yield session
|
|
||||||
await cleanup_tables(session, Base)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
tables = base.metadata.sorted_tables
|
|
||||||
if not tables:
|
|
||||||
return
|
|
||||||
|
|
||||||
table_names = ", ".join(f'"{t.name}"' for t in tables)
|
|
||||||
await session.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
_M = TypeVar("_M", bound=DeclarativeBase)
|
|
||||||
|
|
||||||
|
|
||||||
async def wait_for_row_change(
|
|
||||||
session: AsyncSession,
|
|
||||||
model: type[_M],
|
|
||||||
pk_value: Any,
|
|
||||||
*,
|
|
||||||
columns: list[str] | None = None,
|
|
||||||
interval: float = 0.5,
|
|
||||||
timeout: float | None = None,
|
|
||||||
) -> _M:
|
|
||||||
"""Poll a database row until a change is detected.
|
|
||||||
|
|
||||||
Queries the row every ``interval`` seconds and returns the model instance
|
|
||||||
once a change is detected in any column (or only the specified ``columns``).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: AsyncSession instance
|
|
||||||
model: SQLAlchemy model class
|
|
||||||
pk_value: Primary key value of the row to watch
|
|
||||||
columns: Optional list of column names to watch. If None, all columns
|
|
||||||
are watched.
|
|
||||||
interval: Polling interval in seconds (default: 0.5)
|
|
||||||
timeout: Maximum time to wait in seconds. None means wait forever.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The refreshed model instance with updated values
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
NotFoundError: If the row does not exist or is deleted during polling
|
|
||||||
TimeoutError: If timeout expires before a change is detected
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```python
|
|
||||||
from fastapi_toolsets.db import wait_for_row_change
|
|
||||||
|
|
||||||
# Wait for any column to change
|
|
||||||
updated = await wait_for_row_change(session, User, user_id)
|
|
||||||
|
|
||||||
# Watch specific columns with a timeout
|
|
||||||
updated = await wait_for_row_change(
|
|
||||||
session, User, user_id,
|
|
||||||
columns=["status", "email"],
|
|
||||||
interval=1.0,
|
|
||||||
timeout=30.0,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
instance = await session.get(model, pk_value)
|
|
||||||
if instance is None:
|
|
||||||
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
|
|
||||||
|
|
||||||
if columns is not None:
|
|
||||||
watch_cols = columns
|
|
||||||
else:
|
|
||||||
watch_cols = [attr.key for attr in model.__mapper__.column_attrs]
|
|
||||||
|
|
||||||
initial = {col: getattr(instance, col) for col in watch_cols}
|
|
||||||
|
|
||||||
elapsed = 0.0
|
|
||||||
while True:
|
|
||||||
await asyncio.sleep(interval)
|
|
||||||
elapsed += interval
|
|
||||||
|
|
||||||
if timeout is not None and elapsed >= timeout:
|
|
||||||
raise TimeoutError(
|
|
||||||
f"No change detected on {model.__name__} "
|
|
||||||
f"with pk={pk_value!r} within {timeout}s"
|
|
||||||
)
|
|
||||||
|
|
||||||
session.expunge(instance)
|
|
||||||
instance = await session.get(model, pk_value)
|
|
||||||
|
|
||||||
if instance is None:
|
|
||||||
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} was deleted")
|
|
||||||
|
|
||||||
current = {col: getattr(instance, col) for col in watch_cols}
|
|
||||||
if current != initial:
|
|
||||||
return instance
|
|
||||||
|
|
||||||
|
|
||||||
def _m2m_prop(rel_attr: QueryableAttribute) -> RelationshipProperty: # type: ignore[type-arg]
|
|
||||||
"""Return the validated M2M RelationshipProperty for *rel_attr*.
|
|
||||||
|
|
||||||
Raises TypeError if *rel_attr* is not a Many-to-Many relationship.
|
|
||||||
"""
|
|
||||||
prop = rel_attr.property
|
|
||||||
if not isinstance(prop, RelationshipProperty) or prop.secondary is None:
|
|
||||||
raise TypeError(
|
|
||||||
f"m2m helpers require a Many-to-Many relationship attribute, "
|
|
||||||
f"got {rel_attr!r}. Use a relationship with a secondary table."
|
|
||||||
)
|
|
||||||
return prop
|
|
||||||
|
|
||||||
|
|
||||||
async def m2m_add(
|
|
||||||
session: AsyncSession,
|
|
||||||
instance: DeclarativeBase,
|
|
||||||
rel_attr: QueryableAttribute,
|
|
||||||
*related: DeclarativeBase,
|
|
||||||
ignore_conflicts: bool = False,
|
|
||||||
) -> None:
|
|
||||||
"""Insert rows into a Many-to-Many association table without loading the ORM collection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: DB async session.
|
|
||||||
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
|
||||||
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
|
||||||
*related: One or more related instances to associate with ``instance``.
|
|
||||||
ignore_conflicts: When ``True``, silently skip rows that already exist
|
|
||||||
in the association table (``ON CONFLICT DO NOTHING``).
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
|
||||||
"""
|
|
||||||
prop = _m2m_prop(rel_attr)
|
|
||||||
if not related:
|
|
||||||
return
|
|
||||||
|
|
||||||
secondary = cast(Table, prop.secondary)
|
|
||||||
assert secondary is not None # guaranteed by _m2m_prop
|
|
||||||
sync_pairs = prop.secondary_synchronize_pairs
|
|
||||||
assert sync_pairs is not None # set whenever secondary is set
|
|
||||||
|
|
||||||
# synchronize_pairs: [(parent_col, assoc_col), ...]
|
|
||||||
# secondary_synchronize_pairs: [(related_col, assoc_col), ...]
|
|
||||||
rows: list[dict[str, Any]] = []
|
|
||||||
for rel_instance in related:
|
|
||||||
row: dict[str, Any] = {}
|
|
||||||
for parent_col, assoc_col in prop.synchronize_pairs:
|
|
||||||
row[assoc_col.name] = getattr(instance, cast(str, parent_col.key))
|
|
||||||
for related_col, assoc_col in sync_pairs:
|
|
||||||
row[assoc_col.name] = getattr(rel_instance, cast(str, related_col.key))
|
|
||||||
rows.append(row)
|
|
||||||
|
|
||||||
stmt = pg_insert(secondary).values(rows)
|
|
||||||
if ignore_conflicts:
|
|
||||||
stmt = stmt.on_conflict_do_nothing()
|
|
||||||
await session.execute(stmt)
|
|
||||||
|
|
||||||
|
|
||||||
async def m2m_remove(
|
|
||||||
session: AsyncSession,
|
|
||||||
instance: DeclarativeBase,
|
|
||||||
rel_attr: QueryableAttribute,
|
|
||||||
*related: DeclarativeBase,
|
|
||||||
) -> None:
|
|
||||||
"""Remove rows from a Many-to-Many association table without loading the ORM collection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: DB async session.
|
|
||||||
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
|
||||||
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
|
||||||
*related: One or more related instances to disassociate from ``instance``.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
|
||||||
"""
|
|
||||||
prop = _m2m_prop(rel_attr)
|
|
||||||
if not related:
|
|
||||||
return
|
|
||||||
|
|
||||||
secondary = cast(Table, prop.secondary)
|
|
||||||
assert secondary is not None # guaranteed by _m2m_prop
|
|
||||||
related_pairs = prop.secondary_synchronize_pairs
|
|
||||||
assert related_pairs is not None # set whenever secondary is set
|
|
||||||
|
|
||||||
parent_where = [
|
|
||||||
assoc_col == getattr(instance, cast(str, parent_col.key))
|
|
||||||
for parent_col, assoc_col in prop.synchronize_pairs
|
|
||||||
]
|
|
||||||
|
|
||||||
if len(related_pairs) == 1:
|
|
||||||
related_col, assoc_col = related_pairs[0]
|
|
||||||
related_values = [getattr(r, cast(str, related_col.key)) for r in related]
|
|
||||||
related_where = assoc_col.in_(related_values)
|
|
||||||
else:
|
|
||||||
assoc_cols = [ac for _, ac in related_pairs]
|
|
||||||
rel_cols = [rc for rc, _ in related_pairs]
|
|
||||||
related_values_t = [
|
|
||||||
tuple(getattr(r, cast(str, rc.key)) for rc in rel_cols) for r in related
|
|
||||||
]
|
|
||||||
related_where = tuple_(*assoc_cols).in_(related_values_t)
|
|
||||||
|
|
||||||
await session.execute(delete(secondary).where(*parent_where, related_where))
|
|
||||||
|
|
||||||
|
|
||||||
async def m2m_set(
|
|
||||||
session: AsyncSession,
|
|
||||||
instance: DeclarativeBase,
|
|
||||||
rel_attr: QueryableAttribute,
|
|
||||||
*related: DeclarativeBase,
|
|
||||||
) -> None:
|
|
||||||
"""Replace the entire Many-to-Many association set atomically.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
session: DB async session.
|
|
||||||
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
|
||||||
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
|
||||||
*related: The new complete set of related instances.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
|
||||||
"""
|
|
||||||
prop = _m2m_prop(rel_attr)
|
|
||||||
secondary = cast(Table, prop.secondary)
|
|
||||||
assert secondary is not None # guaranteed by _m2m_prop
|
|
||||||
|
|
||||||
parent_where = [
|
|
||||||
assoc_col == getattr(instance, cast(str, parent_col.key))
|
|
||||||
for parent_col, assoc_col in prop.synchronize_pairs
|
|
||||||
]
|
|
||||||
await session.execute(delete(secondary).where(*parent_where))
|
|
||||||
|
|
||||||
if related:
|
|
||||||
await m2m_add(session, instance, rel_attr, *related)
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Database package: the ``Database`` facade plus PostgreSQL power-tools."""
|
||||||
|
|
||||||
|
from .core import Database, transaction
|
||||||
|
from .locks import LockMode, advisory_lock, lock_tables
|
||||||
|
from .m2m import m2m_add, m2m_remove, m2m_set
|
||||||
|
from .watch import wait_for_row_change
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Database",
|
||||||
|
"LockMode",
|
||||||
|
"advisory_lock",
|
||||||
|
"lock_tables",
|
||||||
|
"m2m_add",
|
||||||
|
"m2m_remove",
|
||||||
|
"m2m_set",
|
||||||
|
"transaction",
|
||||||
|
"wait_for_row_change",
|
||||||
|
]
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"""The ``Database`` facade: session lifecycle, dependency, middleware, transactions."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import PostgresDsn
|
||||||
|
from sqlalchemy import exc as sa_exc
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||||
|
|
||||||
|
from ..exceptions import PoolExhaustedError
|
||||||
|
from .locks import LockMode, lock_tables
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def transaction(
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Run a block inside a savepoint-aware transaction.
|
||||||
|
|
||||||
|
If *session* is already in a transaction, a nested transaction (savepoint)
|
||||||
|
is opened so the block can roll back independently. Otherwise a top-level
|
||||||
|
transaction is started. Commits on clean exit, rolls back on exception.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: AsyncSession instance.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
The session within the transaction context.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import transaction
|
||||||
|
|
||||||
|
async with transaction(session):
|
||||||
|
session.add(model)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
if session.in_transaction():
|
||||||
|
async with session.begin_nested():
|
||||||
|
yield session
|
||||||
|
else:
|
||||||
|
async with session.begin():
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
class _CommitOnResponseMiddleware:
|
||||||
|
"""Commit the request's DB session before the response is sent."""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp, *, state_attr: str) -> None:
|
||||||
|
self.app = app
|
||||||
|
self.state_attr = state_attr
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
async def send_wrapper(message: Message) -> None:
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
# ``scope["state"]`` is the same dict ``request.state`` writes
|
||||||
|
# to, so this is the session stashed by the dependency.
|
||||||
|
state = scope.get("state")
|
||||||
|
session = state.get(self.state_attr) if state else None
|
||||||
|
if session is not None and session.in_transaction():
|
||||||
|
await session.commit()
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
await self.app(scope, receive, send_wrapper)
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
"""One object that owns the engine, sessions, dependency, and middleware.
|
||||||
|
|
||||||
|
Provide exactly one of *url* (the facade builds and disposes the engine) or
|
||||||
|
*engine* (an engine you own, e.g. for Alembic or ``event.listen``, left
|
||||||
|
untouched).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Database connection URL. Accepts a plain string or a Pydantic
|
||||||
|
:class:`~pydantic.PostgresDsn`.
|
||||||
|
engine: An existing :class:`AsyncEngine` to reuse instead of *url*.
|
||||||
|
session_class: Session class for the sessionmaker (e.g. ``EventSession``).
|
||||||
|
expire_on_commit: Expire attributes after commit. Defaults to ``False``.
|
||||||
|
autoflush: Autoflush the session before queries. Defaults to ``True``.
|
||||||
|
connect_args: DBAPI-level connection arguments forwarded to
|
||||||
|
:func:`create_async_engine` (URL mode only).
|
||||||
|
**engine_options: Extra keyword arguments forwarded to
|
||||||
|
:func:`create_async_engine` (URL mode only).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If neither or both of *url* and *engine* are given, or if
|
||||||
|
*connect_args*/*engine_options* are passed together with *engine*.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi import Depends, FastAPI
|
||||||
|
from fastapi_toolsets.db import Database
|
||||||
|
|
||||||
|
db = Database("postgresql+asyncpg://postgres:postgres@localhost/app")
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
db.install(app)
|
||||||
|
|
||||||
|
@app.get("/users/{user_id}")
|
||||||
|
async def get_user(user_id: int, session=Depends(db)):
|
||||||
|
return await UserCrud.get(session, [User.id == user_id])
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str | PostgresDsn | None = None,
|
||||||
|
*,
|
||||||
|
engine: AsyncEngine | None = None,
|
||||||
|
session_class: type[AsyncSession] = AsyncSession,
|
||||||
|
expire_on_commit: bool = False,
|
||||||
|
autoflush: bool = True,
|
||||||
|
connect_args: dict[str, Any] | None = None,
|
||||||
|
**engine_options: Any,
|
||||||
|
) -> None:
|
||||||
|
if (url is None) == (engine is None):
|
||||||
|
raise TypeError(
|
||||||
|
"Database requires exactly one of 'url' or 'engine' "
|
||||||
|
"(got both or neither)."
|
||||||
|
)
|
||||||
|
if engine is not None and (engine_options or connect_args is not None):
|
||||||
|
raise TypeError(
|
||||||
|
"connect_args/engine_options are only valid in URL mode; "
|
||||||
|
"configure the engine you pass via 'engine=' yourself."
|
||||||
|
)
|
||||||
|
|
||||||
|
if engine is not None:
|
||||||
|
self._owns_engine = False
|
||||||
|
self.engine: AsyncEngine = engine
|
||||||
|
else:
|
||||||
|
assert url is not None # guaranteed by the XOR check above
|
||||||
|
self._owns_engine = True
|
||||||
|
if connect_args is not None:
|
||||||
|
engine_options["connect_args"] = connect_args
|
||||||
|
# ``PostgresDsn`` (and other URL objects) are not str subclasses, so
|
||||||
|
# coerce to the string form SQLAlchemy expects.
|
||||||
|
self.engine = create_async_engine(str(url), **engine_options)
|
||||||
|
self._sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||||
|
self.engine,
|
||||||
|
class_=session_class,
|
||||||
|
expire_on_commit=expire_on_commit,
|
||||||
|
autoflush=autoflush,
|
||||||
|
)
|
||||||
|
# Private, per-instance state attribute; cannot collide with another
|
||||||
|
# Database or be mismatched against the middleware.
|
||||||
|
self._state_attr = f"_ft_db_session_{id(self):x}"
|
||||||
|
self._middleware_installed = False
|
||||||
|
self._disposed = False
|
||||||
|
|
||||||
|
async def _dispose(self) -> None:
|
||||||
|
"""Dispose the engine once, only if we own it (idempotent)."""
|
||||||
|
if self._owns_engine and not self._disposed:
|
||||||
|
self._disposed = True
|
||||||
|
await self.engine.dispose()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(self, app: Any) -> AsyncGenerator[None, None]:
|
||||||
|
"""Dispose the engine on shutdown; use as ``FastAPI(lifespan=db.lifespan)``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: The ASGI application (unused; required by the lifespan protocol).
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
Control to the application for its lifetime.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
app = FastAPI(lifespan=db.lifespan)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await self._dispose()
|
||||||
|
|
||||||
|
def install(self, app: Any) -> None:
|
||||||
|
"""Wire the commit middleware and engine disposal onto *app*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app: The FastAPI/Starlette application to wire.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app):
|
||||||
|
... # your startup
|
||||||
|
yield
|
||||||
|
... # your shutdown
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
db.install(app)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
app.add_middleware(_CommitOnResponseMiddleware, state_attr=self._state_attr)
|
||||||
|
self._middleware_installed = True
|
||||||
|
|
||||||
|
inner_lifespan = app.router.lifespan_context
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _composed(app_: Any) -> AsyncGenerator[None, None]:
|
||||||
|
async with self.lifespan(app_):
|
||||||
|
async with inner_lifespan(app_):
|
||||||
|
yield
|
||||||
|
|
||||||
|
app.router.lifespan_context = _composed
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _open(self) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Open a session and eagerly acquire a connection (fail-fast on pool)."""
|
||||||
|
async with self._sessionmaker() as session:
|
||||||
|
try:
|
||||||
|
await session.connection()
|
||||||
|
except sa_exc.TimeoutError as e:
|
||||||
|
raise PoolExhaustedError() from e
|
||||||
|
yield session
|
||||||
|
|
||||||
|
async def __call__(self, request: Request) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""FastAPI dependency: yield a session and commit once at the right time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The incoming request (injected by FastAPI).
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
An AsyncSession for the duration of the request.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@app.get("/users/{user_id}")
|
||||||
|
async def get_user(user_id: int, session=Depends(db)):
|
||||||
|
return await UserCrud.get(session, [User.id == user_id])
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
async with self._open() as session:
|
||||||
|
setattr(request.state, self._state_attr, session)
|
||||||
|
yield session
|
||||||
|
if not self._middleware_installed and session.in_transaction():
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def session(self) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Open a session outside request handlers (background tasks, CLI, tests).
|
||||||
|
|
||||||
|
Commits on clean exit, rolls back on exception.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
An AsyncSession ready for database operations.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
async with db.session() as session:
|
||||||
|
user = await UserCrud.get(session, [User.id == 1])
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
async with self._open() as session:
|
||||||
|
yield session
|
||||||
|
if session.in_transaction():
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def begin(self) -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""Open a session already inside a transaction (sugar for the common case).
|
||||||
|
|
||||||
|
Equivalent to ``session()`` + :func:`transaction`. Commits on clean exit,
|
||||||
|
rolls back on exception.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
An AsyncSession open within a transaction.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
async with db.begin() as session:
|
||||||
|
session.add(User(name="ada"))
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
async with self.session() as session, transaction(session):
|
||||||
|
yield session
|
||||||
|
|
||||||
|
def lock_tables(
|
||||||
|
self,
|
||||||
|
tables: list[type[DeclarativeBase]],
|
||||||
|
*,
|
||||||
|
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
|
||||||
|
timeout: str = "5s",
|
||||||
|
) -> AbstractAsyncContextManager[AsyncSession]:
|
||||||
|
"""Lock PostgreSQL tables for the duration of a dedicated transaction.
|
||||||
|
|
||||||
|
Opens its own session from the facade's sessionmaker, changes are
|
||||||
|
committed when the context exits.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tables: List of SQLAlchemy model classes to lock.
|
||||||
|
mode: Lock mode (default: ``SHARE UPDATE EXCLUSIVE``).
|
||||||
|
timeout: Lock timeout (default: ``"5s"``).
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
The dedicated session, open within the locked transaction.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
LockTimeoutError: If the lock cannot be acquired within *timeout*.
|
||||||
|
PoolExhaustedError: If the connection pool is exhausted.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
async with db.lock_tables([User, Account]) as session:
|
||||||
|
user = await UserCrud.get(session, [User.id == 1])
|
||||||
|
user.balance += 100
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
return lock_tables(self._sessionmaker, tables, mode=mode, timeout=timeout)
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""PostgreSQL locking helpers: table locks and advisory locks."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||||
|
from enum import Enum
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
from sqlalchemy import exc as sa_exc
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from ..exceptions import LockTimeoutError, PoolExhaustedError
|
||||||
|
|
||||||
|
_SessionT = TypeVar("_SessionT", bound=AsyncSession)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_lock_not_available(e: sa_exc.DBAPIError) -> bool:
|
||||||
|
return e.orig is not None and isinstance(
|
||||||
|
e.orig.__cause__, asyncpg.exceptions.LockNotAvailableError
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class LockMode(str, Enum):
|
||||||
|
"""PostgreSQL table lock modes.
|
||||||
|
|
||||||
|
See: https://www.postgresql.org/docs/current/explicit-locking.html
|
||||||
|
"""
|
||||||
|
|
||||||
|
ACCESS_SHARE = "ACCESS SHARE"
|
||||||
|
ROW_SHARE = "ROW SHARE"
|
||||||
|
ROW_EXCLUSIVE = "ROW EXCLUSIVE"
|
||||||
|
SHARE_UPDATE_EXCLUSIVE = "SHARE UPDATE EXCLUSIVE"
|
||||||
|
SHARE = "SHARE"
|
||||||
|
SHARE_ROW_EXCLUSIVE = "SHARE ROW EXCLUSIVE"
|
||||||
|
EXCLUSIVE = "EXCLUSIVE"
|
||||||
|
ACCESS_EXCLUSIVE = "ACCESS EXCLUSIVE"
|
||||||
|
|
||||||
|
|
||||||
|
def lock_tables(
|
||||||
|
session_maker: async_sessionmaker[_SessionT],
|
||||||
|
tables: list[type[DeclarativeBase]],
|
||||||
|
*,
|
||||||
|
mode: LockMode = LockMode.SHARE_UPDATE_EXCLUSIVE,
|
||||||
|
timeout: str = "5s",
|
||||||
|
) -> AbstractAsyncContextManager[_SessionT]:
|
||||||
|
"""Lock PostgreSQL tables for the duration of a transaction.
|
||||||
|
|
||||||
|
Prefer the method on a :class:`Database` instance; use this
|
||||||
|
directly only when you manage your own session factory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_maker: Async session factory used to create the dedicated
|
||||||
|
session.
|
||||||
|
tables: List of SQLAlchemy model classes to lock.
|
||||||
|
mode: Lock mode (default: SHARE UPDATE EXCLUSIVE).
|
||||||
|
timeout: Lock timeout (default: "5s").
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
The dedicated session, open within the locked transaction.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
LockTimeoutError: If the lock cannot be acquired within *timeout*.
|
||||||
|
PoolExhaustedError: If the connection pool is exhausted.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import lock_tables
|
||||||
|
|
||||||
|
async with lock_tables(session_maker, [User, Account]) as session:
|
||||||
|
user = await UserCrud.get(session, [User.id == 1])
|
||||||
|
user.balance += 100
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
table_names = ",".join(table.__tablename__ for table in tables)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _lock() -> AsyncGenerator[_SessionT, None]:
|
||||||
|
async with session_maker() as session:
|
||||||
|
try:
|
||||||
|
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
||||||
|
await session.execute(text(f"LOCK {table_names} IN {mode.value} MODE"))
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except sa_exc.TimeoutError as e:
|
||||||
|
await session.rollback()
|
||||||
|
raise PoolExhaustedError(
|
||||||
|
f"Connection pool exhausted while locking '{table_names}'. "
|
||||||
|
) from e
|
||||||
|
except sa_exc.DBAPIError as e:
|
||||||
|
await session.rollback()
|
||||||
|
if _is_lock_not_available(e):
|
||||||
|
raise LockTimeoutError(
|
||||||
|
f"Lock on '{table_names}' could not be acquired within {timeout}."
|
||||||
|
) from e
|
||||||
|
raise # pragma: no cover
|
||||||
|
except BaseException:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
return _lock()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def advisory_lock(
|
||||||
|
session: AsyncSession,
|
||||||
|
key: int | tuple[int, int],
|
||||||
|
*,
|
||||||
|
shared: bool = False,
|
||||||
|
nowait: bool = False,
|
||||||
|
timeout: str | None = None,
|
||||||
|
) -> AsyncGenerator[bool, None]:
|
||||||
|
"""Acquire a PostgreSQL session-level advisory lock.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: AsyncSession instance.
|
||||||
|
key: Lock key, either a single ``int`` (bigint) or a ``(int, int)`` pair for namespacing.
|
||||||
|
shared: Acquire a shared lock (multiple holders allowed). Default is exclusive.
|
||||||
|
nowait: Return ``False`` immediately if the lock is unavailable instead of waiting.
|
||||||
|
timeout: Maximum wait time (e.g. ``"5s"``, ``"500ms"``). Raises ``DBAPIError``
|
||||||
|
if exceeded. Ignored when *nowait* is ``True``.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
``True`` if the lock was acquired, ``False`` if *nowait* is ``True`` and the lock
|
||||||
|
is already held.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
LockTimeoutError: If *timeout* is set and the lock cannot be acquired in time.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import advisory_lock
|
||||||
|
|
||||||
|
async with advisory_lock(session, 42):
|
||||||
|
...
|
||||||
|
|
||||||
|
async with advisory_lock(session, 42, nowait=True) as acquired:
|
||||||
|
if not acquired:
|
||||||
|
raise HTTPException(409, "Resource is locked")
|
||||||
|
|
||||||
|
async with advisory_lock(session, 42, timeout="5s"):
|
||||||
|
...
|
||||||
|
|
||||||
|
async with advisory_lock(session, (1, user_id), shared=True):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
suffix = "_shared" if shared else ""
|
||||||
|
acquire_fn = f"{'pg_try_advisory_lock' if nowait else 'pg_advisory_lock'}{suffix}"
|
||||||
|
release_fn = f"pg_advisory_unlock{suffix}"
|
||||||
|
|
||||||
|
if isinstance(key, tuple):
|
||||||
|
k1, k2 = key
|
||||||
|
args = "CAST(:k1 AS integer), CAST(:k2 AS integer)"
|
||||||
|
params: dict[str, int] = {"k1": k1, "k2": k2}
|
||||||
|
else:
|
||||||
|
args = ":k"
|
||||||
|
params = {"k": key}
|
||||||
|
|
||||||
|
acquire_sql = text(f"SELECT {acquire_fn}({args})")
|
||||||
|
release_sql = text(f"SELECT {release_fn}({args})")
|
||||||
|
|
||||||
|
# Lock management runs raw SQL on the caller's session. Guard it with
|
||||||
|
# ``no_autoflush`` so acquiring or releasing the lock never flushes the
|
||||||
|
# caller's pending ORM changes; SQLAlchemy 2.1 autoflushes on raw
|
||||||
|
# ``text()`` too, where 2.0 did not.
|
||||||
|
try:
|
||||||
|
with session.no_autoflush:
|
||||||
|
if timeout is not None and not nowait:
|
||||||
|
await session.execute(text(f"SET LOCAL lock_timeout='{timeout}'"))
|
||||||
|
result = await session.execute(acquire_sql, params)
|
||||||
|
except sa_exc.DBAPIError as e:
|
||||||
|
if _is_lock_not_available(e):
|
||||||
|
raise LockTimeoutError(
|
||||||
|
f"Advisory lock {key!r} could not be acquired within {timeout}."
|
||||||
|
) from e
|
||||||
|
raise # pragma: no cover
|
||||||
|
acquired = result.scalar() if nowait else True
|
||||||
|
try:
|
||||||
|
yield acquired
|
||||||
|
finally:
|
||||||
|
if acquired:
|
||||||
|
with session.no_autoflush:
|
||||||
|
await session.execute(release_sql, params)
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Many-to-Many association-table helpers (direct, without loading collections)."""
|
||||||
|
|
||||||
|
from typing import Any, TypeVar, cast
|
||||||
|
|
||||||
|
from sqlalchemy import ColumnElement, Table, delete, tuple_
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
|
||||||
|
from sqlalchemy.orm.relationships import RelationshipProperty
|
||||||
|
|
||||||
|
_M = TypeVar("_M", bound=DeclarativeBase)
|
||||||
|
|
||||||
|
|
||||||
|
def _m2m_prop(rel_attr: QueryableAttribute) -> tuple[RelationshipProperty, Table]: # type: ignore[type-arg]
|
||||||
|
"""Return the validated M2M RelationshipProperty and its secondary table.
|
||||||
|
|
||||||
|
Raises TypeError if *rel_attr* is not a Many-to-Many relationship.
|
||||||
|
"""
|
||||||
|
prop = rel_attr.property
|
||||||
|
if not isinstance(prop, RelationshipProperty) or prop.secondary is None:
|
||||||
|
raise TypeError(
|
||||||
|
f"m2m helpers require a Many-to-Many relationship attribute, "
|
||||||
|
f"got {rel_attr!r}. Use a relationship with a secondary table."
|
||||||
|
)
|
||||||
|
return prop, cast(Table, prop.secondary)
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_where(
|
||||||
|
prop: RelationshipProperty, # type: ignore[type-arg]
|
||||||
|
instance: DeclarativeBase,
|
||||||
|
) -> list[ColumnElement[bool]]:
|
||||||
|
"""Build the WHERE clauses matching the owner side of *instance*."""
|
||||||
|
return [
|
||||||
|
assoc_col == getattr(instance, cast(str, parent_col.key))
|
||||||
|
for parent_col, assoc_col in prop.synchronize_pairs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def m2m_add(
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: DeclarativeBase,
|
||||||
|
rel_attr: QueryableAttribute,
|
||||||
|
*related: DeclarativeBase,
|
||||||
|
ignore_conflicts: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Insert rows into a Many-to-Many association table without loading the ORM collection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DB async session.
|
||||||
|
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
||||||
|
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
||||||
|
*related: One or more related instances to associate with ``instance``.
|
||||||
|
ignore_conflicts: When ``True``, silently skip rows that already exist
|
||||||
|
in the association table (``ON CONFLICT DO NOTHING``).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import m2m_add, transaction
|
||||||
|
|
||||||
|
async with transaction(session):
|
||||||
|
await m2m_add(session, post, Post.tags, tag1, tag2)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
prop, secondary = _m2m_prop(rel_attr)
|
||||||
|
if not related:
|
||||||
|
return
|
||||||
|
|
||||||
|
sync_pairs = prop.secondary_synchronize_pairs
|
||||||
|
assert sync_pairs is not None # set whenever secondary is set
|
||||||
|
|
||||||
|
# synchronize_pairs: [(parent_col, assoc_col), ...]
|
||||||
|
# secondary_synchronize_pairs: [(related_col, assoc_col), ...]
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for rel_instance in related:
|
||||||
|
row: dict[str, Any] = {}
|
||||||
|
for parent_col, assoc_col in prop.synchronize_pairs:
|
||||||
|
row[assoc_col.name] = getattr(instance, cast(str, parent_col.key))
|
||||||
|
for related_col, assoc_col in sync_pairs:
|
||||||
|
row[assoc_col.name] = getattr(rel_instance, cast(str, related_col.key))
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
stmt = pg_insert(secondary).values(rows)
|
||||||
|
if ignore_conflicts:
|
||||||
|
stmt = stmt.on_conflict_do_nothing()
|
||||||
|
await session.execute(stmt)
|
||||||
|
|
||||||
|
|
||||||
|
async def m2m_remove(
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: DeclarativeBase,
|
||||||
|
rel_attr: QueryableAttribute,
|
||||||
|
*related: DeclarativeBase,
|
||||||
|
) -> None:
|
||||||
|
"""Remove rows from a Many-to-Many association table without loading the ORM collection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DB async session.
|
||||||
|
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
||||||
|
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
||||||
|
*related: One or more related instances to disassociate from ``instance``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import m2m_remove, transaction
|
||||||
|
|
||||||
|
async with transaction(session):
|
||||||
|
await m2m_remove(session, post, Post.tags, tag1)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
prop, secondary = _m2m_prop(rel_attr)
|
||||||
|
if not related:
|
||||||
|
return
|
||||||
|
|
||||||
|
related_pairs = prop.secondary_synchronize_pairs
|
||||||
|
assert related_pairs is not None # set whenever secondary is set
|
||||||
|
|
||||||
|
parent_where = _parent_where(prop, instance)
|
||||||
|
|
||||||
|
if len(related_pairs) == 1:
|
||||||
|
related_col, assoc_col = related_pairs[0]
|
||||||
|
related_values = [getattr(r, cast(str, related_col.key)) for r in related]
|
||||||
|
related_where = assoc_col.in_(related_values)
|
||||||
|
else:
|
||||||
|
assoc_cols = [ac for _, ac in related_pairs]
|
||||||
|
rel_cols = [rc for rc, _ in related_pairs]
|
||||||
|
related_values_t = [
|
||||||
|
tuple(getattr(r, cast(str, rc.key)) for rc in rel_cols) for r in related
|
||||||
|
]
|
||||||
|
related_where = tuple_(*assoc_cols).in_(related_values_t)
|
||||||
|
|
||||||
|
await session.execute(delete(secondary).where(*parent_where, related_where))
|
||||||
|
|
||||||
|
|
||||||
|
async def m2m_set(
|
||||||
|
session: AsyncSession,
|
||||||
|
instance: DeclarativeBase,
|
||||||
|
rel_attr: QueryableAttribute,
|
||||||
|
*related: DeclarativeBase,
|
||||||
|
) -> None:
|
||||||
|
"""Replace the entire Many-to-Many association set atomically.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DB async session.
|
||||||
|
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
||||||
|
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
||||||
|
*related: The new complete set of related instances.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import m2m_set, transaction
|
||||||
|
|
||||||
|
async with transaction(session):
|
||||||
|
await m2m_set(session, post, Post.tags, tag1, tag2) # replaces all
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
prop, secondary = _m2m_prop(rel_attr)
|
||||||
|
|
||||||
|
await session.execute(delete(secondary).where(*_parent_where(prop, instance)))
|
||||||
|
|
||||||
|
if related:
|
||||||
|
await m2m_add(session, instance, rel_attr, *related)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Database admin and test helpers: DDL and truncation."""
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
|
||||||
|
async def create_database(
|
||||||
|
db_name: str,
|
||||||
|
*,
|
||||||
|
server_url: str,
|
||||||
|
) -> None:
|
||||||
|
"""Create a database.
|
||||||
|
|
||||||
|
Connects to *server_url* using ``AUTOCOMMIT`` isolation and issues a
|
||||||
|
``CREATE DATABASE`` statement for *db_name*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_name: Name of the database to create.
|
||||||
|
server_url: URL used for server-level DDL (must point to an existing
|
||||||
|
database on the same server).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db.testing import create_database
|
||||||
|
|
||||||
|
SERVER_URL = "postgresql+asyncpg://postgres:postgres@localhost/postgres"
|
||||||
|
await create_database("myapp_test", server_url=SERVER_URL)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
engine = create_async_engine(server_url, isolation_level="AUTOCOMMIT")
|
||||||
|
try:
|
||||||
|
async with engine.connect() as conn:
|
||||||
|
await conn.execute(text(f"CREATE DATABASE {db_name}"))
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_tables(
|
||||||
|
session: AsyncSession,
|
||||||
|
base: type[DeclarativeBase],
|
||||||
|
) -> None:
|
||||||
|
"""Truncate all tables for fast between-test cleanup.
|
||||||
|
|
||||||
|
Executes a single ``TRUNCATE … RESTART IDENTITY CASCADE`` statement
|
||||||
|
across every table in *base*'s metadata.
|
||||||
|
|
||||||
|
This is a no-op when the metadata contains no tables.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: An active async database session.
|
||||||
|
base: SQLAlchemy DeclarativeBase class containing model metadata.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
@pytest.fixture
|
||||||
|
async def db_session(worker_db_url):
|
||||||
|
async with create_db_session(worker_db_url, Base) as session:
|
||||||
|
yield session
|
||||||
|
await cleanup_tables(session, Base)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
tables = base.metadata.sorted_tables
|
||||||
|
if not tables:
|
||||||
|
return
|
||||||
|
|
||||||
|
table_names = ", ".join(f'"{t.name}"' for t in tables)
|
||||||
|
await session.execute(text(f"TRUNCATE {table_names} RESTART IDENTITY CASCADE"))
|
||||||
|
await session.commit()
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Row-watching helpers: poll a database row until it changes."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from ..exceptions import NotFoundError
|
||||||
|
|
||||||
|
_M = TypeVar("_M", bound=DeclarativeBase)
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_row_change(
|
||||||
|
session: AsyncSession,
|
||||||
|
model: type[_M],
|
||||||
|
pk_value: Any,
|
||||||
|
*,
|
||||||
|
columns: list[str] | None = None,
|
||||||
|
interval: float = 0.5,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> _M:
|
||||||
|
"""Poll a database row until a change is detected.
|
||||||
|
|
||||||
|
Queries the row every ``interval`` seconds and returns the model instance
|
||||||
|
once a change is detected in any column (or only the specified ``columns``).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: AsyncSession instance.
|
||||||
|
model: SQLAlchemy model class.
|
||||||
|
pk_value: Primary key value of the row to watch.
|
||||||
|
columns: Optional list of column names to watch. If None, all columns
|
||||||
|
are watched.
|
||||||
|
interval: Polling interval in seconds (default: 0.5).
|
||||||
|
timeout: Maximum time to wait in seconds. None means wait forever.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The refreshed model instance with updated values.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotFoundError: If the row does not exist or is deleted during polling.
|
||||||
|
TimeoutError: If timeout expires before a change is detected.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import wait_for_row_change
|
||||||
|
|
||||||
|
# Wait for any column to change
|
||||||
|
updated = await wait_for_row_change(session, User, user_id)
|
||||||
|
|
||||||
|
# Watch specific columns with a timeout
|
||||||
|
updated = await wait_for_row_change(
|
||||||
|
session, User, user_id,
|
||||||
|
columns=["status", "email"],
|
||||||
|
interval=1.0,
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
bind = getattr(session, "bind", None)
|
||||||
|
if bind is None:
|
||||||
|
raise TypeError(
|
||||||
|
"wait_for_row_change requires a session bound to an engine "
|
||||||
|
"(session.bind is None)"
|
||||||
|
)
|
||||||
|
watcher = AsyncSession(bind=bind)
|
||||||
|
try:
|
||||||
|
|
||||||
|
async def _reload() -> _M | None:
|
||||||
|
await watcher.rollback()
|
||||||
|
return await watcher.get(model, pk_value, populate_existing=True)
|
||||||
|
|
||||||
|
instance = await _reload()
|
||||||
|
if instance is None:
|
||||||
|
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
|
||||||
|
|
||||||
|
if columns is not None:
|
||||||
|
watch_cols = columns
|
||||||
|
else:
|
||||||
|
watch_cols = [attr.key for attr in model.__mapper__.column_attrs]
|
||||||
|
|
||||||
|
initial = {col: getattr(instance, col) for col in watch_cols}
|
||||||
|
|
||||||
|
elapsed = 0.0
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
elapsed += interval
|
||||||
|
|
||||||
|
if timeout is not None and elapsed >= timeout:
|
||||||
|
raise TimeoutError(
|
||||||
|
f"No change detected on {model.__name__} "
|
||||||
|
f"with pk={pk_value!r} within {timeout}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
instance = await _reload()
|
||||||
|
|
||||||
|
if instance is None:
|
||||||
|
raise NotFoundError(
|
||||||
|
f"{model.__name__} with pk={pk_value!r} was deleted"
|
||||||
|
)
|
||||||
|
|
||||||
|
current = {col: getattr(instance, col) for col in watch_cols}
|
||||||
|
if current != initial:
|
||||||
|
return instance
|
||||||
|
finally:
|
||||||
|
await watcher.close()
|
||||||
@@ -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 get_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
|
||||||
@@ -229,7 +314,7 @@ async def _load_ordered(
|
|||||||
model_name = type(instances[0]).__name__
|
model_name = type(instances[0]).__name__
|
||||||
loaded: list[DeclarativeBase] = []
|
loaded: list[DeclarativeBase] = []
|
||||||
|
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
for model_cls, group in _group_by_type(instances):
|
for model_cls, group in _group_by_type(instances):
|
||||||
match strategy:
|
match strategy:
|
||||||
case LoadStrategy.INSERT:
|
case LoadStrategy.INSERT:
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -204,6 +204,11 @@ async def _invoke_callback(
|
|||||||
await result
|
await result
|
||||||
|
|
||||||
|
|
||||||
|
async def _reload_if_present(session: AsyncSession, obj: Any, state: Any) -> None:
|
||||||
|
"""Re-populate *obj* from the DB if its row still exists."""
|
||||||
|
await session.get(type(obj), state.key[1], populate_existing=True)
|
||||||
|
|
||||||
|
|
||||||
class EventSession(AsyncSession):
|
class EventSession(AsyncSession):
|
||||||
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
|
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
|
||||||
|
|
||||||
@@ -253,7 +258,7 @@ class EventSession(AsyncSession):
|
|||||||
state is None or state.detached or state.transient
|
state is None or state.detached or state.transient
|
||||||
): # pragma: no cover
|
): # pragma: no cover
|
||||||
continue
|
continue
|
||||||
await self.refresh(obj)
|
await _reload_if_present(self, obj, state)
|
||||||
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:
|
||||||
@@ -277,7 +282,7 @@ class EventSession(AsyncSession):
|
|||||||
state is None or state.detached or state.transient
|
state is None or state.detached or state.transient
|
||||||
): # pragma: no cover
|
): # pragma: no cover
|
||||||
continue
|
continue
|
||||||
await self.refresh(obj)
|
await _reload_if_present(self, obj, state)
|
||||||
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 get_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 get_transaction(session):
|
|
||||||
for instance in instances:
|
|
||||||
if strategy == LoadStrategy.INSERT:
|
|
||||||
session.add(instance)
|
|
||||||
loaded.append(instance)
|
|
||||||
elif strategy == LoadStrategy.MERGE:
|
|
||||||
merged = await session.merge(instance)
|
|
||||||
loaded.append(merged)
|
|
||||||
elif strategy == LoadStrategy.SKIP_EXISTING: # pragma: no branch
|
|
||||||
pk = _get_primary_key(instance)
|
|
||||||
if pk is not None:
|
|
||||||
existing = await session.get(type(instance), pk)
|
|
||||||
if existing is None:
|
|
||||||
session.add(instance)
|
|
||||||
loaded.append(instance)
|
|
||||||
else:
|
|
||||||
loaded.append(existing)
|
|
||||||
else:
|
|
||||||
session.add(instance)
|
|
||||||
loaded.append(instance)
|
|
||||||
|
|
||||||
if loaded: # pragma: no branch
|
|
||||||
load_options = _relationship_load_options(type(loaded[0]))
|
|
||||||
if load_options:
|
|
||||||
return await _reload_with_relationships(session, loaded, load_options)
|
|
||||||
|
|
||||||
return loaded
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
from ..db import cleanup_tables, create_database
|
from ..db.testing import cleanup_tables, create_database
|
||||||
from ..models.watched import EventSession
|
from ..models.watched import EventSession
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+743
-169
File diff suppressed because it is too large
Load Diff
@@ -91,13 +91,19 @@ async def seed(session: AsyncSession):
|
|||||||
class TestAppSessionDep:
|
class TestAppSessionDep:
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_get_db_yields_async_session(self):
|
async def test_get_db_yields_async_session(self):
|
||||||
"""get_db yields a real AsyncSession when called directly."""
|
"""The Database dependency yields a real AsyncSession when called directly."""
|
||||||
from docs_src.examples.pagination_search.db import get_db
|
from starlette.requests import Request
|
||||||
|
|
||||||
gen = get_db()
|
from fastapi_toolsets.db import Database
|
||||||
|
|
||||||
|
db = Database(DATABASE_URL)
|
||||||
|
try:
|
||||||
|
gen = db(Request({"type": "http", "headers": []}))
|
||||||
session = await gen.__anext__()
|
session = await gen.__anext__()
|
||||||
assert isinstance(session, AsyncSession)
|
assert isinstance(session, AsyncSession)
|
||||||
await gen.aclose()
|
await gen.aclose()
|
||||||
|
finally:
|
||||||
|
await db.engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
class TestOffsetPagination:
|
class TestOffsetPagination:
|
||||||
|
|||||||
+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:
|
||||||
|
|||||||
+80
-43
@@ -21,12 +21,12 @@ 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,
|
_WATCHED_MODELS,
|
||||||
|
EventSession,
|
||||||
_after_flush,
|
_after_flush,
|
||||||
_after_rollback,
|
_after_rollback,
|
||||||
_get_watched_fields,
|
_get_watched_fields,
|
||||||
@@ -1001,6 +1001,57 @@ 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_get = mixin_session.get
|
||||||
|
real_refresh = mixin_session.refresh
|
||||||
|
|
||||||
|
def _matches_doomed(pk):
|
||||||
|
return pk == doomed_id or (isinstance(pk, tuple) and pk[0] == doomed_id)
|
||||||
|
|
||||||
|
async def racing_get(model, pk, *args, **kwargs):
|
||||||
|
if _matches_doomed(pk):
|
||||||
|
await kill_doomed_row_once()
|
||||||
|
return await real_get(model, pk, *args, **kwargs)
|
||||||
|
|
||||||
|
async def racing_refresh(obj, *args, **kwargs):
|
||||||
|
if getattr(obj, "id", None) == doomed_id:
|
||||||
|
await kill_doomed_row_once()
|
||||||
|
return await real_refresh(obj, *args, **kwargs)
|
||||||
|
|
||||||
|
# Patch both possible reload mechanisms (session.get / session.refresh)
|
||||||
|
# so this test still exercises the race regardless of which one
|
||||||
|
# EventSession.commit() uses internally to pick up server defaults.
|
||||||
|
mixin_session.get = racing_get
|
||||||
|
mixin_session.refresh = racing_refresh
|
||||||
|
with 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}
|
||||||
|
|
||||||
|
|
||||||
class TestTransientObject:
|
class TestTransientObject:
|
||||||
"""Create + delete within the same transaction should fire no events."""
|
"""Create + delete within the same transaction should fire no events."""
|
||||||
@@ -1506,8 +1557,8 @@ class TestListensFor:
|
|||||||
assert all(e["event"] == "change" for e in _listener_events)
|
assert all(e["event"] == "change" for e in _listener_events)
|
||||||
|
|
||||||
|
|
||||||
class TestEventSessionWithGetTransaction:
|
class TestEventSessionWithTransaction:
|
||||||
"""Verify callbacks fire correctly when using get_transaction / lock_tables."""
|
"""Verify callbacks fire correctly when using transaction / lock_tables."""
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clear_events(self):
|
def clear_events(self):
|
||||||
@@ -1517,10 +1568,10 @@ class TestEventSessionWithGetTransaction:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_callbacks_fire_after_outer_commit_not_savepoint(self, mixin_session):
|
async def test_callbacks_fire_after_outer_commit_not_savepoint(self, mixin_session):
|
||||||
"""get_transaction creates a savepoint; callbacks fire only on outer commit."""
|
"""transaction creates a savepoint; callbacks fire only on outer commit."""
|
||||||
from fastapi_toolsets.db import get_transaction
|
from fastapi_toolsets.db import transaction
|
||||||
|
|
||||||
async with get_transaction(mixin_session):
|
async with transaction(mixin_session):
|
||||||
obj = WatchedModel(status="active", other="x")
|
obj = WatchedModel(status="active", other="x")
|
||||||
mixin_session.add(obj)
|
mixin_session.add(obj)
|
||||||
|
|
||||||
@@ -1535,14 +1586,14 @@ class TestEventSessionWithGetTransaction:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_nested_transactions_accumulate_events(self, mixin_session):
|
async def test_nested_transactions_accumulate_events(self, mixin_session):
|
||||||
"""Multiple get_transaction blocks accumulate events for a single commit."""
|
"""Multiple transaction blocks accumulate events for a single commit."""
|
||||||
from fastapi_toolsets.db import get_transaction
|
from fastapi_toolsets.db import transaction
|
||||||
|
|
||||||
async with get_transaction(mixin_session):
|
async with transaction(mixin_session):
|
||||||
obj1 = WatchedModel(status="first", other="x")
|
obj1 = WatchedModel(status="first", other="x")
|
||||||
mixin_session.add(obj1)
|
mixin_session.add(obj1)
|
||||||
|
|
||||||
async with get_transaction(mixin_session):
|
async with transaction(mixin_session):
|
||||||
obj2 = WatchedModel(status="second", other="y")
|
obj2 = WatchedModel(status="second", other="y")
|
||||||
mixin_session.add(obj2)
|
mixin_session.add(obj2)
|
||||||
|
|
||||||
@@ -1556,14 +1607,14 @@ class TestEventSessionWithGetTransaction:
|
|||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_savepoint_rollback_suppresses_events(self, mixin_session):
|
async def test_savepoint_rollback_suppresses_events(self, mixin_session):
|
||||||
"""Objects from a rolled-back savepoint don't fire callbacks."""
|
"""Objects from a rolled-back savepoint don't fire callbacks."""
|
||||||
from fastapi_toolsets.db import get_transaction
|
from fastapi_toolsets.db import transaction
|
||||||
|
|
||||||
survivor = WatchedModel(status="kept", other="x")
|
survivor = WatchedModel(status="kept", other="x")
|
||||||
mixin_session.add(survivor)
|
mixin_session.add(survivor)
|
||||||
await mixin_session.flush()
|
await mixin_session.flush()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with get_transaction(mixin_session):
|
async with transaction(mixin_session):
|
||||||
doomed = WatchedModel(status="doomed", other="y")
|
doomed = WatchedModel(status="doomed", other="y")
|
||||||
mixin_session.add(doomed)
|
mixin_session.add(doomed)
|
||||||
await mixin_session.flush()
|
await mixin_session.flush()
|
||||||
@@ -1590,9 +1641,9 @@ class TestEventSessionWithGetTransaction:
|
|||||||
assert len(creates) == 1
|
assert len(creates) == 1
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_update_inside_get_transaction(self, mixin_session):
|
async def test_update_inside_transaction(self, mixin_session):
|
||||||
"""UPDATE events fire with correct changes after get_transaction commit."""
|
"""UPDATE events fire with correct changes after transaction commit."""
|
||||||
from fastapi_toolsets.db import get_transaction
|
from fastapi_toolsets.db import transaction
|
||||||
|
|
||||||
obj = WatchedModel(status="initial", other="x")
|
obj = WatchedModel(status="initial", other="x")
|
||||||
mixin_session.add(obj)
|
mixin_session.add(obj)
|
||||||
@@ -1600,7 +1651,7 @@ class TestEventSessionWithGetTransaction:
|
|||||||
|
|
||||||
_test_events.clear()
|
_test_events.clear()
|
||||||
|
|
||||||
async with get_transaction(mixin_session):
|
async with transaction(mixin_session):
|
||||||
obj.status = "updated"
|
obj.status = "updated"
|
||||||
|
|
||||||
await mixin_session.commit()
|
await mixin_session.commit()
|
||||||
@@ -1696,7 +1747,7 @@ class TestEventSessionWithNullableFields:
|
|||||||
|
|
||||||
|
|
||||||
class TestEventSessionWithFastAPIDependency:
|
class TestEventSessionWithFastAPIDependency:
|
||||||
"""Verify EventSession works when session comes from create_db_dependency."""
|
"""Verify EventSession works when session comes from the Database dependency."""
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clear_events(self):
|
def clear_events(self):
|
||||||
@@ -1706,31 +1757,24 @@ class TestEventSessionWithFastAPIDependency:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_create_event_fires_via_dependency(self):
|
async def test_create_event_fires_via_dependency(self):
|
||||||
"""CREATE callback fires when session is provided by create_db_dependency."""
|
"""CREATE callback fires when session is provided by the Database dependency."""
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
AsyncSession,
|
|
||||||
async_sessionmaker,
|
|
||||||
create_async_engine,
|
|
||||||
)
|
|
||||||
|
|
||||||
from fastapi_toolsets.db import create_db_dependency
|
from fastapi_toolsets.db import Database
|
||||||
from fastapi_toolsets.models import EventSession
|
from fastapi_toolsets.models import EventSession
|
||||||
|
|
||||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||||
session_factory = async_sessionmaker(
|
|
||||||
engine, expire_on_commit=False, class_=EventSession
|
|
||||||
)
|
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(MixinBase.metadata.create_all)
|
await conn.run_sync(MixinBase.metadata.create_all)
|
||||||
|
|
||||||
get_db = create_db_dependency(session_factory)
|
db = Database(engine=engine, session_class=EventSession)
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
@app.post("/watched")
|
@app.post("/watched")
|
||||||
async def create_watched(session: AsyncSession = Depends(get_db)):
|
async def create_watched(session: AsyncSession = Depends(db)):
|
||||||
obj = WatchedModel(status="from-api", other="x")
|
obj = WatchedModel(status="from-api", other="x")
|
||||||
session.add(obj)
|
session.add(obj)
|
||||||
return {"id": str(obj.id)}
|
return {"id": str(obj.id)}
|
||||||
@@ -1753,40 +1797,33 @@ class TestEventSessionWithFastAPIDependency:
|
|||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_update_event_fires_via_dependency(self):
|
async def test_update_event_fires_via_dependency(self):
|
||||||
"""UPDATE callback fires when session is provided by create_db_dependency."""
|
"""UPDATE callback fires when session is provided by the Database dependency."""
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import (
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||||
AsyncSession,
|
|
||||||
async_sessionmaker,
|
|
||||||
create_async_engine,
|
|
||||||
)
|
|
||||||
|
|
||||||
from fastapi_toolsets.db import create_db_dependency
|
from fastapi_toolsets.db import Database
|
||||||
from fastapi_toolsets.models import EventSession
|
from fastapi_toolsets.models import EventSession
|
||||||
|
|
||||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||||
session_factory = async_sessionmaker(
|
|
||||||
engine, expire_on_commit=False, class_=EventSession
|
|
||||||
)
|
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(MixinBase.metadata.create_all)
|
await conn.run_sync(MixinBase.metadata.create_all)
|
||||||
|
|
||||||
get_db = create_db_dependency(session_factory)
|
db = Database(engine=engine, session_class=EventSession)
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
# Pre-seed an object.
|
# Pre-seed an object.
|
||||||
async with session_factory() as seed_session:
|
async with db.session() as seed_session:
|
||||||
obj = WatchedModel(status="initial", other="x")
|
obj = WatchedModel(status="initial", other="x")
|
||||||
seed_session.add(obj)
|
seed_session.add(obj)
|
||||||
await seed_session.commit()
|
await seed_session.flush()
|
||||||
obj_id = obj.id
|
obj_id = obj.id
|
||||||
|
|
||||||
_test_events.clear()
|
_test_events.clear()
|
||||||
|
|
||||||
@app.put("/watched/{item_id}")
|
@app.put("/watched/{item_id}")
|
||||||
async def update_watched(item_id: str, session: AsyncSession = Depends(get_db)):
|
async def update_watched(item_id: str, session: AsyncSession = Depends(db)):
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
stmt = select(WatchedModel).where(WatchedModel.id == item_id)
|
stmt = select(WatchedModel).where(WatchedModel.id == item_id)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from sqlalchemy.engine import make_url
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from fastapi_toolsets.db import get_transaction
|
from fastapi_toolsets.db import transaction
|
||||||
from fastapi_toolsets.fixtures import Context, FixtureRegistry, LoadStrategy
|
from fastapi_toolsets.fixtures import Context, FixtureRegistry, LoadStrategy
|
||||||
from fastapi_toolsets.pytest import (
|
from fastapi_toolsets.pytest import (
|
||||||
create_async_client,
|
create_async_client,
|
||||||
@@ -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,
|
||||||
@@ -387,14 +387,14 @@ class TestCreateDbSession:
|
|||||||
assert session.autoflush is False
|
assert session.autoflush is False
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_get_transaction_commits_visible_to_separate_session(self):
|
async def test_transaction_commits_visible_to_separate_session(self):
|
||||||
"""Data written via get_transaction() is committed and visible to other sessions."""
|
"""Data written via transaction() is committed and visible to other sessions."""
|
||||||
role_id = uuid.uuid4()
|
role_id = uuid.uuid4()
|
||||||
|
|
||||||
async with create_db_session(DATABASE_URL, Base, drop_tables=False) as session:
|
async with create_db_session(DATABASE_URL, Base, drop_tables=False) as session:
|
||||||
# Simulate what _create_fixture_function does: insert via get_transaction
|
# Simulate what _create_fixture_function does: insert via transaction()
|
||||||
# with no explicit commit afterward.
|
# with no explicit commit afterward.
|
||||||
async with get_transaction(session):
|
async with transaction(session):
|
||||||
role = Role(id=role_id, name="visible_to_other_session")
|
role = Role(id=role_id, name="visible_to_other_session")
|
||||||
session.add(role)
|
session.add(role)
|
||||||
|
|
||||||
@@ -409,9 +409,9 @@ class TestCreateDbSession:
|
|||||||
result = await other.execute(select(Role).where(Role.id == role_id))
|
result = await other.execute(select(Role).where(Role.id == role_id))
|
||||||
fetched = result.scalar_one_or_none()
|
fetched = result.scalar_one_or_none()
|
||||||
assert fetched is not None, (
|
assert fetched is not None, (
|
||||||
"Fixture data inserted via get_transaction() must be committed "
|
"Fixture data inserted via transaction() must be committed "
|
||||||
"and visible to a separate session. If create_db_session uses "
|
"and visible to a separate session. If create_db_session uses "
|
||||||
"create_db_context, auto-begin forces get_transaction() into "
|
"db.session(), auto-begin forces transaction() into "
|
||||||
"savepoints instead of real commits."
|
"savepoints instead of real commits."
|
||||||
)
|
)
|
||||||
assert fetched.name == "visible_to_other_session"
|
assert fetched.name == "visible_to_other_session"
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "4.1.3"
|
version = "5.0.0b2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
@@ -786,16 +786,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 +836,26 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prek"
|
name = "prek"
|
||||||
version = "0.4.3"
|
version = "0.4.8"
|
||||||
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/8e/46/e436a6eb9fdb4d3fd08d0ab7fdba19fe03a9e994ec810de57869b853bd8e/prek-0.4.8.tar.gz", hash = "sha256:d15d8bef72ab7b02c7dc01458ac9e05b3131534492b5ce9bb11c4f6f636fa868", size = 494570, upload-time = "2026-07-04T12:05:10.941Z" }
|
||||||
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/5c/78/b4149c8913ced2e42debb49e261c4788a1ce431e84226921c2e1a7ea8545/prek-0.4.8-py3-none-linux_armv6l.whl", hash = "sha256:1f8f8cdc65836b571824c965daebb81b449f7e4a43894c58621f5708d5a185ed", size = 5668955, upload-time = "2026-07-04T12:04:41.588Z" },
|
||||||
{ 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/76/5f/7f54a0087b6b2f1751aeb41266d9c15e66fd0055492814798ab818cd0414/prek-0.4.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bce1798e96d9e3a6e6abf435da7107e81452f69edb3ca7c6f90a457355ea46e2", size = 6030947, upload-time = "2026-07-04T12:04:43.8Z" },
|
||||||
{ 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/6c/d6/f2829fc3902920c36b764a386fa303e71a8219dac25cb3827c575e84199a/prek-0.4.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ab3a52db17254d701c3cebb7eea58c8230aa7c1959aacfd5b5f25de18edb15d1", size = 5572593, upload-time = "2026-07-04T12:04:45.763Z" },
|
||||||
{ 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/74/8c/c5589955bcd5e3e33b67d8bc3110818cecac82a38fd6bc8b5dfdc5de421c/prek-0.4.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:b3fcfd620523bbc3f51a21d7cd63449f659b9e2cf3582de12dd5949e23227b8f", size = 5847150, upload-time = "2026-07-04T12:04:47.419Z" },
|
||||||
{ 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/2d/9d/1f2dc91bdb79d2c4714b27eac9477a51490fba5b4731330dbbebc76bd345/prek-0.4.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42e65bc8425e9d7f1691a13ca1da2e07807d1ba76c35740833354b945131689e", size = 5573738, upload-time = "2026-07-04T12:04:49.125Z" },
|
||||||
{ 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/81/29/69a7b58e16ecbc5f3989bf4b028018d11a82dcdd320b93d6588d72f32aa7/prek-0.4.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f578492a8e0c9bc6b4bf6dfbba8716f647d4cd0769bf10ad6cf336e3096fd392", size = 5981054, upload-time = "2026-07-04T12:04:50.842Z" },
|
||||||
{ 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/63/cc/9b9850a60c22ed18c7755ebd2d72c6eefb37fac58149d09f6adc4691c2cf/prek-0.4.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4335f9d5beb123a3884a7fe34f57c9f0828f4fbb7666beab4298833459b104f", size = 6751350, upload-time = "2026-07-04T12:04:52.529Z" },
|
||||||
{ 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/01/e5/c425aa7272b430630119e6757def3a2007555ba8cbeb2630e0448e7a8b7f/prek-0.4.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18a8747df9c602e052881d3efb14dd7f7d62a59bd7277ae5171c9e7661d59d84", size = 6243881, upload-time = "2026-07-04T12:04:54.703Z" },
|
||||||
{ 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/1c/da/accd3ad07fd2891d3c2777eb42435439fdf11982c51d60f087c0b6b6e102/prek-0.4.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4db639db481d5f854eff9b3d2108889e613b8c15868bcf6bdd777c7cee577436", size = 5848846, upload-time = "2026-07-04T12:04:56.402Z" },
|
||||||
{ 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/15/00/3477704635249f21f5f98ce444cd7690c2aa9dc8d146a045db88ef2cd8c5/prek-0.4.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c3890a6f92316d2cf44eb50584e8d2b23a596dd70487022e61186a71a2ac0900", size = 5713942, upload-time = "2026-07-04T12:04:58.311Z" },
|
||||||
{ 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/fb/e6/3ca4fabaebeadc976d9a92d1d9130674265355ea3b728418bad61583b097/prek-0.4.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:fc7e15c24c591a37c6ffce5b25a021b16c299ac2649f183d812b67d665cd6551", size = 5554725, upload-time = "2026-07-04T12:04:59.96Z" },
|
||||||
{ 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/a5/46/2ab6aaaeff0cedb8955b2e4032071c8712382bdd423bb849718c3720180d/prek-0.4.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:36fe721704ff0c7624c1167639e23a5fe658bfd38c314f487219c9afd1eeb733", size = 5838595, upload-time = "2026-07-04T12:05:01.861Z" },
|
||||||
{ 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/ae/8b/91398f2b6cd1629d5d8ca8c85b08eca500814a374313b0193f4aaf6ab6c4/prek-0.4.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:162e544abc394a8124f3a4ad68efee116bad09440e679dbd1675177335c2a432", size = 6357222, upload-time = "2026-07-04T12:05:03.845Z" },
|
||||||
{ 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/b2/2a/ce5cbfaad36866134a21754640a05ecdba641fcd7ad15aa74cf3443f34f6/prek-0.4.8-py3-none-win32.whl", hash = "sha256:2602e46c8c5da7dfa69f60fcf88c2b57132ac623f49fb08bfb3094298c5f07e3", size = 5354388, upload-time = "2026-07-04T12:05:05.587Z" },
|
||||||
{ 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/df/03/3bc908bc5f7e430315553e47dfa055f19923a3888f9afe4da19f244b5cbf/prek-0.4.8-py3-none-win_amd64.whl", hash = "sha256:7cb22da60bee41b89c4978c0bea7126a3c0ccc003dae6748cf29b53947815edc", size = 5748221, upload-time = "2026-07-04T12:05:07.559Z" },
|
||||||
{ 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/dd/a7/4295e6d5f5028171dfeb115ad38ab76bf3fe0c8df91b70d73c79aa760a94/prek-0.4.8-py3-none-win_arm64.whl", hash = "sha256:da70057f577b15d4bd121bf9dd29ee205fd4b4d75a0cafba062e84d7e8b4378b", size = 5574425, upload-time = "2026-07-04T12:05:09.595Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1165,27 +1165,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]]
|
||||||
@@ -1262,15 +1262,15 @@ asyncio = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "starlette"
|
name = "starlette"
|
||||||
version = "1.0.1"
|
version = "1.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/08/a3/84e821cc54b4ab50ae6dbc6ac3800a651b65ec35f045cc73785380654057/starlette-1.0.1.tar.gz", hash = "sha256:512399c5f1de7fac99c88572212ded9ddeddef2fb32afa82d724000e88b38f4f", size = 2659596, upload-time = "2026-05-21T21:58:58.433Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/e1/b2df4bc09a1e51ff664c1e17018a4274b42e5e9352e4a478ea540512dc88/starlette-1.0.1-py3-none-any.whl", hash = "sha256:7c0e69b2ee1c848bd54669d908500117a3ee13de603a21427e5c6fc1adf98dcd", size = 72802, upload-time = "2026-05-21T21:58:56.551Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1329,27 +1329,27 @@ 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]]
|
||||||
@@ -1426,7 +1426,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 +1438,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" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user