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.
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/PKG-INFO +1 -1
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/pyproject.toml +1 -1
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/__init__.py +1 -1
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/commands/fixtures.py +20 -17
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/config.py +32 -21
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/utils.py +2 -1
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/db/core.py +17 -9
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/db/watch.py +44 -28
- fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/fixtures/__init__.py +28 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/fixtures/registry.py +86 -34
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/fixtures/utils.py +120 -85
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/models/watched.py +7 -2
- fastapi_toolsets-5.0.0b2/src/fastapi_toolsets/pytest/plugin.py +128 -0
- fastapi_toolsets-5.0.0b1/src/fastapi_toolsets/fixtures/__init__.py +0 -21
- fastapi_toolsets-5.0.0b1/src/fastapi_toolsets/pytest/plugin.py +0 -210
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/LICENSE +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/README.md +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/_imports.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/__init__.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/app.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/pyproject.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/crud/__init__.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/crud/factory.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/crud/search.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/db/__init__.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/db/locks.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/db/m2m.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/db/testing.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/dependencies.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/exceptions/handler.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/fixtures/enum.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/logger.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/metrics/__init__.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/metrics/handler.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/metrics/registry.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/models/__init__.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/models/columns.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/py.typed +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/pytest/__init__.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/pytest/utils.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/schemas.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/__init__.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/abc.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/oauth.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/__init__.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/bearer.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/cookie.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/header.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/security/sources/multi.py +0 -0
- {fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/types.py +0 -0
{fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/cli/commands/fixtures.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
37
|
+
fixtures = registry.get_by_context(context) if context else registry.get_all()
|
|
36
38
|
|
|
37
39
|
if not fixtures:
|
|
38
|
-
|
|
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
|
-
|
|
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[
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
112
|
+
obj = import_from_string(import_path)
|
|
113
|
+
if not isinstance(obj, expected_type):
|
|
99
114
|
raise typer.BadParameter(
|
|
100
|
-
f"'
|
|
115
|
+
f"'{key}' must be a {expected_type.__name__} instance, got {type(obj).__name__}"
|
|
101
116
|
)
|
|
102
117
|
|
|
103
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
if
|
|
62
|
-
raise
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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)
|
{fastapi_toolsets-5.0.0b1 → fastapi_toolsets-5.0.0b2}/src/fastapi_toolsets/fixtures/registry.py
RENAMED
|
@@ -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
|
-
|
|
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
|
|
209
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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)
|