mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-04-16 14:46:24 +02:00
Compare commits
5 Commits
aca3f62a6b
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54d7609ac9 | ||
|
94e7d79d06
|
|||
|
|
9268b576b4 | ||
|
|
863e6ce6e9 | ||
|
|
c7397faea4 |
@@ -118,6 +118,57 @@ async def clean(db_session):
|
|||||||
await cleanup_tables(session=db_session, base=Base)
|
await cleanup_tables(session=db_session, base=Base)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
### `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:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import lock_tables, m2m_add
|
||||||
|
|
||||||
|
async with lock_tables(session, [Tag]):
|
||||||
|
tag = await TagCrud.create(session, TagCreate(name="python"))
|
||||||
|
await m2m_add(session, post, Post.tags, tag)
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass `ignore_conflicts=True` to silently skip associations that already exist:
|
||||||
|
|
||||||
|
```python
|
||||||
|
await m2m_add(session, post, Post.tags, tag, ignore_conflicts=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `m2m_remove` — delete associations
|
||||||
|
|
||||||
|
[`m2m_remove`](../reference/db.md#fastapi_toolsets.db.m2m_remove) deletes specific association rows. Removing a non-existent association is a no-op:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import get_transaction, m2m_remove
|
||||||
|
|
||||||
|
async with get_transaction(session):
|
||||||
|
await m2m_remove(session, post, Post.tags, tag1, tag2)
|
||||||
|
```
|
||||||
|
|
||||||
|
### `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:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import get_transaction, m2m_set
|
||||||
|
|
||||||
|
# Replace all tags
|
||||||
|
async with get_transaction(session):
|
||||||
|
await m2m_set(session, post, Post.tags, tag_a, tag_b)
|
||||||
|
|
||||||
|
# Clear all tags
|
||||||
|
async with get_transaction(session):
|
||||||
|
await m2m_set(session, post, Post.tags)
|
||||||
|
```
|
||||||
|
|
||||||
|
All three helpers raise `TypeError` if the relationship attribute is not a Many-to-Many (i.e. has no secondary table).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
[:material-api: API Reference](../reference/db.md)
|
[:material-api: API Reference](../reference/db.md)
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ from fastapi_toolsets.db import (
|
|||||||
create_db_context,
|
create_db_context,
|
||||||
get_transaction,
|
get_transaction,
|
||||||
lock_tables,
|
lock_tables,
|
||||||
|
m2m_add,
|
||||||
|
m2m_remove,
|
||||||
|
m2m_set,
|
||||||
wait_for_row_change,
|
wait_for_row_change,
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
@@ -32,3 +35,9 @@ from fastapi_toolsets.db import (
|
|||||||
## ::: fastapi_toolsets.db.create_database
|
## ::: fastapi_toolsets.db.create_database
|
||||||
|
|
||||||
## ::: fastapi_toolsets.db.cleanup_tables
|
## ::: fastapi_toolsets.db.cleanup_tables
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.m2m_add
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.m2m_remove
|
||||||
|
|
||||||
|
## ::: fastapi_toolsets.db.m2m_set
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "3.0.3"
|
version = "3.1.0"
|
||||||
description = "Production-ready utilities for FastAPI applications"
|
description = "Production-ready utilities for FastAPI applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ Example usage:
|
|||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "3.0.3"
|
__version__ = "3.1.0"
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import asyncio
|
|||||||
from collections.abc import AsyncGenerator, Callable
|
from collections.abc import AsyncGenerator, Callable
|
||||||
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, TypeVar
|
from typing import Any, TypeVar, cast
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import Table, delete, text, tuple_
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
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
|
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
|
||||||
|
from sqlalchemy.orm.relationships import RelationshipProperty
|
||||||
|
|
||||||
from .exceptions import NotFoundError
|
from .exceptions import NotFoundError
|
||||||
|
|
||||||
@@ -20,6 +22,9 @@ __all__ = [
|
|||||||
"create_db_dependency",
|
"create_db_dependency",
|
||||||
"get_transaction",
|
"get_transaction",
|
||||||
"lock_tables",
|
"lock_tables",
|
||||||
|
"m2m_add",
|
||||||
|
"m2m_remove",
|
||||||
|
"m2m_set",
|
||||||
"wait_for_row_change",
|
"wait_for_row_change",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -339,3 +344,140 @@ async def wait_for_row_change(
|
|||||||
current = {col: getattr(instance, col) for col in watch_cols}
|
current = {col: getattr(instance, col) for col in watch_cols}
|
||||||
if current != initial:
|
if current != initial:
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -2,12 +2,18 @@
|
|||||||
|
|
||||||
from .enum import LoadStrategy
|
from .enum import LoadStrategy
|
||||||
from .registry import Context, FixtureRegistry
|
from .registry import Context, FixtureRegistry
|
||||||
from .utils import get_obj_by_attr, load_fixtures, load_fixtures_by_context
|
from .utils import (
|
||||||
|
get_field_by_attr,
|
||||||
|
get_obj_by_attr,
|
||||||
|
load_fixtures,
|
||||||
|
load_fixtures_by_context,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Context",
|
"Context",
|
||||||
"FixtureRegistry",
|
"FixtureRegistry",
|
||||||
"LoadStrategy",
|
"LoadStrategy",
|
||||||
|
"get_field_by_attr",
|
||||||
"get_obj_by_attr",
|
"get_obj_by_attr",
|
||||||
"load_fixtures",
|
"load_fixtures",
|
||||||
"load_fixtures_by_context",
|
"load_fixtures_by_context",
|
||||||
|
|||||||
@@ -250,6 +250,31 @@ def get_obj_by_attr(
|
|||||||
) from None
|
) 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,
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"""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 Callable, Sequence
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
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
|
from sqlalchemy.orm import DeclarativeBase, selectinload
|
||||||
|
from sqlalchemy.orm.interfaces import ExecutableOption, ORMOption
|
||||||
|
|
||||||
from ..db import get_transaction
|
from ..db import get_transaction
|
||||||
from ..fixtures import FixtureRegistry, LoadStrategy
|
from ..fixtures import FixtureRegistry, LoadStrategy
|
||||||
@@ -112,7 +114,7 @@ def _create_fixture_function(
|
|||||||
elif strategy == LoadStrategy.MERGE:
|
elif strategy == LoadStrategy.MERGE:
|
||||||
merged = await session.merge(instance)
|
merged = await session.merge(instance)
|
||||||
loaded.append(merged)
|
loaded.append(merged)
|
||||||
elif strategy == LoadStrategy.SKIP_EXISTING:
|
elif strategy == LoadStrategy.SKIP_EXISTING: # pragma: no branch
|
||||||
pk = _get_primary_key(instance)
|
pk = _get_primary_key(instance)
|
||||||
if pk is not None:
|
if pk is not None:
|
||||||
existing = await session.get(type(instance), pk)
|
existing = await session.get(type(instance), pk)
|
||||||
@@ -125,6 +127,11 @@ def _create_fixture_function(
|
|||||||
session.add(instance)
|
session.add(instance)
|
||||||
loaded.append(instance)
|
loaded.append(instance)
|
||||||
|
|
||||||
|
if loaded: # pragma: no branch
|
||||||
|
load_options = _relationship_load_options(type(loaded[0]))
|
||||||
|
if load_options:
|
||||||
|
return await _reload_with_relationships(session, loaded, load_options)
|
||||||
|
|
||||||
return loaded
|
return loaded
|
||||||
|
|
||||||
# Update function signature to include dependencies
|
# Update function signature to include dependencies
|
||||||
@@ -141,6 +148,54 @@ def _create_fixture_function(
|
|||||||
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:
|
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
||||||
"""Get the primary key value of a model instance."""
|
"""Get the primary key value of a model instance."""
|
||||||
mapper = instance.__class__.__mapper__
|
mapper = instance.__class__.__mapper__
|
||||||
|
|||||||
454
tests/test_db.py
454
tests/test_db.py
@@ -4,10 +4,26 @@ import asyncio
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import text
|
from sqlalchemy import (
|
||||||
|
Column,
|
||||||
|
ForeignKey,
|
||||||
|
ForeignKeyConstraint,
|
||||||
|
String,
|
||||||
|
Table,
|
||||||
|
Uuid,
|
||||||
|
select,
|
||||||
|
text,
|
||||||
|
)
|
||||||
from sqlalchemy.engine import make_url
|
from sqlalchemy.engine import make_url
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
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
|
from sqlalchemy.orm import (
|
||||||
|
DeclarativeBase,
|
||||||
|
Mapped,
|
||||||
|
mapped_column,
|
||||||
|
relationship,
|
||||||
|
selectinload,
|
||||||
|
)
|
||||||
|
|
||||||
from fastapi_toolsets.db import (
|
from fastapi_toolsets.db import (
|
||||||
LockMode,
|
LockMode,
|
||||||
@@ -17,12 +33,15 @@ from fastapi_toolsets.db import (
|
|||||||
create_db_dependency,
|
create_db_dependency,
|
||||||
get_transaction,
|
get_transaction,
|
||||||
lock_tables,
|
lock_tables,
|
||||||
|
m2m_add,
|
||||||
|
m2m_remove,
|
||||||
|
m2m_set,
|
||||||
wait_for_row_change,
|
wait_for_row_change,
|
||||||
)
|
)
|
||||||
from fastapi_toolsets.exceptions import NotFoundError
|
from fastapi_toolsets.exceptions import NotFoundError
|
||||||
from fastapi_toolsets.pytest import create_db_session
|
from fastapi_toolsets.pytest import create_db_session
|
||||||
|
|
||||||
from .conftest import DATABASE_URL, Base, Role, RoleCrud, User, UserCrud
|
from .conftest import DATABASE_URL, Base, Post, Role, RoleCrud, Tag, User, UserCrud
|
||||||
|
|
||||||
|
|
||||||
class TestCreateDbDependency:
|
class TestCreateDbDependency:
|
||||||
@@ -81,6 +100,21 @@ class TestCreateDbDependency:
|
|||||||
|
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_no_commit_when_not_in_transaction(self):
|
||||||
|
"""Dependency skips commit if the session is no longer in a transaction on exit."""
|
||||||
|
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
get_db = create_db_dependency(session_factory)
|
||||||
|
|
||||||
|
async for session in get_db():
|
||||||
|
# Manually commit — session exits the transaction
|
||||||
|
await session.commit()
|
||||||
|
assert not session.in_transaction()
|
||||||
|
# The dependency's post-yield path must not call commit again (no error)
|
||||||
|
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_update_after_lock_tables_is_persisted(self):
|
async def test_update_after_lock_tables_is_persisted(self):
|
||||||
"""Changes made after lock_tables exits (before endpoint returns) are committed.
|
"""Changes made after lock_tables exits (before endpoint returns) are committed.
|
||||||
@@ -480,3 +514,417 @@ class TestCleanupTables:
|
|||||||
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
async with create_db_session(DATABASE_URL, Base, drop_tables=True) as session:
|
||||||
# Should not raise
|
# Should not raise
|
||||||
await cleanup_tables(session, EmptyBase)
|
await cleanup_tables(session, EmptyBase)
|
||||||
|
|
||||||
|
|
||||||
|
class TestM2MAdd:
|
||||||
|
"""Tests for m2m_add helper."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_adds_single_related(self, db_session: AsyncSession):
|
||||||
|
"""Associates one related instance via the secondary table."""
|
||||||
|
user = User(username="m2m_author", email="m2m@test.com")
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
post = Post(title="Post A", author_id=user.id)
|
||||||
|
tag = Tag(name="python")
|
||||||
|
db_session.add_all([post, tag])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
async with get_transaction(db_session):
|
||||||
|
await m2m_add(db_session, post, Post.tags, tag)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
|
||||||
|
)
|
||||||
|
loaded = result.scalar_one()
|
||||||
|
assert len(loaded.tags) == 1
|
||||||
|
assert loaded.tags[0].id == tag.id
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_adds_multiple_related(self, db_session: AsyncSession):
|
||||||
|
"""Associates multiple related instances in a single call."""
|
||||||
|
user = User(username="m2m_author2", email="m2m2@test.com")
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
post = Post(title="Post B", author_id=user.id)
|
||||||
|
tag1 = Tag(name="web")
|
||||||
|
tag2 = Tag(name="api")
|
||||||
|
tag3 = Tag(name="async")
|
||||||
|
db_session.add_all([post, tag1, tag2, tag3])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
async with get_transaction(db_session):
|
||||||
|
await m2m_add(db_session, post, Post.tags, tag1, tag2, tag3)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
|
||||||
|
)
|
||||||
|
loaded = result.scalar_one()
|
||||||
|
assert {t.id for t in loaded.tags} == {tag1.id, tag2.id, tag3.id}
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_noop_for_empty_related(self, db_session: AsyncSession):
|
||||||
|
"""Calling with no related instances is a no-op."""
|
||||||
|
user = User(username="m2m_author3", email="m2m3@test.com")
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
post = Post(title="Post C", author_id=user.id)
|
||||||
|
db_session.add(post)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
async with get_transaction(db_session):
|
||||||
|
await m2m_add(db_session, post, Post.tags) # no related instances
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
|
||||||
|
)
|
||||||
|
loaded = result.scalar_one()
|
||||||
|
assert loaded.tags == []
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_ignore_conflicts_true(self, db_session: AsyncSession):
|
||||||
|
"""Duplicate inserts are silently skipped when ignore_conflicts=True."""
|
||||||
|
user = User(username="m2m_author4", email="m2m4@test.com")
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
post = Post(title="Post D", author_id=user.id)
|
||||||
|
tag = Tag(name="duplicate_tag")
|
||||||
|
db_session.add_all([post, tag])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
async with get_transaction(db_session):
|
||||||
|
await m2m_add(db_session, post, Post.tags, tag)
|
||||||
|
|
||||||
|
# Second call with ignore_conflicts=True must not raise
|
||||||
|
async with get_transaction(db_session):
|
||||||
|
await m2m_add(db_session, post, Post.tags, tag, ignore_conflicts=True)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
|
||||||
|
)
|
||||||
|
loaded = result.scalar_one()
|
||||||
|
assert len(loaded.tags) == 1
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_ignore_conflicts_false_raises(self, db_session: AsyncSession):
|
||||||
|
"""Duplicate inserts raise IntegrityError when ignore_conflicts=False (default)."""
|
||||||
|
user = User(username="m2m_author5", email="m2m5@test.com")
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
post = Post(title="Post E", author_id=user.id)
|
||||||
|
tag = Tag(name="conflict_tag")
|
||||||
|
db_session.add_all([post, tag])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
async with get_transaction(db_session):
|
||||||
|
await m2m_add(db_session, post, Post.tags, tag)
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
async with get_transaction(db_session):
|
||||||
|
await m2m_add(db_session, post, Post.tags, tag)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_non_m2m_raises_type_error(self, db_session: AsyncSession):
|
||||||
|
"""Passing a non-M2M relationship attribute raises TypeError."""
|
||||||
|
user = User(username="m2m_author6", email="m2m6@test.com")
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
role = Role(name="type_err_role")
|
||||||
|
db_session.add(role)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
with pytest.raises(TypeError, match="Many-to-Many"):
|
||||||
|
await m2m_add(db_session, user, User.role, role)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_works_inside_lock_tables(self, db_session: AsyncSession):
|
||||||
|
"""m2m_add works correctly inside a lock_tables nested transaction."""
|
||||||
|
user = User(username="m2m_lock_author", email="m2m_lock@test.com")
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
async with lock_tables(db_session, [Tag]):
|
||||||
|
tag = Tag(name="locked_tag")
|
||||||
|
db_session.add(tag)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
post = Post(title="Post Lock", author_id=user.id)
|
||||||
|
db_session.add(post)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
await m2m_add(db_session, post, Post.tags, tag)
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
|
||||||
|
)
|
||||||
|
loaded = result.scalar_one()
|
||||||
|
assert len(loaded.tags) == 1
|
||||||
|
assert loaded.tags[0].name == "locked_tag"
|
||||||
|
|
||||||
|
|
||||||
|
class _LocalBase(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_comp_assoc = Table(
|
||||||
|
"_comp_assoc",
|
||||||
|
_LocalBase.metadata,
|
||||||
|
Column("owner_id", Uuid, ForeignKey("_comp_owners.id"), primary_key=True),
|
||||||
|
Column("item_group", String(50), primary_key=True),
|
||||||
|
Column("item_code", String(50), primary_key=True),
|
||||||
|
ForeignKeyConstraint(
|
||||||
|
["item_group", "item_code"],
|
||||||
|
["_comp_items.group_id", "_comp_items.item_code"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _CompOwner(_LocalBase):
|
||||||
|
__tablename__ = "_comp_owners"
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||||
|
items: Mapped[list["_CompItem"]] = relationship(secondary=_comp_assoc)
|
||||||
|
|
||||||
|
|
||||||
|
class _CompItem(_LocalBase):
|
||||||
|
__tablename__ = "_comp_items"
|
||||||
|
group_id: Mapped[str] = mapped_column(String(50), primary_key=True)
|
||||||
|
item_code: Mapped[str] = mapped_column(String(50), primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestM2MRemove:
|
||||||
|
"""Tests for m2m_remove helper."""
|
||||||
|
|
||||||
|
async def _setup(
|
||||||
|
self, session: AsyncSession, username: str, email: str, *tag_names: str
|
||||||
|
):
|
||||||
|
"""Create a user, post, and tags; associate all tags with the post."""
|
||||||
|
user = User(username=username, email=email)
|
||||||
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
post = Post(title=f"Post {username}", author_id=user.id)
|
||||||
|
tags = [Tag(name=n) for n in tag_names]
|
||||||
|
session.add(post)
|
||||||
|
session.add_all(tags)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
async with get_transaction(session):
|
||||||
|
await m2m_add(session, post, Post.tags, *tags)
|
||||||
|
|
||||||
|
return post, tags
|
||||||
|
|
||||||
|
async def _load_tags(self, session: AsyncSession, post: Post) -> list[Tag]:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
|
||||||
|
)
|
||||||
|
return result.scalar_one().tags
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_removes_single(self, db_session: AsyncSession):
|
||||||
|
"""Removes one association, leaving others intact."""
|
||||||
|
post, (tag1, tag2) = await self._setup(
|
||||||
|
db_session, "rm_author1", "rm1@test.com", "tag_rm_a", "tag_rm_b"
|
||||||
|
)
|
||||||
|
|
||||||
|
async with get_transaction(db_session):
|
||||||
|
await m2m_remove(db_session, post, Post.tags, tag1)
|
||||||
|
|
||||||
|
remaining = await self._load_tags(db_session, post)
|
||||||
|
assert len(remaining) == 1
|
||||||
|
assert remaining[0].id == tag2.id
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_removes_multiple(self, db_session: AsyncSession):
|
||||||
|
"""Removes multiple associations in one call."""
|
||||||
|
post, (tag1, tag2, tag3) = await self._setup(
|
||||||
|
db_session, "rm_author2", "rm2@test.com", "tag_rm_c", "tag_rm_d", "tag_rm_e"
|
||||||
|
)
|
||||||
|
|
||||||
|
async with get_transaction(db_session):
|
||||||
|
await m2m_remove(db_session, post, Post.tags, tag1, tag3)
|
||||||
|
|
||||||
|
remaining = await self._load_tags(db_session, post)
|
||||||
|
assert len(remaining) == 1
|
||||||
|
assert remaining[0].id == tag2.id
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_noop_for_empty_related(self, db_session: AsyncSession):
|
||||||
|
"""Calling with no related instances is a no-op."""
|
||||||
|
post, (tag,) = await self._setup(
|
||||||
|
db_session, "rm_author3", "rm3@test.com", "tag_rm_f"
|
||||||
|
)
|
||||||
|
|
||||||
|
async with get_transaction(db_session):
|
||||||
|
await m2m_remove(db_session, post, Post.tags)
|
||||||
|
|
||||||
|
remaining = await self._load_tags(db_session, post)
|
||||||
|
assert len(remaining) == 1
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_idempotent_for_missing_association(self, db_session: AsyncSession):
|
||||||
|
"""Removing a non-existent association does not raise."""
|
||||||
|
post, (tag1,) = await self._setup(
|
||||||
|
db_session, "rm_author4", "rm4@test.com", "tag_rm_g"
|
||||||
|
)
|
||||||
|
tag2 = Tag(name="tag_rm_h")
|
||||||
|
db_session.add(tag2)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
# tag2 was never associated — should not raise
|
||||||
|
async with get_transaction(db_session):
|
||||||
|
await m2m_remove(db_session, post, Post.tags, tag2)
|
||||||
|
|
||||||
|
remaining = await self._load_tags(db_session, post)
|
||||||
|
assert len(remaining) == 1
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_non_m2m_raises_type_error(self, db_session: AsyncSession):
|
||||||
|
"""Passing a non-M2M relationship attribute raises TypeError."""
|
||||||
|
user = User(username="rm_author5", email="rm5@test.com")
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
role = Role(name="rm_type_err_role")
|
||||||
|
db_session.add(role)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
with pytest.raises(TypeError, match="Many-to-Many"):
|
||||||
|
await m2m_remove(db_session, user, User.role, role)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_removes_composite_pk_related(self):
|
||||||
|
"""Composite-PK branch: DELETE uses tuple IN when related side has multi-col PK."""
|
||||||
|
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(_LocalBase.metadata.create_all)
|
||||||
|
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
try:
|
||||||
|
async with session_factory() as session:
|
||||||
|
owner = _CompOwner()
|
||||||
|
item1 = _CompItem(group_id="g1", item_code="c1")
|
||||||
|
item2 = _CompItem(group_id="g1", item_code="c2")
|
||||||
|
session.add_all([owner, item1, item2])
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
async with get_transaction(session):
|
||||||
|
await m2m_add(session, owner, _CompOwner.items, item1, item2)
|
||||||
|
|
||||||
|
async with get_transaction(session):
|
||||||
|
await m2m_remove(session, owner, _CompOwner.items, item1)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async with session_factory() as verify:
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
result = await verify.execute(
|
||||||
|
select(_CompOwner)
|
||||||
|
.where(_CompOwner.id == owner.id)
|
||||||
|
.options(selectinload(_CompOwner.items))
|
||||||
|
)
|
||||||
|
loaded = result.scalar_one()
|
||||||
|
assert len(loaded.items) == 1
|
||||||
|
assert (loaded.items[0].group_id, loaded.items[0].item_code) == (
|
||||||
|
"g1",
|
||||||
|
"c2",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(_LocalBase.metadata.drop_all)
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
class TestM2MSet:
|
||||||
|
"""Tests for m2m_set helper."""
|
||||||
|
|
||||||
|
async def _load_tags(self, session: AsyncSession, post: Post) -> list[Tag]:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Post).where(Post.id == post.id).options(selectinload(Post.tags))
|
||||||
|
)
|
||||||
|
return result.scalar_one().tags
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_replaces_existing_set(self, db_session: AsyncSession):
|
||||||
|
"""Replaces the full association set atomically."""
|
||||||
|
user = User(username="set_author1", email="set1@test.com")
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
post = Post(title="Post Set A", author_id=user.id)
|
||||||
|
tag1 = Tag(name="tag_set_a")
|
||||||
|
tag2 = Tag(name="tag_set_b")
|
||||||
|
tag3 = Tag(name="tag_set_c")
|
||||||
|
db_session.add_all([post, tag1, tag2, tag3])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
async with get_transaction(db_session):
|
||||||
|
await m2m_add(db_session, post, Post.tags, tag1, tag2)
|
||||||
|
|
||||||
|
async with get_transaction(db_session):
|
||||||
|
await m2m_set(db_session, post, Post.tags, tag3)
|
||||||
|
|
||||||
|
remaining = await self._load_tags(db_session, post)
|
||||||
|
assert len(remaining) == 1
|
||||||
|
assert remaining[0].id == tag3.id
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_clears_all_when_no_related(self, db_session: AsyncSession):
|
||||||
|
"""Passing no related instances clears all associations."""
|
||||||
|
user = User(username="set_author2", email="set2@test.com")
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
post = Post(title="Post Set B", author_id=user.id)
|
||||||
|
tag = Tag(name="tag_set_d")
|
||||||
|
db_session.add_all([post, tag])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
async with get_transaction(db_session):
|
||||||
|
await m2m_add(db_session, post, Post.tags, tag)
|
||||||
|
|
||||||
|
async with get_transaction(db_session):
|
||||||
|
await m2m_set(db_session, post, Post.tags)
|
||||||
|
|
||||||
|
remaining = await self._load_tags(db_session, post)
|
||||||
|
assert remaining == []
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_set_on_empty_then_populate(self, db_session: AsyncSession):
|
||||||
|
"""m2m_set works on a post with no existing associations."""
|
||||||
|
user = User(username="set_author3", email="set3@test.com")
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
post = Post(title="Post Set C", author_id=user.id)
|
||||||
|
tag1 = Tag(name="tag_set_e")
|
||||||
|
tag2 = Tag(name="tag_set_f")
|
||||||
|
db_session.add_all([post, tag1, tag2])
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
async with get_transaction(db_session):
|
||||||
|
await m2m_set(db_session, post, Post.tags, tag1, tag2)
|
||||||
|
|
||||||
|
remaining = await self._load_tags(db_session, post)
|
||||||
|
assert {t.id for t in remaining} == {tag1.id, tag2.id}
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_non_m2m_raises_type_error(self, db_session: AsyncSession):
|
||||||
|
"""Passing a non-M2M relationship attribute raises TypeError."""
|
||||||
|
user = User(username="set_author4", email="set4@test.com")
|
||||||
|
db_session.add(user)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
role = Role(name="set_type_err_role")
|
||||||
|
db_session.add(role)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
with pytest.raises(TypeError, match="Many-to-Many"):
|
||||||
|
await m2m_set(db_session, user, User.role, role)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from fastapi_toolsets.fixtures import (
|
|||||||
Context,
|
Context,
|
||||||
FixtureRegistry,
|
FixtureRegistry,
|
||||||
LoadStrategy,
|
LoadStrategy,
|
||||||
|
get_field_by_attr,
|
||||||
get_obj_by_attr,
|
get_obj_by_attr,
|
||||||
load_fixtures,
|
load_fixtures,
|
||||||
load_fixtures_by_context,
|
load_fixtures_by_context,
|
||||||
@@ -951,6 +952,41 @@ class TestGetObjByAttr:
|
|||||||
get_obj_by_attr(self.roles, "id", "not-a-uuid")
|
get_obj_by_attr(self.roles, "id", "not-a-uuid")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetFieldByAttr:
|
||||||
|
"""Tests for get_field_by_attr helper function."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
self.registry = FixtureRegistry()
|
||||||
|
self.role_id_1 = uuid.uuid4()
|
||||||
|
self.role_id_2 = uuid.uuid4()
|
||||||
|
role_id_1 = self.role_id_1
|
||||||
|
role_id_2 = self.role_id_2
|
||||||
|
|
||||||
|
@self.registry.register
|
||||||
|
def roles() -> list[Role]:
|
||||||
|
return [
|
||||||
|
Role(id=role_id_1, name="admin"),
|
||||||
|
Role(id=role_id_2, name="user"),
|
||||||
|
]
|
||||||
|
|
||||||
|
self.roles = roles
|
||||||
|
|
||||||
|
def test_returns_id_by_default(self):
|
||||||
|
"""Returns the id field when no field is specified."""
|
||||||
|
result = get_field_by_attr(self.roles, "name", "admin")
|
||||||
|
assert result == self.role_id_1
|
||||||
|
|
||||||
|
def test_returns_specified_field(self):
|
||||||
|
"""Returns the requested field instead of id."""
|
||||||
|
result = get_field_by_attr(self.roles, "id", self.role_id_2, field="name")
|
||||||
|
assert result == "user"
|
||||||
|
|
||||||
|
def test_no_match_raises_stop_iteration(self):
|
||||||
|
"""Propagates StopIteration from get_obj_by_attr when no match found."""
|
||||||
|
with pytest.raises(StopIteration, match="No object with name=missing"):
|
||||||
|
get_field_by_attr(self.roles, "name", "missing")
|
||||||
|
|
||||||
|
|
||||||
class TestGetPrimaryKey:
|
class TestGetPrimaryKey:
|
||||||
"""Unit tests for the _get_primary_key helper (composite PK paths)."""
|
"""Unit tests for the _get_primary_key helper (composite PK paths)."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
"""Tests for fastapi_toolsets.pytest module."""
|
"""Tests for fastapi_toolsets.pytest module."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import ForeignKey, String, select, text
|
||||||
from sqlalchemy.engine import make_url
|
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 selectinload
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from fastapi_toolsets.db import get_transaction
|
from fastapi_toolsets.db import get_transaction
|
||||||
from fastapi_toolsets.fixtures import Context, FixtureRegistry
|
from fastapi_toolsets.fixtures import Context, FixtureRegistry, LoadStrategy
|
||||||
from fastapi_toolsets.pytest import (
|
from fastapi_toolsets.pytest import (
|
||||||
create_async_client,
|
create_async_client,
|
||||||
create_db_session,
|
create_db_session,
|
||||||
@@ -19,9 +20,23 @@ from fastapi_toolsets.pytest import (
|
|||||||
register_fixtures,
|
register_fixtures,
|
||||||
worker_database_url,
|
worker_database_url,
|
||||||
)
|
)
|
||||||
|
from fastapi_toolsets.pytest.plugin import (
|
||||||
|
_get_primary_key,
|
||||||
|
_relationship_load_options,
|
||||||
|
_reload_with_relationships,
|
||||||
|
)
|
||||||
from fastapi_toolsets.pytest.utils import _get_xdist_worker
|
from fastapi_toolsets.pytest.utils import _get_xdist_worker
|
||||||
|
|
||||||
from .conftest import DATABASE_URL, Base, Role, RoleCrud, User, UserCrud
|
from .conftest import (
|
||||||
|
DATABASE_URL,
|
||||||
|
Base,
|
||||||
|
IntRole,
|
||||||
|
Permission,
|
||||||
|
Role,
|
||||||
|
RoleCrud,
|
||||||
|
User,
|
||||||
|
UserCrud,
|
||||||
|
)
|
||||||
|
|
||||||
test_registry = FixtureRegistry()
|
test_registry = FixtureRegistry()
|
||||||
|
|
||||||
@@ -136,14 +151,8 @@ class TestGeneratedFixtures:
|
|||||||
async def test_fixture_relationships_work(
|
async def test_fixture_relationships_work(
|
||||||
self, db_session: AsyncSession, fixture_users: list[User]
|
self, db_session: AsyncSession, fixture_users: list[User]
|
||||||
):
|
):
|
||||||
"""Loaded fixtures have working relationships."""
|
"""Loaded fixtures have working relationships directly accessible."""
|
||||||
# Load user with role relationship
|
user = next(u for u in fixture_users if u.id == USER_ADMIN_ID)
|
||||||
user = await UserCrud.get(
|
|
||||||
db_session,
|
|
||||||
[User.id == USER_ADMIN_ID],
|
|
||||||
load_options=[selectinload(User.role)],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert user.role is not None
|
assert user.role is not None
|
||||||
assert user.role.name == "plugin_admin"
|
assert user.role.name == "plugin_admin"
|
||||||
|
|
||||||
@@ -177,6 +186,15 @@ class TestGeneratedFixtures:
|
|||||||
assert users[0].username == "plugin_admin"
|
assert users[0].username == "plugin_admin"
|
||||||
assert users[1].username == "plugin_user"
|
assert users[1].username == "plugin_user"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_fixture_auto_loads_relationships(
|
||||||
|
self, db_session: AsyncSession, fixture_users: list[User]
|
||||||
|
):
|
||||||
|
"""Fixtures automatically eager-load all direct relationships."""
|
||||||
|
user = next(u for u in fixture_users if u.username == "plugin_admin")
|
||||||
|
assert user.role is not None
|
||||||
|
assert user.role.name == "plugin_admin"
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_multiple_fixtures_in_same_test(
|
async def test_multiple_fixtures_in_same_test(
|
||||||
self,
|
self,
|
||||||
@@ -516,3 +534,192 @@ class TestCreateWorkerDatabase:
|
|||||||
)
|
)
|
||||||
assert result.scalar() is None
|
assert result.scalar() is None
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
class _LocalBase(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _Group(_LocalBase):
|
||||||
|
__tablename__ = "_test_groups"
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
|
||||||
|
class _CompositeItem(_LocalBase):
|
||||||
|
"""Model with composite PK and a relationship — exercises the fallback path."""
|
||||||
|
|
||||||
|
__tablename__ = "_test_composite_items"
|
||||||
|
group_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("_test_groups.id"), primary_key=True
|
||||||
|
)
|
||||||
|
item_code: Mapped[str] = mapped_column(String(50), primary_key=True)
|
||||||
|
group: Mapped["_Group"] = relationship()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetPrimaryKey:
|
||||||
|
"""Unit tests for _get_primary_key — no DB needed."""
|
||||||
|
|
||||||
|
def test_single_pk_returns_value(self):
|
||||||
|
rid = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||||
|
role = Role(id=rid, name="x")
|
||||||
|
assert _get_primary_key(role) == rid
|
||||||
|
|
||||||
|
def test_composite_pk_all_set_returns_tuple(self):
|
||||||
|
perm = Permission(subject="posts", action="read")
|
||||||
|
assert _get_primary_key(perm) == ("posts", "read")
|
||||||
|
|
||||||
|
def test_composite_pk_partial_none_returns_none(self):
|
||||||
|
perm = Permission(subject=None, action="read")
|
||||||
|
assert _get_primary_key(perm) is None
|
||||||
|
|
||||||
|
def test_composite_pk_all_none_returns_none(self):
|
||||||
|
perm = Permission(subject=None, action=None)
|
||||||
|
assert _get_primary_key(perm) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRelationshipLoadOptions:
|
||||||
|
"""Unit tests for _relationship_load_options — no DB needed."""
|
||||||
|
|
||||||
|
def test_empty_for_model_with_no_relationships(self):
|
||||||
|
assert _relationship_load_options(IntRole) == []
|
||||||
|
|
||||||
|
def test_returns_options_for_model_with_relationships(self):
|
||||||
|
opts = _relationship_load_options(User)
|
||||||
|
assert len(opts) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestFixtureStrategies:
|
||||||
|
"""Integration tests covering INSERT, SKIP_EXISTING, empty fixture, no-rels model."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_empty_fixture_returns_empty_list(self, db_session: AsyncSession):
|
||||||
|
"""Fixture function returning [] produces an empty list."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry.register()
|
||||||
|
def empty() -> list[Role]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
local_ns: dict = {}
|
||||||
|
register_fixtures(registry, local_ns, session_fixture="db_session")
|
||||||
|
inner = local_ns["fixture_empty"].__wrapped__ # type: ignore[attr-defined]
|
||||||
|
result = await inner(db_session=db_session)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_insert_strategy_no_relationships(self, db_session: AsyncSession):
|
||||||
|
"""INSERT strategy adds instances; model with no rels skips reload (line 135)."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry.register()
|
||||||
|
def int_roles() -> list[IntRole]:
|
||||||
|
return [IntRole(name="insert_role")]
|
||||||
|
|
||||||
|
local_ns: dict = {}
|
||||||
|
register_fixtures(
|
||||||
|
registry,
|
||||||
|
local_ns,
|
||||||
|
session_fixture="db_session",
|
||||||
|
strategy=LoadStrategy.INSERT,
|
||||||
|
)
|
||||||
|
inner = local_ns["fixture_int_roles"].__wrapped__ # type: ignore[attr-defined]
|
||||||
|
result = await inner(db_session=db_session)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].name == "insert_role"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_skip_existing_inserts_new_record(self, db_session: AsyncSession):
|
||||||
|
"""SKIP_EXISTING inserts when the record does not yet exist."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
role_id = uuid.uuid4()
|
||||||
|
|
||||||
|
@registry.register()
|
||||||
|
def new_roles() -> list[Role]:
|
||||||
|
return [Role(id=role_id, name="skip_new")]
|
||||||
|
|
||||||
|
local_ns: dict = {}
|
||||||
|
register_fixtures(
|
||||||
|
registry,
|
||||||
|
local_ns,
|
||||||
|
session_fixture="db_session",
|
||||||
|
strategy=LoadStrategy.SKIP_EXISTING,
|
||||||
|
)
|
||||||
|
inner = local_ns["fixture_new_roles"].__wrapped__ # type: ignore[attr-defined]
|
||||||
|
result = await inner(db_session=db_session)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].id == role_id
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_skip_existing_returns_existing_record(
|
||||||
|
self, db_session: AsyncSession
|
||||||
|
):
|
||||||
|
"""SKIP_EXISTING returns the existing DB record when PK already present."""
|
||||||
|
role_id = uuid.uuid4()
|
||||||
|
existing = Role(id=role_id, name="already_there")
|
||||||
|
db_session.add(existing)
|
||||||
|
await db_session.flush()
|
||||||
|
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry.register()
|
||||||
|
def dup_roles() -> list[Role]:
|
||||||
|
return [Role(id=role_id, name="should_not_overwrite")]
|
||||||
|
|
||||||
|
local_ns: dict = {}
|
||||||
|
register_fixtures(
|
||||||
|
registry,
|
||||||
|
local_ns,
|
||||||
|
session_fixture="db_session",
|
||||||
|
strategy=LoadStrategy.SKIP_EXISTING,
|
||||||
|
)
|
||||||
|
inner = local_ns["fixture_dup_roles"].__wrapped__ # type: ignore[attr-defined]
|
||||||
|
result = await inner(db_session=db_session)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].name == "already_there"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_skip_existing_null_pk_inserts(self, db_session: AsyncSession):
|
||||||
|
"""SKIP_EXISTING with null PK (auto-increment) falls through to session.add()."""
|
||||||
|
registry = FixtureRegistry()
|
||||||
|
|
||||||
|
@registry.register()
|
||||||
|
def auto_roles() -> list[IntRole]:
|
||||||
|
return [IntRole(name="auto_int")]
|
||||||
|
|
||||||
|
local_ns: dict = {}
|
||||||
|
register_fixtures(
|
||||||
|
registry,
|
||||||
|
local_ns,
|
||||||
|
session_fixture="db_session",
|
||||||
|
strategy=LoadStrategy.SKIP_EXISTING,
|
||||||
|
)
|
||||||
|
inner = local_ns["fixture_auto_roles"].__wrapped__ # type: ignore[attr-defined]
|
||||||
|
result = await inner(db_session=db_session)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].name == "auto_int"
|
||||||
|
|
||||||
|
|
||||||
|
class TestReloadWithRelationshipsCompositePK:
|
||||||
|
"""Integration test for _reload_with_relationships composite-PK fallback."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_composite_pk_fallback_loads_relationships(self):
|
||||||
|
"""Models with composite PKs are reloaded per-instance via session.get()."""
|
||||||
|
async with create_db_session(DATABASE_URL, _LocalBase) as session:
|
||||||
|
group = _Group(id=uuid.uuid4(), name="g1")
|
||||||
|
session.add(group)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
item = _CompositeItem(group_id=group.id, item_code="A")
|
||||||
|
session.add(item)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
load_opts = _relationship_load_options(_CompositeItem)
|
||||||
|
assert load_opts # _CompositeItem has 'group' relationship
|
||||||
|
|
||||||
|
reloaded = await _reload_with_relationships(session, [item], load_opts)
|
||||||
|
assert len(reloaded) == 1
|
||||||
|
reloaded_item = cast(_CompositeItem, reloaded[0])
|
||||||
|
assert reloaded_item.group is not None
|
||||||
|
assert reloaded_item.group.name == "g1"
|
||||||
|
|||||||
30
uv.lock
generated
30
uv.lock
generated
@@ -251,7 +251,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "3.0.3"
|
version = "3.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
@@ -1324,7 +1324,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zensical"
|
name = "zensical"
|
||||||
version = "0.0.32"
|
version = "0.0.33"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
@@ -1334,18 +1334,18 @@ dependencies = [
|
|||||||
{ name = "pymdown-extensions" },
|
{ name = "pymdown-extensions" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/94/4a49ca9329136445f4111fda60e4bfcbe68d95e18e9aa02e4606fba5df4a/zensical-0.0.32.tar.gz", hash = "sha256:0f857b09a2b10c99202b3712e1ffc4d1d1ffa4c7c2f1aa0fafb1346b2d8df604", size = 3891955, upload-time = "2026-04-07T11:41:29.203Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/59/c2/dea4b86dc1ca2a7b55414017f12cfb12b5cfdf3a1ed7c77a04c271eb523b/zensical-0.0.33.tar.gz", hash = "sha256:05209cb4f80185c533e0d37c25d084ddc2050e3d5a4dd1b1812961c2ee0c3380", size = 3892278, upload-time = "2026-04-14T11:08:19.895Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/e1/dd03762447f1c2a4c8aff08e8f047ec17c73421714a0600ef71c361a5934/zensical-0.0.32-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7ed181c76c03fec4c2dd5db207810044bf9c3fa87097fbdbabd633661e20fc70", size = 12416474, upload-time = "2026-04-07T11:40:55.888Z" },
|
{ url = "https://files.pythonhosted.org/packages/74/5f/45d5200405420a9d8ac91cf9e7826622ea12f3198e8e6ac4ffb481eb53bf/zensical-0.0.33-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f658e3c241cfbb560bd8811116a9486cff7e04d7d5aed73569dd533c74187450", size = 12416748, upload-time = "2026-04-14T11:07:43.246Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/a6/2f1babb00842c6efa5ae755b3ab414e4688ae8e47bdd2e785c0c37ef625d/zensical-0.0.32-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8cde82bf256408f75ae2b07bffcaac7d080b6aad5f7acf210c438cb7413c3081", size = 12292801, upload-time = "2026-04-07T11:40:59.648Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/1e/aadaf31d6e4d20419ecedaf0b1c804e359ec23dcdb44c8d2bf6d8407080c/zensical-0.0.33-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f9813ac3256c28e2e2f1ba5c9fab1b4bca62bbe0e0f8e85ac22d33b068b1b08a", size = 12293372, upload-time = "2026-04-14T11:07:46.569Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/f1/d32706de06fd30fb07ae514222a79dd17d4578cd1634e5b692e0c790a61e/zensical-0.0.32-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60e60e2358249b2a2c5e1c5c04586d8dbba27e577441cc9dd32fe8d879c6951e", size = 12658847, upload-time = "2026-04-07T11:41:02.347Z" },
|
{ url = "https://files.pythonhosted.org/packages/db/e5/838be8451ea8b2aecec39fbec3971060fc705e17f5741249740d9b6a6824/zensical-0.0.33-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3bad7ac71028769c5d1f3f84f448dbb7352db28d77095d1b40a8d1b0aa34ec30", size = 12659832, upload-time = "2026-04-14T11:07:50.754Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/42/a3daf4047c86382749a59795c4e7acd59952b4f6f37f329cd2d41cc37a0f/zensical-0.0.32-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec79b4304009138e7a38ebe24e8a8e9dbc15d38922185f8a84470a7757d7b73f", size = 12604777, upload-time = "2026-04-07T11:41:05.227Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/5c/dd957d7c83efc13a70a6058d4190a3afcf29942aefb391120bca5466347d/zensical-0.0.33-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:06bb039daf044547c9400a52f9493b3cd486ba9baef3324fdcffd2e26e61105f", size = 12603847, upload-time = "2026-04-14T11:07:53.698Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/11/4af61d3fb07713cd3f77981c1b3017a60c2b210b36f1b04353f9116d03ca/zensical-0.0.32-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fc92fa7d0860ec6d95426a5f545cfc5493c60f8ab44fcc11611a4251f34f1b70", size = 12956242, upload-time = "2026-04-07T11:41:07.58Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/99/dd6ccc392ece1f34fb20ea339a01717badbbeb2fba1d4f3019a5028d0bcc/zensical-0.0.33-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:260238062b3139ece0edab93f4dbe7a12923453091f5aa580dfd73e799388076", size = 12956236, upload-time = "2026-04-14T11:07:56.728Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/34/e9b5f4376bbf460f8c07a77af59bd169c7c68ed719a074e6667ba41109f8/zensical-0.0.32-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:07f69019396060e310c9c3b18747ce8982ad56d67fbab269b61e74a6a5bdcb4a", size = 12701954, upload-time = "2026-04-07T11:41:10.532Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/76/e0a1b884eadf6afa7e2d56c90c268eec36836ac27e96ef250c0129e55417/zensical-0.0.33-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7dff0f4afda7b8586bc4ab2a5684bce5b282232dd4e0cad3be4c73fedd264425", size = 12701944, upload-time = "2026-04-14T11:07:59.928Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/43/a52e5dcb324f38a1d22f7fafd4eec273385d04de52a7ab5ac7b444cf2bdc/zensical-0.0.32-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d096c9ed20a48e5ff095eca218eef94f67e739cdf0abf7e1f7e232e78f6d980c", size = 12835464, upload-time = "2026-04-07T11:41:13.152Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/38/e1ff13461e406864fa2b23fc828822659a7dbac5c79398f724d17f088540/zensical-0.0.33-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:207b4d81b208d75b97dc7bd318804550b886a3e852ef67429ef0e6b9442839d1", size = 12835444, upload-time = "2026-04-14T11:08:02.998Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/95/bede89ecb4932bbd29db7b61bf530a962aed09d3a8d5aa71a64af1d4920f/zensical-0.0.32-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:bf5576b7154bde18cebd9a7b065d3ab8b334c6e73d5b2e83abe2b17f9d00a992", size = 12876574, upload-time = "2026-04-07T11:41:16.085Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/04/7d24d52d6903fc5c511633afe8b5716fef19da09685327665cc127f61648/zensical-0.0.33-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:06d2f57f7bc8cc8fd904386020ea1365eebc411e8698a871e9525c885abca574", size = 12878419, upload-time = "2026-04-14T11:08:06.054Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/e8/9b25fda22bf729ca2598cc42cefe9b20e751d12d23e35c70ea0c7939d20a/zensical-0.0.32-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f33905a1e0b03a2ad548554a157b7f7c398e6f41012d1e755105ae2bc60eab8a", size = 13022702, upload-time = "2026-04-07T11:41:18.947Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/ec/87fc9e360c694ab006363c7834639eccafd0d26a487cd63dd609bd68f36a/zensical-0.0.33-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:c2851b82d83aa0b2ae4f8e99731cfeedeecebfa04e6b3fc4d375deca629fa240", size = 13022474, upload-time = "2026-04-14T11:08:09.007Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/35/0c6d0b57187bd470a05e8a391c0edd1d690eb429e12b9755c99cf60a370e/zensical-0.0.32-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0a73a53b1dd41fd239875a3cb57c4284747989c45b6933f18e9b51f1b5f3d8ef", size = 12975593, upload-time = "2026-04-07T11:41:21.436Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/b3/0bf174ab6ceedb31d9af462073b5339c894b2084a27d42cb9f0906050d76/zensical-0.0.33-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:90daaf512b0429d7b9147ad5e6085b455d24803eff18b508aed738ca65444683", size = 12975233, upload-time = "2026-04-14T11:08:12.535Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/2d/4e88bcefc33b7af22f0637fd002d3cf5384e8354f0a7f8a9dbfcd40cfa24/zensical-0.0.32-cp310-abi3-win32.whl", hash = "sha256:f8cb579bdb9b56f1704b93f4e17b42895c8cb466e8eec933fbe0153b5b1e3459", size = 12012163, upload-time = "2026-04-07T11:41:23.975Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/27/7cc3c2d284698647f60f3b823e0101e619c87edf158d47ee11bf4bfb6228/zensical-0.0.33-cp310-abi3-win32.whl", hash = "sha256:2701820597fe19361a12371129927c58c19633dcaa5f6986d610dce58cecd8c4", size = 12012664, upload-time = "2026-04-14T11:08:14.977Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/ae/a80a2f15fd10201fe3dfd6b5cdf85351165f820cf5b29e3c3b24092c158c/zensical-0.0.32-cp310-abi3-win_amd64.whl", hash = "sha256:6d662f42b5d0eadfac6d281e9d86574bc7a9f812f1ed496335d15f2d581d4b28", size = 12205948, upload-time = "2026-04-07T11:41:27.056Z" },
|
{ url = "https://files.pythonhosted.org/packages/25/0b/6be5c2fdaf9f1600577e7ba5e235d86b72a26f6af389efb146f978f76ac3/zensical-0.0.33-cp310-abi3-win_amd64.whl", hash = "sha256:a5a0911b4247708a55951b74c459f4d5faec5daaf287d23a2e1f0d96be1e647f", size = 12206255, upload-time = "2026-04-14T11:08:17.375Z" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user