fastapi-toolsets 5.0.0b1__tar.gz → 5.0.0b2__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.
Files changed (53) hide show
  1. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/PKG-INFO +1 -1
  2. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/pyproject.toml +1 -1
  3. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/__init__.py +1 -1
  4. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/commands/fixtures.py +20 -17
  5. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/config.py +32 -21
  6. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/utils.py +2 -1
  7. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/db/core.py +17 -9
  8. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/db/watch.py +44 -28
  9. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/fixtures/__init__.py +28 -0
  10. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/fixtures/registry.py +86 -34
  11. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/fixtures/utils.py +120 -85
  12. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/models/watched.py +7 -2
  13. fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/pytest/plugin.py +128 -0
  14. fastapi_toolsets-5.0.0b1/src/fastapi_toolsets/fixtures/__init__.py +0 -21
  15. fastapi_toolsets-5.0.0b1/src/fastapi_toolsets/pytest/plugin.py +0 -210
  16. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/LICENSE +0 -0
  17. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/README.md +0 -0
  18. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/_imports.py +0 -0
  19. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/__init__.py +0 -0
  20. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/app.py +0 -0
  21. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
  22. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/pyproject.py +0 -0
  23. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/crud/__init__.py +0 -0
  24. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/crud/factory.py +0 -0
  25. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/crud/search.py +0 -0
  26. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/db/__init__.py +0 -0
  27. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/db/locks.py +0 -0
  28. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/db/m2m.py +0 -0
  29. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/db/testing.py +0 -0
  30. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/dependencies.py +0 -0
  31. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
  32. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
  33. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/exceptions/handler.py +0 -0
  34. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/fixtures/enum.py +0 -0
  35. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/logger.py +0 -0
  36. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/metrics/__init__.py +0 -0
  37. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/metrics/handler.py +0 -0
  38. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/metrics/registry.py +0 -0
  39. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/models/__init__.py +0 -0
  40. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/models/columns.py +0 -0
  41. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/py.typed +0 -0
  42. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/pytest/__init__.py +0 -0
  43. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/pytest/utils.py +0 -0
  44. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/schemas.py +0 -0
  45. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/__init__.py +0 -0
  46. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/abc.py +0 -0
  47. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/oauth.py +0 -0
  48. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/__init__.py +0 -0
  49. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/bearer.py +0 -0
  50. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/cookie.py +0 -0
  51. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/header.py +0 -0
  52. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/multi.py +0 -0
  53. {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fastapi-toolsets
3
- Version: 5.0.0b1
3
+ Version: 5.0.0b2
4
4
  Summary: Production-ready utilities for FastAPI applications
5
5
  Keywords: fastapi,sqlalchemy,postgresql
6
6
  Author: d3vyce
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-toolsets"
3
- version = "5.0.0b1"
3
+ version = "5.0.0b2"
4
4
  description = "Production-ready utilities for FastAPI applications"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -24,4 +24,4 @@ Example usage:
24
24
  return Response(data={"user": user.username}, message="Success")
25
25
  """
26
26
 
27
- __version__ = "5.0.0b1"
27
+ __version__ = "5.0.0b2"
@@ -6,7 +6,8 @@ import typer
6
6
  from rich.console import Console
7
7
  from rich.table import Table
8
8
 
9
- from ...fixtures import Context, LoadStrategy, load_fixtures_by_context
9
+ from ...fixtures import Context, LoadStrategy
10
+ from ...logger import get_logger
10
11
  from ..config import get_db_context, get_fixtures_registry
11
12
  from ..utils import async_command
12
13
 
@@ -16,13 +17,14 @@ fixture_cli = typer.Typer(
16
17
  no_args_is_help=True,
17
18
  )
18
19
  console = Console()
20
+ logger = get_logger()
19
21
 
20
22
 
21
23
  @fixture_cli.command("list")
22
24
  def list_fixtures(
23
25
  ctx: typer.Context,
24
26
  context: Annotated[
25
- Context | None,
27
+ str | None,
26
28
  typer.Option(
27
29
  "--context",
28
30
  "-c",
@@ -32,10 +34,10 @@ def list_fixtures(
32
34
  ) -> None:
33
35
  """List all registered fixtures."""
34
36
  registry = get_fixtures_registry()
35
- fixtures = registry.get_by_context(context.value) if context else registry.get_all()
37
+ fixtures = registry.get_by_context(context) if context else registry.get_all()
36
38
 
37
39
  if not fixtures:
38
- print("No fixtures found.")
40
+ logger.info("No fixtures found.")
39
41
  return
40
42
 
41
43
  table = Table("Name", "Contexts", "Dependencies")
@@ -46,7 +48,7 @@ def list_fixtures(
46
48
  table.add_row(fixture.name, contexts, deps)
47
49
 
48
50
  console.print(table)
49
- print(f"\nTotal: {len(fixtures)} fixture(s)")
51
+ logger.info("Total: %d fixture(s)", len(fixtures))
50
52
 
51
53
 
52
54
  @fixture_cli.command("load")
@@ -54,7 +56,7 @@ def list_fixtures(
54
56
  async def load(
55
57
  ctx: typer.Context,
56
58
  contexts: Annotated[
57
- list[Context] | None,
59
+ list[str] | None,
58
60
  typer.Argument(help="Contexts to load."),
59
61
  ] = None,
60
62
  strategy: Annotated[
@@ -69,26 +71,27 @@ async def load(
69
71
  ] = False,
70
72
  ) -> None:
71
73
  """Load fixtures into the database."""
74
+ from ...fixtures import load_fixtures_by_context
75
+
72
76
  registry = get_fixtures_registry()
73
77
  db_context = get_db_context()
74
78
 
75
- context_list = list(contexts) if contexts else [Context.BASE]
79
+ context_list = contexts or [Context.BASE.value]
76
80
 
77
81
  ordered = registry.resolve_context_dependencies(*context_list)
78
82
 
79
83
  if not ordered:
80
- print("No fixtures to load for the specified context(s).")
84
+ logger.info("No fixtures to load for the specified context(s).")
81
85
  return
82
86
 
83
- print(f"\nFixtures to load ({strategy.value} strategy):")
84
- for name in ordered:
85
- fixture = registry.get(name)
86
- instances = list(fixture.func())
87
- model_name = type(instances[0]).__name__ if instances else "?"
88
- print(f" - {name}: {len(instances)} {model_name}(s)")
89
-
90
87
  if dry_run:
91
- print("\n[Dry run - no changes made]")
88
+ logger.info("Fixtures to load (%s strategy):", strategy.value)
89
+ for name in ordered:
90
+ variants = registry.get_load_variants(name, *context_list)
91
+ instances = [inst for v in variants for inst in v.func()]
92
+ model_name = type(instances[0]).__name__ if instances else "?"
93
+ logger.info(" - %s: %d %s(s)", name, len(instances), model_name)
94
+ logger.info("[Dry run - no changes made]")
92
95
  return
93
96
 
94
97
  async with db_context() as session:
@@ -97,4 +100,4 @@ async def load(
97
100
  )
98
101
 
99
102
  total = sum(len(items) for items in result.values())
100
- print(f"\nLoaded {total} record(s) successfully.")
103
+ logger.info("Loaded %d record(s) successfully.", total)
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import importlib
6
6
  import sys
7
- from typing import TYPE_CHECKING, Any, Literal, overload
7
+ from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
8
8
 
9
9
  import typer
10
10
 
@@ -13,6 +13,8 @@ from .pyproject import find_pyproject, load_pyproject
13
13
  if TYPE_CHECKING:
14
14
  from ..fixtures import FixtureRegistry
15
15
 
16
+ T = TypeVar("T")
17
+
16
18
 
17
19
  def _ensure_project_in_path():
18
20
  """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:
88
90
  return value
89
91
 
90
92
 
91
- def get_fixtures_registry() -> FixtureRegistry:
92
- """Import and return the fixtures registry from config."""
93
- from ..fixtures import FixtureRegistry
93
+ @overload
94
+ def _import_typed(
95
+ key: str, expected_type: type[T], *, required: Literal[True]
96
+ ) -> T: ... # pragma: no cover
97
+ @overload
98
+ def _import_typed(
99
+ key: str, expected_type: type[T], *, required: bool
100
+ ) -> T | None: ... # pragma: no cover
101
+ def _import_typed(key: str, expected_type: type[T], *, required: bool) -> T | None:
102
+ """Import a config value by key and validate its type.
94
103
 
95
- import_path = get_config_value("fixtures", required=True)
96
- registry = import_from_string(import_path)
104
+ Raises:
105
+ typer.BadParameter: If required and missing, or if the imported
106
+ value isn't an instance of *expected_type*.
107
+ """
108
+ import_path = get_config_value(key, required=required)
109
+ if not import_path:
110
+ return None
97
111
 
98
- if not isinstance(registry, FixtureRegistry):
112
+ obj = import_from_string(import_path)
113
+ if not isinstance(obj, expected_type):
99
114
  raise typer.BadParameter(
100
- f"'fixtures' must be a FixtureRegistry instance, got {type(registry).__name__}"
115
+ f"'{key}' must be a {expected_type.__name__} instance, got {type(obj).__name__}"
101
116
  )
102
117
 
103
- return registry
118
+ return obj
119
+
120
+
121
+ def get_fixtures_registry() -> FixtureRegistry:
122
+ """Import and return the fixtures registry from config."""
123
+ from ..fixtures import FixtureRegistry
124
+
125
+ return _import_typed("fixtures", FixtureRegistry, required=True)
104
126
 
105
127
 
106
128
  def get_db_context() -> Any:
@@ -111,15 +133,4 @@ def get_db_context() -> Any:
111
133
 
112
134
  def get_custom_cli() -> typer.Typer | None:
113
135
  """Import and return the custom CLI Typer instance from config."""
114
- import_path = get_config_value("custom_cli")
115
- if not import_path:
116
- return None
117
-
118
- custom = import_from_string(import_path)
119
-
120
- if not isinstance(custom, typer.Typer):
121
- raise typer.BadParameter(
122
- f"'custom_cli' must be a Typer instance, got {type(custom).__name__}"
123
- )
124
-
125
- return custom
136
+ return _import_typed("custom_cli", typer.Typer, required=False)
@@ -1,6 +1,5 @@
1
1
  """CLI utility functions."""
2
2
 
3
- import asyncio
4
3
  import functools
5
4
  from collections.abc import Callable, Coroutine
6
5
  from typing import Any, ParamSpec, TypeVar
@@ -24,6 +23,8 @@ def async_command(func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]:
24
23
 
25
24
  @functools.wraps(func)
26
25
  def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
26
+ import asyncio
27
+
27
28
  return asyncio.run(func(*args, **kwargs))
28
29
 
29
30
  return wrapper
@@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator
4
4
  from contextlib import AbstractAsyncContextManager, asynccontextmanager
5
5
  from typing import Any
6
6
 
7
+ from pydantic import PostgresDsn
7
8
  from sqlalchemy import exc as sa_exc
8
9
  from sqlalchemy.ext.asyncio import (
9
10
  AsyncEngine,
@@ -84,18 +85,20 @@ class Database:
84
85
  untouched).
85
86
 
86
87
  Args:
87
- url: Database connection URL (e.g. ``"postgresql+asyncpg://..."``).
88
+ url: Database connection URL. Accepts a plain string or a Pydantic
89
+ :class:`~pydantic.PostgresDsn`.
88
90
  engine: An existing :class:`AsyncEngine` to reuse instead of *url*.
89
91
  session_class: Session class for the sessionmaker (e.g. ``EventSession``).
90
92
  expire_on_commit: Expire attributes after commit. Defaults to ``False``.
91
93
  autoflush: Autoflush the session before queries. Defaults to ``True``.
94
+ connect_args: DBAPI-level connection arguments forwarded to
95
+ :func:`create_async_engine` (URL mode only).
92
96
  **engine_options: Extra keyword arguments forwarded to
93
- :func:`create_async_engine` (URL mode only, e.g. ``pool_size``,
94
- ``echo``, ``connect_args``).
97
+ :func:`create_async_engine` (URL mode only).
95
98
 
96
99
  Raises:
97
100
  TypeError: If neither or both of *url* and *engine* are given, or if
98
- *engine_options* are passed together with *engine*.
101
+ *connect_args*/*engine_options* are passed together with *engine*.
99
102
 
100
103
  Example:
101
104
  ```python
@@ -115,12 +118,13 @@ class Database:
115
118
 
116
119
  def __init__(
117
120
  self,
118
- url: str | None = None,
121
+ url: str | PostgresDsn | None = None,
119
122
  *,
120
123
  engine: AsyncEngine | None = None,
121
124
  session_class: type[AsyncSession] = AsyncSession,
122
125
  expire_on_commit: bool = False,
123
126
  autoflush: bool = True,
127
+ connect_args: dict[str, Any] | None = None,
124
128
  **engine_options: Any,
125
129
  ) -> None:
126
130
  if (url is None) == (engine is None):
@@ -128,10 +132,10 @@ class Database:
128
132
  "Database requires exactly one of 'url' or 'engine' "
129
133
  "(got both or neither)."
130
134
  )
131
- if engine is not None and engine_options:
135
+ if engine is not None and (engine_options or connect_args is not None):
132
136
  raise TypeError(
133
- "engine_options are only valid in URL mode; configure the "
134
- "engine you pass via 'engine=' yourself."
137
+ "connect_args/engine_options are only valid in URL mode; "
138
+ "configure the engine you pass via 'engine=' yourself."
135
139
  )
136
140
 
137
141
  if engine is not None:
@@ -140,7 +144,11 @@ class Database:
140
144
  else:
141
145
  assert url is not None # guaranteed by the XOR check above
142
146
  self._owns_engine = True
143
- self.engine = create_async_engine(url, **engine_options)
147
+ if connect_args is not None:
148
+ engine_options["connect_args"] = connect_args
149
+ # ``PostgresDsn`` (and other URL objects) are not str subclasses, so
150
+ # coerce to the string form SQLAlchemy expects.
151
+ self.engine = create_async_engine(str(url), **engine_options)
144
152
  self._sessionmaker: async_sessionmaker[AsyncSession] = async_sessionmaker(
145
153
  self.engine,
146
154
  class_=session_class,
@@ -57,34 +57,50 @@ async def wait_for_row_change(
57
57
  )
58
58
  ```
59
59
  """
60
- instance = await session.get(model, pk_value)
61
- if instance is None:
62
- raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
63
-
64
- if columns is not None:
65
- watch_cols = columns
66
- else:
67
- watch_cols = [attr.key for attr in model.__mapper__.column_attrs]
68
-
69
- initial = {col: getattr(instance, col) for col in watch_cols}
70
-
71
- elapsed = 0.0
72
- while True:
73
- await asyncio.sleep(interval)
74
- elapsed += interval
75
-
76
- if timeout is not None and elapsed >= timeout:
77
- raise TimeoutError(
78
- f"No change detected on {model.__name__} "
79
- f"with pk={pk_value!r} within {timeout}s"
80
- )
60
+ bind = getattr(session, "bind", None)
61
+ if bind is None:
62
+ raise TypeError(
63
+ "wait_for_row_change requires a session bound to an engine "
64
+ "(session.bind is None)"
65
+ )
66
+ watcher = AsyncSession(bind=bind)
67
+ try:
81
68
 
82
- session.expunge(instance)
83
- instance = await session.get(model, pk_value)
69
+ async def _reload() -> _M | None:
70
+ await watcher.rollback()
71
+ return await watcher.get(model, pk_value, populate_existing=True)
84
72
 
73
+ instance = await _reload()
85
74
  if instance is None:
86
- raise NotFoundError(f"{model.__name__} with pk={pk_value!r} was deleted")
87
-
88
- current = {col: getattr(instance, col) for col in watch_cols}
89
- if current != initial:
90
- return instance
75
+ raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
76
+
77
+ if columns is not None:
78
+ watch_cols = columns
79
+ else:
80
+ watch_cols = [attr.key for attr in model.__mapper__.column_attrs]
81
+
82
+ initial = {col: getattr(instance, col) for col in watch_cols}
83
+
84
+ elapsed = 0.0
85
+ while True:
86
+ await asyncio.sleep(interval)
87
+ elapsed += interval
88
+
89
+ if timeout is not None and elapsed >= timeout:
90
+ raise TimeoutError(
91
+ f"No change detected on {model.__name__} "
92
+ f"with pk={pk_value!r} within {timeout}s"
93
+ )
94
+
95
+ instance = await _reload()
96
+
97
+ if instance is None:
98
+ raise NotFoundError(
99
+ f"{model.__name__} with pk={pk_value!r} was deleted"
100
+ )
101
+
102
+ current = {col: getattr(instance, col) for col in watch_cols}
103
+ if current != initial:
104
+ return instance
105
+ finally:
106
+ await watcher.close()
@@ -0,0 +1,28 @@
1
+ """Fixture system for seeding databases with dependency resolution."""
2
+
3
+ from .enum import Context, LoadStrategy
4
+
5
+ __all__ = [
6
+ "Context",
7
+ "FixtureRegistry",
8
+ "LoadStrategy",
9
+ "load_fixtures",
10
+ "load_fixtures_by_context",
11
+ ]
12
+
13
+ _LAZY = {
14
+ "FixtureRegistry": ".registry",
15
+ "load_fixtures": ".utils",
16
+ "load_fixtures_by_context": ".utils",
17
+ }
18
+
19
+
20
+ def __getattr__(name: str):
21
+ module_name = _LAZY.get(name)
22
+ if module_name is None:
23
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
24
+
25
+ import importlib
26
+
27
+ module = importlib.import_module(module_name, __name__)
28
+ return getattr(module, name)
@@ -7,11 +7,8 @@ from typing import Any, cast
7
7
 
8
8
  from sqlalchemy.orm import DeclarativeBase
9
9
 
10
- from ..logger import get_logger
11
10
  from .enum import Context
12
11
 
13
- logger = get_logger()
14
-
15
12
 
16
13
  def _normalize_contexts(
17
14
  contexts: list[str | Enum] | tuple[str | Enum, ...],
@@ -20,6 +17,11 @@ def _normalize_contexts(
20
17
  return [c.value if isinstance(c, Enum) else c for c in contexts]
21
18
 
22
19
 
20
+ def _context_filter_values(contexts: tuple[str | Enum, ...]) -> set[str]:
21
+ """Normalize *contexts* for filtering, always including Context.BASE."""
22
+ return set(_normalize_contexts(contexts)) | {Context.BASE.value}
23
+
24
+
23
25
  @dataclass
24
26
  class Fixture:
25
27
  """A fixture definition with metadata."""
@@ -70,8 +72,6 @@ class FixtureRegistry:
70
72
  @fixtures.register(contexts=[Context.TESTING])
71
73
  def users():
72
74
  return [User(id=2, username="tester")]
73
- # load_fixtures_by_context(..., Context.BASE, Context.TESTING)
74
- # → loads both User(admin) and User(tester) under the "users" name
75
75
  ```
76
76
  """
77
77
 
@@ -189,9 +189,7 @@ class FixtureRegistry:
189
189
  ValueError: If the fixture has multiple context variants — use
190
190
  :meth:`get_variants` in that case.
191
191
  """
192
- if name not in self._fixtures:
193
- raise KeyError(f"Fixture '{name}' not found")
194
- variants = self._fixtures[name]
192
+ variants = self.get_variants(name)
195
193
  if len(variants) > 1:
196
194
  raise ValueError(
197
195
  f"Fixture '{name}' has {len(variants)} context variants. "
@@ -205,8 +203,9 @@ class FixtureRegistry:
205
203
  Args:
206
204
  name: Fixture name.
207
205
  *contexts: If given, only return variants whose context set
208
- intersects with these values. Both :class:`Context` enum
209
- values and plain strings are accepted.
206
+ intersects with these values (:class:`Context.BASE` variants
207
+ are always included). Both :class:`Context` enum values and
208
+ plain strings are accepted.
210
209
 
211
210
  Returns:
212
211
  List of matching :class:`Fixture` objects (may be empty when a
@@ -220,16 +219,89 @@ class FixtureRegistry:
220
219
  variants = self._fixtures[name]
221
220
  if not contexts:
222
221
  return list(variants)
223
- context_values = set(_normalize_contexts(contexts))
222
+ context_values = _context_filter_values(contexts)
224
223
  return [v for v in variants if set(v.contexts) & context_values]
225
224
 
225
+ def get_load_variants(self, name: str, *contexts: str | Enum) -> list[Fixture]:
226
+ """Return variants for *name* filtered by *contexts*.
227
+
228
+ Raises:
229
+ KeyError: If no fixture with *name* is registered.
230
+ """
231
+ variants = self.get_variants(name, *contexts)
232
+ if contexts and not variants:
233
+ return self.get_variants(name)
234
+ return variants
235
+
226
236
  def get_all(self) -> list[Fixture]:
227
237
  """Get all registered fixtures (all variants of all names)."""
228
238
  return [f for variants in self._fixtures.values() for f in variants]
229
239
 
240
+ def get_dependencies(self, name: str) -> list[str]:
241
+ """Get the union of ``depends_on`` across all variants of *name*.
242
+
243
+ Raises:
244
+ KeyError: If no fixture named *name* is registered.
245
+ """
246
+ variants = self._fixtures.get(name)
247
+ if variants is None:
248
+ raise KeyError(f"Fixture '{name}' not found")
249
+
250
+ seen: set[str] = set()
251
+ deps: list[str] = []
252
+ for variant in variants:
253
+ for dep in variant.depends_on:
254
+ if dep not in seen:
255
+ deps.append(dep)
256
+ seen.add(dep)
257
+ return deps
258
+
259
+ def obj(self, name: str, attr_name: str, value: Any) -> DeclarativeBase:
260
+ """Get a model instance from a registered fixture by attribute value.
261
+
262
+ Args:
263
+ name: Fixture name to look up.
264
+ attr_name: Name of the attribute to match against.
265
+ value: Value to match.
266
+
267
+ Returns:
268
+ The first model instance where the attribute matches the given value.
269
+
270
+ Raises:
271
+ KeyError: If no fixture named *name* is registered.
272
+ StopIteration: If no matching object is found.
273
+ """
274
+ instances = (
275
+ obj for variant in self.get_variants(name) for obj in variant.func()
276
+ )
277
+ try:
278
+ return next(obj for obj in instances if getattr(obj, attr_name) == value)
279
+ except StopIteration:
280
+ raise StopIteration(
281
+ f"No object with {attr_name}={value} found in fixture '{name}'"
282
+ ) from None
283
+
284
+ def field(self, name: str, attr_name: str, value: Any, *, field: str = "id") -> Any:
285
+ """Get a single field value from a fixture object matched by an attribute.
286
+
287
+ Args:
288
+ name: Fixture name to look up.
289
+ attr_name: Name of the attribute to match against.
290
+ value: Value to match.
291
+ field: Attribute name to return from the matched object (default: ``"id"``).
292
+
293
+ Returns:
294
+ The value of ``field`` on the first matching model instance.
295
+
296
+ Raises:
297
+ KeyError: If no fixture named *name* is registered.
298
+ StopIteration: If no matching object is found.
299
+ """
300
+ return getattr(self.obj(name, attr_name, value), field)
301
+
230
302
  def get_by_context(self, *contexts: str | Enum) -> list[Fixture]:
231
303
  """Get fixtures for specific contexts."""
232
- context_values = set(_normalize_contexts(contexts))
304
+ context_values = _context_filter_values(contexts)
233
305
  return [
234
306
  f
235
307
  for variants in self._fixtures.values()
@@ -254,7 +326,6 @@ class FixtureRegistry:
254
326
  ValueError: If circular dependency detected
255
327
  """
256
328
  resolved: list[str] = []
257
- seen: set[str] = set()
258
329
  visiting: set[str] = set()
259
330
 
260
331
  def visit(name: str) -> None:
@@ -264,25 +335,11 @@ class FixtureRegistry:
264
335
  raise ValueError(f"Circular dependency detected: {name}")
265
336
 
266
337
  visiting.add(name)
267
- variants = self._fixtures.get(name)
268
- if variants is None:
269
- raise KeyError(f"Fixture '{name}' not found")
270
-
271
- # Union of depends_on across all variants, preserving first-seen order.
272
- seen_deps: set[str] = set()
273
- all_deps: list[str] = []
274
- for variant in variants:
275
- for dep in variant.depends_on:
276
- if dep not in seen_deps:
277
- all_deps.append(dep)
278
- seen_deps.add(dep)
279
-
280
- for dep in all_deps:
338
+ for dep in self.get_dependencies(name):
281
339
  visit(dep)
282
340
 
283
341
  visiting.remove(name)
284
342
  resolved.append(name)
285
- seen.add(name)
286
343
 
287
344
  for name in names:
288
345
  visit(name)
@@ -303,9 +360,4 @@ class FixtureRegistry:
303
360
  # appear multiple times if it has variants in different contexts).
304
361
  names = list(dict.fromkeys(f.name for f in context_fixtures))
305
362
 
306
- all_deps: set[str] = set()
307
- for name in names:
308
- deps = self.resolve_dependencies(name)
309
- all_deps.update(deps)
310
-
311
- return self.resolve_dependencies(*all_deps)
363
+ return self.resolve_dependencies(*names)