Rework fixtures module (#333)

* feat: fixture refresh DB-generated values onto returned instances

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

* refactor: log fixture command output instead of print

* chore: clean up fixture module
This commit is contained in:
d3vyce
2026-07-01 19:24:00 +02:00
committed by GitHub
parent 70e0b3b9d5
commit fe2c0f3eff
10 changed files with 359 additions and 295 deletions
+13 -5
View File
@@ -147,18 +147,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
-3
View File
@@ -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
+15 -14
View File
@@ -7,6 +7,7 @@ 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, load_fixtures_by_context
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,6 +17,7 @@ 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")
@@ -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")
@@ -72,23 +74,22 @@ async def load(
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]
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 +98,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)
+33 -22
View File
@@ -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 -9
View File
@@ -2,20 +2,12 @@
from .enum import LoadStrategy from .enum import LoadStrategy
from .registry import Context, FixtureRegistry from .registry import Context, FixtureRegistry
from .utils import ( from .utils import load_fixtures, load_fixtures_by_context
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",
"load_fixtures", "load_fixtures",
"load_fixtures_by_context", "load_fixtures_by_context",
"register_fixtures",
] ]
+76 -28
View File
@@ -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, ...],
@@ -189,9 +186,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. "
@@ -223,10 +218,83 @@ class FixtureRegistry:
context_values = set(_normalize_contexts(contexts)) context_values = set(_normalize_contexts(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 = set(_normalize_contexts(contexts))
@@ -254,7 +322,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 +331,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 +356,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)
+118 -83
View File
@@ -1,17 +1,18 @@
"""Fixture loading utilities for database seeding.""" """Fixture loading utilities for database seeding."""
from collections.abc import Callable, Sequence from collections.abc import Iterator
from enum import Enum from enum import Enum
from typing import Any from typing import Any, cast
from sqlalchemy import Table, select
from sqlalchemy import inspect as sa_inspect from sqlalchemy import inspect as sa_inspect
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase from sqlalchemy.orm import DeclarativeBase, selectinload
from sqlalchemy.orm.interfaces import ExecutableOption, ORMOption
from ..db import transaction from ..db import transaction
from ..logger import get_logger from ..logger import get_logger
from ..types import ModelType
from .enum import LoadStrategy from .enum import LoadStrategy
from .registry import FixtureRegistry, _normalize_contexts from .registry import FixtureRegistry, _normalize_contexts
@@ -93,17 +94,42 @@ def _group_by_column_set(
return list(groups.values()) return list(groups.values())
def _grouped_table_dicts(
model_cls: type[DeclarativeBase], instances: list[DeclarativeBase]
) -> Iterator[
tuple[type[DeclarativeBase], list[dict[str, Any]], list[DeclarativeBase]]
]:
"""Yield (cls, group_dicts, group_instances) per table in the inheritance
chain and per column-set group, skipping empty groups.
"""
for cls in _get_table_chain(model_cls):
dicts = [_instance_to_dict_for_cls(i, cls) for i in instances]
for group_dicts, group_instances in _group_by_column_set(dicts, instances):
if group_dicts and group_dicts[0]: # pragma: no branch
yield cls, group_dicts, group_instances
async def _batch_insert( async def _batch_insert(
session: AsyncSession, session: AsyncSession,
model_cls: type[DeclarativeBase], model_cls: type[DeclarativeBase],
instances: list[DeclarativeBase], instances: list[DeclarativeBase],
) -> None: ) -> None:
"""INSERT all instances raises on conflict (no duplicate handling).""" """INSERT all instances, raises on conflict."""
for cls in _get_table_chain(model_cls): for cls, group_dicts, group_instances in _grouped_table_dicts(model_cls, instances):
dicts = [_instance_to_dict_for_cls(i, cls) for i in instances] table = cast(Table, cls.__table__)
for group_dicts, _ in _group_by_column_set(dicts, instances): missing_pk_cols = [
if group_dicts and group_dicts[0]: # pragma: no branch col for col in table.primary_key.columns if col.key not in group_dicts[0]
await session.execute(pg_insert(cls).values(group_dicts)) ]
if not missing_pk_cols:
await session.execute(pg_insert(table), group_dicts)
continue
stmt = pg_insert(table).returning(
*missing_pk_cols, sort_by_parameter_order=True
)
result = await session.execute(stmt, group_dicts)
for inst, row in zip(group_instances, result):
for col, val in zip(missing_pk_cols, row):
setattr(inst, col.key, val)
async def _batch_merge( async def _batch_merge(
@@ -112,30 +138,26 @@ 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] pk_names = [col.name for col in cls.__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 cls.__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(cls).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]
if update_cols: if update_cols:
stmt = stmt.on_conflict_do_update( stmt = stmt.on_conflict_do_update(
index_elements=pk_names, index_elements=pk_names,
set_={col: stmt.excluded[col] for col in update_cols}, set_={col: stmt.excluded[col] for col in update_cols},
) )
else: else:
stmt = stmt.on_conflict_do_nothing(index_elements=pk_names) stmt = stmt.on_conflict_do_nothing(index_elements=pk_names)
await session.execute(stmt) await session.execute(stmt)
async def _batch_skip_existing( async def _batch_skip_existing(
@@ -169,8 +191,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, 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 +224,64 @@ 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_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
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 +294,11 @@ async def _load_ordered(
for name in ordered_names: for name in ordered_names:
variants = ( variants = (
registry.get_variants(name, *contexts) registry.get_load_variants(name, *contexts)
if contexts is not None if contexts is not None
else registry.get_variants(name) else registry.get_variants(name)
) )
if contexts is not None and not variants:
variants = registry.get_variants(name)
if not variants: # pragma: no cover if not variants: # pragma: no cover
results[name] = [] results[name] = []
continue continue
@@ -244,8 +327,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 +349,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,
+27 -109
View File
@@ -1,16 +1,14 @@
"""Pytest plugin for using FixtureRegistry fixtures in tests.""" """Pytest plugin for using FixtureRegistry fixtures in tests."""
from collections.abc import Callable, Sequence from collections.abc import Sequence
from typing import Any, cast from typing import Any
import pytest import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import DeclarativeBase, selectinload from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.interfaces import ExecutableOption, ORMOption
from ..db import transaction
from ..fixtures import FixtureRegistry, LoadStrategy from ..fixtures import FixtureRegistry, LoadStrategy
from ..fixtures.utils import _get_primary_key, _load_ordered, _refresh_loaded
def register_fixtures( def register_fixtures(
@@ -57,7 +55,7 @@ def register_fixtures(
# Build list of pytest fixture dependencies # Build list of pytest fixture dependencies
pytest_deps = [session_fixture] pytest_deps = [session_fixture]
for dep in fixture.depends_on: for dep in registry.get_dependencies(fixture.name):
pytest_deps.append(f"{prefix}{dep}") pytest_deps.append(f"{prefix}{dep}")
# Create the fixture function # Create the fixture function
@@ -83,56 +81,38 @@ def _create_fixture_function(
fixture_name: str, fixture_name: str,
dependencies: list[str], dependencies: list[str],
strategy: LoadStrategy, strategy: LoadStrategy,
) -> Callable[..., Any]: ) -> Any:
"""Create a fixture function with the correct signature. """Create a fixture function with the correct signature.
The function signature must include all dependencies as parameters The function signature must include all dependencies as parameters
for pytest to resolve them correctly. for pytest (and pytest-anyio's fixture chaining) to resolve them
correctly — dynamic resolution via ``request.getfixturevalue`` deadlocks
when called from inside an already-running async fixture.
""" """
# Get the fixture definition
fixture_def = registry.get(fixture_name) fixture_def = registry.get(fixture_name)
# Build the function dynamically with correct parameters
# We need the session as first param, then all dependencies
async def fixture_func(**kwargs: Any) -> Sequence[DeclarativeBase]: async def fixture_func(**kwargs: Any) -> Sequence[DeclarativeBase]:
# Get session from kwargs (first dependency)
session: AsyncSession = kwargs[dependencies[0]] session: AsyncSession = kwargs[dependencies[0]]
result = (await _load_ordered(session, registry, [fixture_name], strategy))[
fixture_name
]
# Load the fixture data if strategy is LoadStrategy.SKIP_EXISTING:
instances = list(fixture_def.func()) # _load_ordered only returns newly-inserted rows for this
# strategy (the CLI seeding contract). A test fixture should
# still hand back the full, usable set including rows that
# were already present, so top up with those.
declared = list(fixture_def.func())
result_pks = {_get_primary_key(r) for r in result}
missing = [
d
for d in declared
if (pk := _get_primary_key(d)) is not None and pk not in result_pks
]
if missing:
result = result + await _refresh_loaded(session, missing)
if not instances: return result
return []
loaded: list[DeclarativeBase] = []
async with transaction(session):
for instance in instances:
if strategy == LoadStrategy.INSERT:
session.add(instance)
loaded.append(instance)
elif strategy == LoadStrategy.MERGE:
merged = await session.merge(instance)
loaded.append(merged)
elif strategy == LoadStrategy.SKIP_EXISTING: # pragma: no branch
pk = _get_primary_key(instance)
if pk is not None:
existing = await session.get(type(instance), pk)
if existing is None:
session.add(instance)
loaded.append(instance)
else:
loaded.append(existing)
else:
session.add(instance)
loaded.append(instance)
if loaded: # pragma: no branch
load_options = _relationship_load_options(type(loaded[0]))
if load_options:
return await _reload_with_relationships(session, loaded, load_options)
return loaded
# Update function signature to include dependencies # Update function signature to include dependencies
# This is needed for pytest to inject the right fixtures # This is needed for pytest to inject the right fixtures
@@ -146,65 +126,3 @@ def _create_fixture_function(
created_func.__doc__ = f"Load {fixture_name} fixture data." created_func.__doc__ = f"Load {fixture_name} fixture data."
return created_func return created_func
def _relationship_load_options(model: type[DeclarativeBase]) -> list[ExecutableOption]:
"""Build selectinload options for all direct relationships on a model."""
return [
selectinload(getattr(model, rel.key)) for rel in model.__mapper__.relationships
]
async def _reload_with_relationships(
session: AsyncSession,
instances: list[DeclarativeBase],
load_options: list[ExecutableOption],
) -> list[DeclarativeBase]:
"""Reload instances in a single bulk query with relationship eager-loading.
Uses one SELECT … WHERE pk IN (…) so selectinload can batch all relationship
queries — 1 + N_relationships round-trips regardless of how many instances
there are, instead of one session.get() per instance.
Preserves the original insertion order.
"""
model = type(instances[0])
mapper = model.__mapper__
pk_cols = mapper.primary_key
if len(pk_cols) == 1:
pk_attr = getattr(model, pk_cols[0].key)
pks = [getattr(inst, pk_cols[0].key) for inst in instances]
result = await session.execute(
select(model).where(pk_attr.in_(pks)).options(*load_options)
)
by_pk = {getattr(row, pk_cols[0].key): row for row in result.unique().scalars()}
return [by_pk[pk] for pk in pks]
# Composite PK: fall back to per-instance reload
reloaded: list[DeclarativeBase] = []
for instance in instances:
pk = _get_primary_key(instance)
refreshed = await session.get(
model,
pk,
options=cast(list[ORMOption], load_options),
populate_existing=True,
)
if refreshed is not None: # pragma: no branch
reloaded.append(refreshed)
return reloaded
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
"""Get the primary key value of a model instance."""
mapper = instance.__class__.__mapper__
pk_cols = mapper.primary_key
if len(pk_cols) == 1:
return getattr(instance, pk_cols[0].name, None)
pk_values = tuple(getattr(instance, col.name, None) for col in pk_cols)
if all(v is not None for v in pk_values):
return pk_values
return None
+75 -21
View File
@@ -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,
) )
@@ -812,6 +811,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 +929,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 +972,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 +994,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 +1040,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:
+1 -1
View File
@@ -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,