Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions docs/module/fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,18 +147,26 @@ Fixtures with the same name are allowed as long as their context sets do not ove

## 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
from fastapi_toolsets.fixtures import get_obj_by_attr

@fixtures.register(depends_on=["roles"])
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)]
```

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

Expand Down
3 changes: 0 additions & 3 deletions docs/reference/fixtures.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ from fastapi_toolsets.fixtures import (
FixtureRegistry,
load_fixtures,
load_fixtures_by_context,
get_obj_by_attr,
)
```

Expand All @@ -27,5 +26,3 @@ from fastapi_toolsets.fixtures import (
## ::: fastapi_toolsets.fixtures.utils.load_fixtures

## ::: fastapi_toolsets.fixtures.utils.load_fixtures_by_context

## ::: fastapi_toolsets.fixtures.utils.get_obj_by_attr
29 changes: 15 additions & 14 deletions src/fastapi_toolsets/cli/commands/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from rich.table import Table

from ...fixtures import Context, LoadStrategy, load_fixtures_by_context
from ...logger import get_logger
from ..config import get_db_context, get_fixtures_registry
from ..utils import async_command

Expand All @@ -16,6 +17,7 @@
no_args_is_help=True,
)
console = Console()
logger = get_logger()


@fixture_cli.command("list")
Expand All @@ -32,10 +34,10 @@ def list_fixtures(
) -> None:
"""List all registered fixtures."""
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:
print("No fixtures found.")
logger.info("No fixtures found.")
return

table = Table("Name", "Contexts", "Dependencies")
Expand All @@ -46,7 +48,7 @@ def list_fixtures(
table.add_row(fixture.name, contexts, deps)

console.print(table)
print(f"\nTotal: {len(fixtures)} fixture(s)")
logger.info("Total: %d fixture(s)", len(fixtures))


@fixture_cli.command("load")
Expand All @@ -72,23 +74,22 @@ async def load(
registry = get_fixtures_registry()
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)

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

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:
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

async with db_context() as session:
Expand All @@ -97,4 +98,4 @@ async def load(
)

total = sum(len(items) for items in result.values())
print(f"\nLoaded {total} record(s) successfully.")
logger.info("Loaded %d record(s) successfully.", total)
53 changes: 32 additions & 21 deletions src/fastapi_toolsets/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import importlib
import sys
from typing import TYPE_CHECKING, Any, Literal, overload
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload

import typer

Expand All @@ -13,6 +13,8 @@
if TYPE_CHECKING:
from ..fixtures import FixtureRegistry

T = TypeVar("T")


def _ensure_project_in_path():
"""Add project root to sys.path if not installed in editable mode."""
Expand Down Expand Up @@ -88,19 +90,39 @@ def get_config_value(key: str, required: bool = False) -> Any | None:
return value


def get_fixtures_registry() -> FixtureRegistry:
"""Import and return the fixtures registry from config."""
from ..fixtures import FixtureRegistry
@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.

import_path = get_config_value("fixtures", required=True)
registry = import_from_string(import_path)
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

if not isinstance(registry, FixtureRegistry):
obj = import_from_string(import_path)
if not isinstance(obj, expected_type):
raise typer.BadParameter(
f"'fixtures' must be a FixtureRegistry instance, got {type(registry).__name__}"
f"'{key}' must be a {expected_type.__name__} instance, got {type(obj).__name__}"
)

return registry
return obj


def get_fixtures_registry() -> FixtureRegistry:
"""Import and return the fixtures registry from config."""
from ..fixtures import FixtureRegistry

return _import_typed("fixtures", FixtureRegistry, required=True)


def get_db_context() -> Any:
Expand All @@ -111,15 +133,4 @@ def get_db_context() -> Any:

def get_custom_cli() -> typer.Typer | None:
"""Import and return the custom CLI Typer instance from config."""
import_path = get_config_value("custom_cli")
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
return _import_typed("custom_cli", typer.Typer, required=False)
10 changes: 1 addition & 9 deletions src/fastapi_toolsets/fixtures/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,12 @@

from .enum import LoadStrategy
from .registry import Context, FixtureRegistry
from .utils import (
get_field_by_attr,
get_obj_by_attr,
load_fixtures,
load_fixtures_by_context,
)
from .utils import load_fixtures, load_fixtures_by_context

__all__ = [
"Context",
"FixtureRegistry",
"LoadStrategy",
"get_field_by_attr",
"get_obj_by_attr",
"load_fixtures",
"load_fixtures_by_context",
"register_fixtures",
]
104 changes: 76 additions & 28 deletions src/fastapi_toolsets/fixtures/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,8 @@

from sqlalchemy.orm import DeclarativeBase

from ..logger import get_logger
from .enum import Context

logger = get_logger()


def _normalize_contexts(
contexts: list[str | Enum] | tuple[str | Enum, ...],
Expand Down Expand Up @@ -189,9 +186,7 @@ def get(self, name: str) -> Fixture:
ValueError: If the fixture has multiple context variants — use
:meth:`get_variants` in that case.
"""
if name not in self._fixtures:
raise KeyError(f"Fixture '{name}' not found")
variants = self._fixtures[name]
variants = self.get_variants(name)
if len(variants) > 1:
raise ValueError(
f"Fixture '{name}' has {len(variants)} context variants. "
Expand Down Expand Up @@ -223,10 +218,83 @@ def get_variants(self, name: str, *contexts: str | Enum) -> list[Fixture]:
context_values = set(_normalize_contexts(contexts))
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]:
"""Get all registered fixtures (all variants of all names)."""
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]:
"""Get fixtures for specific contexts."""
context_values = set(_normalize_contexts(contexts))
Expand Down Expand Up @@ -254,7 +322,6 @@ def resolve_dependencies(self, *names: str) -> list[str]:
ValueError: If circular dependency detected
"""
resolved: list[str] = []
seen: set[str] = set()
visiting: set[str] = set()

def visit(name: str) -> None:
Expand All @@ -264,25 +331,11 @@ def visit(name: str) -> None:
raise ValueError(f"Circular dependency detected: {name}")

visiting.add(name)
variants = self._fixtures.get(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:
for dep in self.get_dependencies(name):
visit(dep)

visiting.remove(name)
resolved.append(name)
seen.add(name)

for name in names:
visit(name)
Expand All @@ -303,9 +356,4 @@ def resolve_context_dependencies(self, *contexts: str | Enum) -> list[str]:
# appear multiple times if it has variants in different contexts).
names = list(dict.fromkeys(f.name for f in context_fixtures))

all_deps: set[str] = set()
for name in names:
deps = self.resolve_dependencies(name)
all_deps.update(deps)

return self.resolve_dependencies(*all_deps)
return self.resolve_dependencies(*names)
Loading