fastapi-toolsets 3.0.3__tar.gz → 3.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/PKG-INFO +1 -1
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/pyproject.toml +1 -1
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/__init__.py +1 -1
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/db.py +145 -3
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/fixtures/__init__.py +7 -1
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/fixtures/utils.py +25 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/pytest/plugin.py +58 -3
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/LICENSE +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/README.md +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/_imports.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/cli/__init__.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/cli/app.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/cli/config.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/cli/pyproject.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/cli/utils.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/crud/__init__.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/crud/factory.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/crud/search.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/dependencies.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/exceptions/handler.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/fixtures/enum.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/fixtures/registry.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/logger.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/metrics/__init__.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/metrics/handler.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/metrics/registry.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/models/__init__.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/models/columns.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/models/watched.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/py.typed +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/pytest/__init__.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/pytest/utils.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/schemas.py +0 -0
- {fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/types.py +0 -0
|
@@ -4,11 +4,13 @@ import asyncio
|
|
|
4
4
|
from collections.abc import AsyncGenerator, Callable
|
|
5
5
|
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
6
6
|
from enum import Enum
|
|
7
|
-
from typing import Any, TypeVar
|
|
7
|
+
from typing import Any, TypeVar, cast
|
|
8
8
|
|
|
9
|
-
from sqlalchemy import text
|
|
9
|
+
from sqlalchemy import Table, delete, text, tuple_
|
|
10
|
+
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
10
11
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
11
|
-
from sqlalchemy.orm import DeclarativeBase
|
|
12
|
+
from sqlalchemy.orm import DeclarativeBase, QueryableAttribute
|
|
13
|
+
from sqlalchemy.orm.relationships import RelationshipProperty
|
|
12
14
|
|
|
13
15
|
from .exceptions import NotFoundError
|
|
14
16
|
|
|
@@ -20,6 +22,9 @@ __all__ = [
|
|
|
20
22
|
"create_db_dependency",
|
|
21
23
|
"get_transaction",
|
|
22
24
|
"lock_tables",
|
|
25
|
+
"m2m_add",
|
|
26
|
+
"m2m_remove",
|
|
27
|
+
"m2m_set",
|
|
23
28
|
"wait_for_row_change",
|
|
24
29
|
]
|
|
25
30
|
|
|
@@ -339,3 +344,140 @@ async def wait_for_row_change(
|
|
|
339
344
|
current = {col: getattr(instance, col) for col in watch_cols}
|
|
340
345
|
if current != initial:
|
|
341
346
|
return instance
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _m2m_prop(rel_attr: QueryableAttribute) -> RelationshipProperty: # type: ignore[type-arg]
|
|
350
|
+
"""Return the validated M2M RelationshipProperty for *rel_attr*.
|
|
351
|
+
|
|
352
|
+
Raises TypeError if *rel_attr* is not a Many-to-Many relationship.
|
|
353
|
+
"""
|
|
354
|
+
prop = rel_attr.property
|
|
355
|
+
if not isinstance(prop, RelationshipProperty) or prop.secondary is None:
|
|
356
|
+
raise TypeError(
|
|
357
|
+
f"m2m helpers require a Many-to-Many relationship attribute, "
|
|
358
|
+
f"got {rel_attr!r}. Use a relationship with a secondary table."
|
|
359
|
+
)
|
|
360
|
+
return prop
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
async def m2m_add(
|
|
364
|
+
session: AsyncSession,
|
|
365
|
+
instance: DeclarativeBase,
|
|
366
|
+
rel_attr: QueryableAttribute,
|
|
367
|
+
*related: DeclarativeBase,
|
|
368
|
+
ignore_conflicts: bool = False,
|
|
369
|
+
) -> None:
|
|
370
|
+
"""Insert rows into a Many-to-Many association table without loading the ORM collection.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
session: DB async session.
|
|
374
|
+
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
|
375
|
+
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
|
376
|
+
*related: One or more related instances to associate with ``instance``.
|
|
377
|
+
ignore_conflicts: When ``True``, silently skip rows that already exist
|
|
378
|
+
in the association table (``ON CONFLICT DO NOTHING``).
|
|
379
|
+
|
|
380
|
+
Raises:
|
|
381
|
+
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
|
382
|
+
"""
|
|
383
|
+
prop = _m2m_prop(rel_attr)
|
|
384
|
+
if not related:
|
|
385
|
+
return
|
|
386
|
+
|
|
387
|
+
secondary = cast(Table, prop.secondary)
|
|
388
|
+
assert secondary is not None # guaranteed by _m2m_prop
|
|
389
|
+
sync_pairs = prop.secondary_synchronize_pairs
|
|
390
|
+
assert sync_pairs is not None # set whenever secondary is set
|
|
391
|
+
|
|
392
|
+
# synchronize_pairs: [(parent_col, assoc_col), ...]
|
|
393
|
+
# secondary_synchronize_pairs: [(related_col, assoc_col), ...]
|
|
394
|
+
rows: list[dict[str, Any]] = []
|
|
395
|
+
for rel_instance in related:
|
|
396
|
+
row: dict[str, Any] = {}
|
|
397
|
+
for parent_col, assoc_col in prop.synchronize_pairs:
|
|
398
|
+
row[assoc_col.name] = getattr(instance, cast(str, parent_col.key))
|
|
399
|
+
for related_col, assoc_col in sync_pairs:
|
|
400
|
+
row[assoc_col.name] = getattr(rel_instance, cast(str, related_col.key))
|
|
401
|
+
rows.append(row)
|
|
402
|
+
|
|
403
|
+
stmt = pg_insert(secondary).values(rows)
|
|
404
|
+
if ignore_conflicts:
|
|
405
|
+
stmt = stmt.on_conflict_do_nothing()
|
|
406
|
+
await session.execute(stmt)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
async def m2m_remove(
|
|
410
|
+
session: AsyncSession,
|
|
411
|
+
instance: DeclarativeBase,
|
|
412
|
+
rel_attr: QueryableAttribute,
|
|
413
|
+
*related: DeclarativeBase,
|
|
414
|
+
) -> None:
|
|
415
|
+
"""Remove rows from a Many-to-Many association table without loading the ORM collection.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
session: DB async session.
|
|
419
|
+
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
|
420
|
+
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
|
421
|
+
*related: One or more related instances to disassociate from ``instance``.
|
|
422
|
+
|
|
423
|
+
Raises:
|
|
424
|
+
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
|
425
|
+
"""
|
|
426
|
+
prop = _m2m_prop(rel_attr)
|
|
427
|
+
if not related:
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
secondary = cast(Table, prop.secondary)
|
|
431
|
+
assert secondary is not None # guaranteed by _m2m_prop
|
|
432
|
+
related_pairs = prop.secondary_synchronize_pairs
|
|
433
|
+
assert related_pairs is not None # set whenever secondary is set
|
|
434
|
+
|
|
435
|
+
parent_where = [
|
|
436
|
+
assoc_col == getattr(instance, cast(str, parent_col.key))
|
|
437
|
+
for parent_col, assoc_col in prop.synchronize_pairs
|
|
438
|
+
]
|
|
439
|
+
|
|
440
|
+
if len(related_pairs) == 1:
|
|
441
|
+
related_col, assoc_col = related_pairs[0]
|
|
442
|
+
related_values = [getattr(r, cast(str, related_col.key)) for r in related]
|
|
443
|
+
related_where = assoc_col.in_(related_values)
|
|
444
|
+
else:
|
|
445
|
+
assoc_cols = [ac for _, ac in related_pairs]
|
|
446
|
+
rel_cols = [rc for rc, _ in related_pairs]
|
|
447
|
+
related_values_t = [
|
|
448
|
+
tuple(getattr(r, cast(str, rc.key)) for rc in rel_cols) for r in related
|
|
449
|
+
]
|
|
450
|
+
related_where = tuple_(*assoc_cols).in_(related_values_t)
|
|
451
|
+
|
|
452
|
+
await session.execute(delete(secondary).where(*parent_where, related_where))
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
async def m2m_set(
|
|
456
|
+
session: AsyncSession,
|
|
457
|
+
instance: DeclarativeBase,
|
|
458
|
+
rel_attr: QueryableAttribute,
|
|
459
|
+
*related: DeclarativeBase,
|
|
460
|
+
) -> None:
|
|
461
|
+
"""Replace the entire Many-to-Many association set atomically.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
session: DB async session.
|
|
465
|
+
instance: The "owner" side model instance (e.g. the ``A`` in ``A.b_list``).
|
|
466
|
+
rel_attr: The M2M relationship attribute on the model class (e.g. ``A.b_list``).
|
|
467
|
+
*related: The new complete set of related instances.
|
|
468
|
+
|
|
469
|
+
Raises:
|
|
470
|
+
TypeError: If ``rel_attr`` is not a Many-to-Many relationship.
|
|
471
|
+
"""
|
|
472
|
+
prop = _m2m_prop(rel_attr)
|
|
473
|
+
secondary = cast(Table, prop.secondary)
|
|
474
|
+
assert secondary is not None # guaranteed by _m2m_prop
|
|
475
|
+
|
|
476
|
+
parent_where = [
|
|
477
|
+
assoc_col == getattr(instance, cast(str, parent_col.key))
|
|
478
|
+
for parent_col, assoc_col in prop.synchronize_pairs
|
|
479
|
+
]
|
|
480
|
+
await session.execute(delete(secondary).where(*parent_where))
|
|
481
|
+
|
|
482
|
+
if related:
|
|
483
|
+
await m2m_add(session, instance, rel_attr, *related)
|
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
from .enum import LoadStrategy
|
|
4
4
|
from .registry import Context, FixtureRegistry
|
|
5
|
-
from .utils import
|
|
5
|
+
from .utils import (
|
|
6
|
+
get_field_by_attr,
|
|
7
|
+
get_obj_by_attr,
|
|
8
|
+
load_fixtures,
|
|
9
|
+
load_fixtures_by_context,
|
|
10
|
+
)
|
|
6
11
|
|
|
7
12
|
__all__ = [
|
|
8
13
|
"Context",
|
|
9
14
|
"FixtureRegistry",
|
|
10
15
|
"LoadStrategy",
|
|
16
|
+
"get_field_by_attr",
|
|
11
17
|
"get_obj_by_attr",
|
|
12
18
|
"load_fixtures",
|
|
13
19
|
"load_fixtures_by_context",
|
|
@@ -250,6 +250,31 @@ def get_obj_by_attr(
|
|
|
250
250
|
) from None
|
|
251
251
|
|
|
252
252
|
|
|
253
|
+
def get_field_by_attr(
|
|
254
|
+
fixtures: Callable[[], Sequence[ModelType]],
|
|
255
|
+
attr_name: str,
|
|
256
|
+
value: Any,
|
|
257
|
+
*,
|
|
258
|
+
field: str = "id",
|
|
259
|
+
) -> Any:
|
|
260
|
+
"""Get a single field value from a fixture object matched by an attribute.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
fixtures: A fixture function registered via ``@registry.register``
|
|
264
|
+
that returns a sequence of SQLAlchemy model instances.
|
|
265
|
+
attr_name: Name of the attribute to match against.
|
|
266
|
+
value: Value to match.
|
|
267
|
+
field: Attribute name to return from the matched object (default: ``"id"``).
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
The value of ``field`` on the first matching model instance.
|
|
271
|
+
|
|
272
|
+
Raises:
|
|
273
|
+
StopIteration: If no matching object is found in the fixture group.
|
|
274
|
+
"""
|
|
275
|
+
return getattr(get_obj_by_attr(fixtures, attr_name, value), field)
|
|
276
|
+
|
|
277
|
+
|
|
253
278
|
async def load_fixtures(
|
|
254
279
|
session: AsyncSession,
|
|
255
280
|
registry: FixtureRegistry,
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
"""Pytest plugin for using FixtureRegistry fixtures in tests."""
|
|
2
2
|
|
|
3
3
|
from collections.abc import Callable, Sequence
|
|
4
|
-
from typing import Any
|
|
4
|
+
from typing import Any, cast
|
|
5
5
|
|
|
6
6
|
import pytest
|
|
7
|
+
from sqlalchemy import select
|
|
7
8
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
8
|
-
from sqlalchemy.orm import DeclarativeBase
|
|
9
|
+
from sqlalchemy.orm import DeclarativeBase, selectinload
|
|
10
|
+
from sqlalchemy.orm.interfaces import ExecutableOption, ORMOption
|
|
9
11
|
|
|
10
12
|
from ..db import get_transaction
|
|
11
13
|
from ..fixtures import FixtureRegistry, LoadStrategy
|
|
@@ -112,7 +114,7 @@ def _create_fixture_function(
|
|
|
112
114
|
elif strategy == LoadStrategy.MERGE:
|
|
113
115
|
merged = await session.merge(instance)
|
|
114
116
|
loaded.append(merged)
|
|
115
|
-
elif strategy == LoadStrategy.SKIP_EXISTING:
|
|
117
|
+
elif strategy == LoadStrategy.SKIP_EXISTING: # pragma: no branch
|
|
116
118
|
pk = _get_primary_key(instance)
|
|
117
119
|
if pk is not None:
|
|
118
120
|
existing = await session.get(type(instance), pk)
|
|
@@ -125,6 +127,11 @@ def _create_fixture_function(
|
|
|
125
127
|
session.add(instance)
|
|
126
128
|
loaded.append(instance)
|
|
127
129
|
|
|
130
|
+
if loaded: # pragma: no branch
|
|
131
|
+
load_options = _relationship_load_options(type(loaded[0]))
|
|
132
|
+
if load_options:
|
|
133
|
+
return await _reload_with_relationships(session, loaded, load_options)
|
|
134
|
+
|
|
128
135
|
return loaded
|
|
129
136
|
|
|
130
137
|
# Update function signature to include dependencies
|
|
@@ -141,6 +148,54 @@ def _create_fixture_function(
|
|
|
141
148
|
return created_func
|
|
142
149
|
|
|
143
150
|
|
|
151
|
+
def _relationship_load_options(model: type[DeclarativeBase]) -> list[ExecutableOption]:
|
|
152
|
+
"""Build selectinload options for all direct relationships on a model."""
|
|
153
|
+
return [
|
|
154
|
+
selectinload(getattr(model, rel.key)) for rel in model.__mapper__.relationships
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def _reload_with_relationships(
|
|
159
|
+
session: AsyncSession,
|
|
160
|
+
instances: list[DeclarativeBase],
|
|
161
|
+
load_options: list[ExecutableOption],
|
|
162
|
+
) -> list[DeclarativeBase]:
|
|
163
|
+
"""Reload instances in a single bulk query with relationship eager-loading.
|
|
164
|
+
|
|
165
|
+
Uses one SELECT … WHERE pk IN (…) so selectinload can batch all relationship
|
|
166
|
+
queries — 1 + N_relationships round-trips regardless of how many instances
|
|
167
|
+
there are, instead of one session.get() per instance.
|
|
168
|
+
|
|
169
|
+
Preserves the original insertion order.
|
|
170
|
+
"""
|
|
171
|
+
model = type(instances[0])
|
|
172
|
+
mapper = model.__mapper__
|
|
173
|
+
pk_cols = mapper.primary_key
|
|
174
|
+
|
|
175
|
+
if len(pk_cols) == 1:
|
|
176
|
+
pk_attr = getattr(model, pk_cols[0].key)
|
|
177
|
+
pks = [getattr(inst, pk_cols[0].key) for inst in instances]
|
|
178
|
+
result = await session.execute(
|
|
179
|
+
select(model).where(pk_attr.in_(pks)).options(*load_options)
|
|
180
|
+
)
|
|
181
|
+
by_pk = {getattr(row, pk_cols[0].key): row for row in result.unique().scalars()}
|
|
182
|
+
return [by_pk[pk] for pk in pks]
|
|
183
|
+
|
|
184
|
+
# Composite PK: fall back to per-instance reload
|
|
185
|
+
reloaded: list[DeclarativeBase] = []
|
|
186
|
+
for instance in instances:
|
|
187
|
+
pk = _get_primary_key(instance)
|
|
188
|
+
refreshed = await session.get(
|
|
189
|
+
model,
|
|
190
|
+
pk,
|
|
191
|
+
options=cast(list[ORMOption], load_options),
|
|
192
|
+
populate_existing=True,
|
|
193
|
+
)
|
|
194
|
+
if refreshed is not None: # pragma: no branch
|
|
195
|
+
reloaded.append(refreshed)
|
|
196
|
+
return reloaded
|
|
197
|
+
|
|
198
|
+
|
|
144
199
|
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
|
145
200
|
"""Get the primary key value of a model instance."""
|
|
146
201
|
mapper = instance.__class__.__mapper__
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/cli/commands/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/cli/commands/fixtures.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/exceptions/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/exceptions/exceptions.py
RENAMED
|
File without changes
|
{fastapi_toolsets-3.0.3 → fastapi_toolsets-3.1.0}/src/fastapi_toolsets/exceptions/handler.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|