fastapi-toolsets 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/PKG-INFO +1 -1
- {fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/pyproject.toml +1 -1
- {fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/__init__.py +1 -1
- fastapi_toolsets-0.3.0/src/fastapi_toolsets/fixtures/__init__.py +13 -0
- fastapi_toolsets-0.3.0/src/fastapi_toolsets/fixtures/enum.py +30 -0
- fastapi_toolsets-0.2.0/src/fastapi_toolsets/fixtures/fixtures.py → fastapi_toolsets-0.3.0/src/fastapi_toolsets/fixtures/registry.py +1 -147
- fastapi_toolsets-0.3.0/src/fastapi_toolsets/fixtures/utils.py +149 -0
- fastapi_toolsets-0.3.0/src/fastapi_toolsets/pytest/__init__.py +8 -0
- fastapi_toolsets-0.2.0/src/fastapi_toolsets/fixtures/pytest_plugin.py → fastapi_toolsets-0.3.0/src/fastapi_toolsets/pytest/plugin.py +1 -1
- fastapi_toolsets-0.3.0/src/fastapi_toolsets/pytest/utils.py +110 -0
- fastapi_toolsets-0.2.0/src/fastapi_toolsets/fixtures/__init__.py +0 -27
- fastapi_toolsets-0.2.0/src/fastapi_toolsets/fixtures/utils.py +0 -26
- {fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/LICENSE +0 -0
- {fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/README.md +0 -0
- {fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/cli/__init__.py +0 -0
- {fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/cli/app.py +0 -0
- {fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/cli/commands/__init__.py +0 -0
- {fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/cli/commands/fixtures.py +0 -0
- {fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/crud.py +0 -0
- {fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/db.py +0 -0
- {fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/exceptions/__init__.py +0 -0
- {fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/exceptions/exceptions.py +0 -0
- {fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/exceptions/handler.py +0 -0
- {fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/py.typed +0 -0
- {fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/schemas.py +0 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .enum import LoadStrategy
|
|
2
|
+
from .registry import Context, FixtureRegistry
|
|
3
|
+
from .utils import get_obj_by_attr, load_fixtures, load_fixtures_by_context
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"Context",
|
|
7
|
+
"FixtureRegistry",
|
|
8
|
+
"LoadStrategy",
|
|
9
|
+
"get_obj_by_attr",
|
|
10
|
+
"load_fixtures",
|
|
11
|
+
"load_fixtures_by_context",
|
|
12
|
+
"register_fixtures",
|
|
13
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class LoadStrategy(str, Enum):
|
|
5
|
+
"""Strategy for loading fixtures into the database."""
|
|
6
|
+
|
|
7
|
+
INSERT = "insert"
|
|
8
|
+
"""Insert new records. Fails if record already exists."""
|
|
9
|
+
|
|
10
|
+
MERGE = "merge"
|
|
11
|
+
"""Insert or update based on primary key (SQLAlchemy merge)."""
|
|
12
|
+
|
|
13
|
+
SKIP_EXISTING = "skip_existing"
|
|
14
|
+
"""Insert only if record doesn't exist (based on primary key)."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Context(str, Enum):
|
|
18
|
+
"""Predefined fixture contexts."""
|
|
19
|
+
|
|
20
|
+
BASE = "base"
|
|
21
|
+
"""Base fixtures loaded in all environments."""
|
|
22
|
+
|
|
23
|
+
PRODUCTION = "production"
|
|
24
|
+
"""Production-only fixtures."""
|
|
25
|
+
|
|
26
|
+
DEVELOPMENT = "development"
|
|
27
|
+
"""Development fixtures."""
|
|
28
|
+
|
|
29
|
+
TESTING = "testing"
|
|
30
|
+
"""Test fixtures."""
|
|
@@ -3,46 +3,15 @@
|
|
|
3
3
|
import logging
|
|
4
4
|
from collections.abc import Callable, Sequence
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
|
-
from enum import Enum
|
|
7
6
|
from typing import Any, cast
|
|
8
7
|
|
|
9
|
-
from sqlalchemy.ext.asyncio import AsyncSession
|
|
10
8
|
from sqlalchemy.orm import DeclarativeBase
|
|
11
9
|
|
|
12
|
-
from
|
|
10
|
+
from .enum import Context
|
|
13
11
|
|
|
14
12
|
logger = logging.getLogger(__name__)
|
|
15
13
|
|
|
16
14
|
|
|
17
|
-
class LoadStrategy(str, Enum):
|
|
18
|
-
"""Strategy for loading fixtures into the database."""
|
|
19
|
-
|
|
20
|
-
INSERT = "insert"
|
|
21
|
-
"""Insert new records. Fails if record already exists."""
|
|
22
|
-
|
|
23
|
-
MERGE = "merge"
|
|
24
|
-
"""Insert or update based on primary key (SQLAlchemy merge)."""
|
|
25
|
-
|
|
26
|
-
SKIP_EXISTING = "skip_existing"
|
|
27
|
-
"""Insert only if record doesn't exist (based on primary key)."""
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class Context(str, Enum):
|
|
31
|
-
"""Predefined fixture contexts."""
|
|
32
|
-
|
|
33
|
-
BASE = "base"
|
|
34
|
-
"""Base fixtures loaded in all environments."""
|
|
35
|
-
|
|
36
|
-
PRODUCTION = "production"
|
|
37
|
-
"""Production-only fixtures."""
|
|
38
|
-
|
|
39
|
-
DEVELOPMENT = "development"
|
|
40
|
-
"""Development fixtures."""
|
|
41
|
-
|
|
42
|
-
TESTING = "testing"
|
|
43
|
-
"""Test fixtures."""
|
|
44
|
-
|
|
45
|
-
|
|
46
15
|
@dataclass
|
|
47
16
|
class Fixture:
|
|
48
17
|
"""A fixture definition with metadata."""
|
|
@@ -204,118 +173,3 @@ class FixtureRegistry:
|
|
|
204
173
|
all_deps.update(deps)
|
|
205
174
|
|
|
206
175
|
return self.resolve_dependencies(*all_deps)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
async def load_fixtures(
|
|
210
|
-
session: AsyncSession,
|
|
211
|
-
registry: FixtureRegistry,
|
|
212
|
-
*names: str,
|
|
213
|
-
strategy: LoadStrategy = LoadStrategy.MERGE,
|
|
214
|
-
) -> dict[str, list[DeclarativeBase]]:
|
|
215
|
-
"""Load specific fixtures by name with dependencies.
|
|
216
|
-
|
|
217
|
-
Args:
|
|
218
|
-
session: Database session
|
|
219
|
-
registry: Fixture registry
|
|
220
|
-
*names: Fixture names to load (dependencies auto-resolved)
|
|
221
|
-
strategy: How to handle existing records
|
|
222
|
-
|
|
223
|
-
Returns:
|
|
224
|
-
Dict mapping fixture names to loaded instances
|
|
225
|
-
|
|
226
|
-
Example:
|
|
227
|
-
# Loads 'roles' first (dependency), then 'users'
|
|
228
|
-
result = await load_fixtures(session, fixtures, "users")
|
|
229
|
-
print(result["users"]) # [User(...), ...]
|
|
230
|
-
"""
|
|
231
|
-
ordered = registry.resolve_dependencies(*names)
|
|
232
|
-
return await _load_ordered(session, registry, ordered, strategy)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
async def load_fixtures_by_context(
|
|
236
|
-
session: AsyncSession,
|
|
237
|
-
registry: FixtureRegistry,
|
|
238
|
-
*contexts: str | Context,
|
|
239
|
-
strategy: LoadStrategy = LoadStrategy.MERGE,
|
|
240
|
-
) -> dict[str, list[DeclarativeBase]]:
|
|
241
|
-
"""Load all fixtures for specific contexts.
|
|
242
|
-
|
|
243
|
-
Args:
|
|
244
|
-
session: Database session
|
|
245
|
-
registry: Fixture registry
|
|
246
|
-
*contexts: Contexts to load (e.g., Context.BASE, Context.TESTING)
|
|
247
|
-
strategy: How to handle existing records
|
|
248
|
-
|
|
249
|
-
Returns:
|
|
250
|
-
Dict mapping fixture names to loaded instances
|
|
251
|
-
|
|
252
|
-
Example:
|
|
253
|
-
# Load base + testing fixtures
|
|
254
|
-
await load_fixtures_by_context(
|
|
255
|
-
session, fixtures,
|
|
256
|
-
Context.BASE, Context.TESTING
|
|
257
|
-
)
|
|
258
|
-
"""
|
|
259
|
-
ordered = registry.resolve_context_dependencies(*contexts)
|
|
260
|
-
return await _load_ordered(session, registry, ordered, strategy)
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
async def _load_ordered(
|
|
264
|
-
session: AsyncSession,
|
|
265
|
-
registry: FixtureRegistry,
|
|
266
|
-
ordered_names: list[str],
|
|
267
|
-
strategy: LoadStrategy,
|
|
268
|
-
) -> dict[str, list[DeclarativeBase]]:
|
|
269
|
-
"""Load fixtures in order."""
|
|
270
|
-
results: dict[str, list[DeclarativeBase]] = {}
|
|
271
|
-
|
|
272
|
-
for name in ordered_names:
|
|
273
|
-
fixture = registry.get(name)
|
|
274
|
-
instances = list(fixture.func())
|
|
275
|
-
|
|
276
|
-
if not instances:
|
|
277
|
-
results[name] = []
|
|
278
|
-
continue
|
|
279
|
-
|
|
280
|
-
model_name = type(instances[0]).__name__
|
|
281
|
-
loaded: list[DeclarativeBase] = []
|
|
282
|
-
|
|
283
|
-
async with get_transaction(session):
|
|
284
|
-
for instance in instances:
|
|
285
|
-
if strategy == LoadStrategy.INSERT:
|
|
286
|
-
session.add(instance)
|
|
287
|
-
loaded.append(instance)
|
|
288
|
-
|
|
289
|
-
elif strategy == LoadStrategy.MERGE:
|
|
290
|
-
merged = await session.merge(instance)
|
|
291
|
-
loaded.append(merged)
|
|
292
|
-
|
|
293
|
-
elif strategy == LoadStrategy.SKIP_EXISTING:
|
|
294
|
-
pk = _get_primary_key(instance)
|
|
295
|
-
if pk is not None:
|
|
296
|
-
existing = await session.get(type(instance), pk)
|
|
297
|
-
if existing is None:
|
|
298
|
-
session.add(instance)
|
|
299
|
-
loaded.append(instance)
|
|
300
|
-
else:
|
|
301
|
-
session.add(instance)
|
|
302
|
-
loaded.append(instance)
|
|
303
|
-
|
|
304
|
-
results[name] = loaded
|
|
305
|
-
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
|
306
|
-
|
|
307
|
-
return results
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
|
311
|
-
"""Get the primary key value of a model instance."""
|
|
312
|
-
mapper = instance.__class__.__mapper__
|
|
313
|
-
pk_cols = mapper.primary_key
|
|
314
|
-
|
|
315
|
-
if len(pk_cols) == 1:
|
|
316
|
-
return getattr(instance, pk_cols[0].name, None)
|
|
317
|
-
|
|
318
|
-
pk_values = tuple(getattr(instance, col.name, None) for col in pk_cols)
|
|
319
|
-
if all(v is not None for v in pk_values):
|
|
320
|
-
return pk_values
|
|
321
|
-
return None
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Callable, Sequence
|
|
3
|
+
from typing import Any, TypeVar
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
6
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
7
|
+
|
|
8
|
+
from ..db import get_transaction
|
|
9
|
+
from .enum import LoadStrategy
|
|
10
|
+
from .registry import Context, FixtureRegistry
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T", bound=DeclarativeBase)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_obj_by_attr(
|
|
18
|
+
fixtures: Callable[[], Sequence[T]], attr_name: str, value: Any
|
|
19
|
+
) -> T:
|
|
20
|
+
"""Get a SQLAlchemy model instance by matching an attribute value.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
fixtures: A fixture function registered via ``@registry.register``
|
|
24
|
+
that returns a sequence of SQLAlchemy model instances.
|
|
25
|
+
attr_name: Name of the attribute to match against.
|
|
26
|
+
value: Value to match.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The first model instance where the attribute matches the given value.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
StopIteration: If no matching object is found.
|
|
33
|
+
"""
|
|
34
|
+
return next(obj for obj in fixtures() if getattr(obj, attr_name) == value)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def load_fixtures(
|
|
38
|
+
session: AsyncSession,
|
|
39
|
+
registry: FixtureRegistry,
|
|
40
|
+
*names: str,
|
|
41
|
+
strategy: LoadStrategy = LoadStrategy.MERGE,
|
|
42
|
+
) -> dict[str, list[DeclarativeBase]]:
|
|
43
|
+
"""Load specific fixtures by name with dependencies.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
session: Database session
|
|
47
|
+
registry: Fixture registry
|
|
48
|
+
*names: Fixture names to load (dependencies auto-resolved)
|
|
49
|
+
strategy: How to handle existing records
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Dict mapping fixture names to loaded instances
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
# Loads 'roles' first (dependency), then 'users'
|
|
56
|
+
result = await load_fixtures(session, fixtures, "users")
|
|
57
|
+
print(result["users"]) # [User(...), ...]
|
|
58
|
+
"""
|
|
59
|
+
ordered = registry.resolve_dependencies(*names)
|
|
60
|
+
return await _load_ordered(session, registry, ordered, strategy)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def load_fixtures_by_context(
|
|
64
|
+
session: AsyncSession,
|
|
65
|
+
registry: FixtureRegistry,
|
|
66
|
+
*contexts: str | Context,
|
|
67
|
+
strategy: LoadStrategy = LoadStrategy.MERGE,
|
|
68
|
+
) -> dict[str, list[DeclarativeBase]]:
|
|
69
|
+
"""Load all fixtures for specific contexts.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
session: Database session
|
|
73
|
+
registry: Fixture registry
|
|
74
|
+
*contexts: Contexts to load (e.g., Context.BASE, Context.TESTING)
|
|
75
|
+
strategy: How to handle existing records
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Dict mapping fixture names to loaded instances
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
# Load base + testing fixtures
|
|
82
|
+
await load_fixtures_by_context(
|
|
83
|
+
session, fixtures,
|
|
84
|
+
Context.BASE, Context.TESTING
|
|
85
|
+
)
|
|
86
|
+
"""
|
|
87
|
+
ordered = registry.resolve_context_dependencies(*contexts)
|
|
88
|
+
return await _load_ordered(session, registry, ordered, strategy)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _load_ordered(
|
|
92
|
+
session: AsyncSession,
|
|
93
|
+
registry: FixtureRegistry,
|
|
94
|
+
ordered_names: list[str],
|
|
95
|
+
strategy: LoadStrategy,
|
|
96
|
+
) -> dict[str, list[DeclarativeBase]]:
|
|
97
|
+
"""Load fixtures in order."""
|
|
98
|
+
results: dict[str, list[DeclarativeBase]] = {}
|
|
99
|
+
|
|
100
|
+
for name in ordered_names:
|
|
101
|
+
fixture = registry.get(name)
|
|
102
|
+
instances = list(fixture.func())
|
|
103
|
+
|
|
104
|
+
if not instances:
|
|
105
|
+
results[name] = []
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
model_name = type(instances[0]).__name__
|
|
109
|
+
loaded: list[DeclarativeBase] = []
|
|
110
|
+
|
|
111
|
+
async with get_transaction(session):
|
|
112
|
+
for instance in instances:
|
|
113
|
+
if strategy == LoadStrategy.INSERT:
|
|
114
|
+
session.add(instance)
|
|
115
|
+
loaded.append(instance)
|
|
116
|
+
|
|
117
|
+
elif strategy == LoadStrategy.MERGE:
|
|
118
|
+
merged = await session.merge(instance)
|
|
119
|
+
loaded.append(merged)
|
|
120
|
+
|
|
121
|
+
elif strategy == LoadStrategy.SKIP_EXISTING:
|
|
122
|
+
pk = _get_primary_key(instance)
|
|
123
|
+
if pk is not None:
|
|
124
|
+
existing = await session.get(type(instance), pk)
|
|
125
|
+
if existing is None:
|
|
126
|
+
session.add(instance)
|
|
127
|
+
loaded.append(instance)
|
|
128
|
+
else:
|
|
129
|
+
session.add(instance)
|
|
130
|
+
loaded.append(instance)
|
|
131
|
+
|
|
132
|
+
results[name] = loaded
|
|
133
|
+
logger.info(f"Loaded fixture '{name}': {len(loaded)} {model_name}(s)")
|
|
134
|
+
|
|
135
|
+
return results
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _get_primary_key(instance: DeclarativeBase) -> Any | None:
|
|
139
|
+
"""Get the primary key value of a model instance."""
|
|
140
|
+
mapper = instance.__class__.__mapper__
|
|
141
|
+
pk_cols = mapper.primary_key
|
|
142
|
+
|
|
143
|
+
if len(pk_cols) == 1:
|
|
144
|
+
return getattr(instance, pk_cols[0].name, None)
|
|
145
|
+
|
|
146
|
+
pk_values = tuple(getattr(instance, col.name, None) for col in pk_cols)
|
|
147
|
+
if all(v is not None for v in pk_values):
|
|
148
|
+
return pk_values
|
|
149
|
+
return None
|
|
@@ -59,7 +59,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
59
59
|
from sqlalchemy.orm import DeclarativeBase
|
|
60
60
|
|
|
61
61
|
from ..db import get_transaction
|
|
62
|
-
from
|
|
62
|
+
from ..fixtures import FixtureRegistry, LoadStrategy
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
def register_fixtures(
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Pytest helper utilities for FastAPI testing."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from httpx import ASGITransport, AsyncClient
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
9
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
10
|
+
|
|
11
|
+
from ..db import create_db_context
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@asynccontextmanager
|
|
15
|
+
async def create_async_client(
|
|
16
|
+
app: Any,
|
|
17
|
+
base_url: str = "http://test",
|
|
18
|
+
) -> AsyncGenerator[AsyncClient, None]:
|
|
19
|
+
"""Create an async httpx client for testing FastAPI applications.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
app: FastAPI application instance.
|
|
23
|
+
base_url: Base URL for requests. Defaults to "http://test".
|
|
24
|
+
|
|
25
|
+
Yields:
|
|
26
|
+
An AsyncClient configured for the app.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
```python
|
|
30
|
+
from fastapi import FastAPI
|
|
31
|
+
from fastapi_toolsets.pytest import create_async_client
|
|
32
|
+
|
|
33
|
+
app = FastAPI()
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
async def client():
|
|
37
|
+
async with create_async_client(app) as c:
|
|
38
|
+
yield c
|
|
39
|
+
|
|
40
|
+
async def test_endpoint(client: AsyncClient):
|
|
41
|
+
response = await client.get("/health")
|
|
42
|
+
assert response.status_code == 200
|
|
43
|
+
```
|
|
44
|
+
"""
|
|
45
|
+
transport = ASGITransport(app=app)
|
|
46
|
+
async with AsyncClient(transport=transport, base_url=base_url) as client:
|
|
47
|
+
yield client
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@asynccontextmanager
|
|
51
|
+
async def create_db_session(
|
|
52
|
+
database_url: str,
|
|
53
|
+
base: type[DeclarativeBase],
|
|
54
|
+
*,
|
|
55
|
+
echo: bool = False,
|
|
56
|
+
expire_on_commit: bool = False,
|
|
57
|
+
drop_tables: bool = True,
|
|
58
|
+
) -> AsyncGenerator[AsyncSession, None]:
|
|
59
|
+
"""Create a database session for testing.
|
|
60
|
+
|
|
61
|
+
Creates tables before yielding the session and optionally drops them after.
|
|
62
|
+
Each call creates a fresh engine and session for test isolation.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
database_url: Database connection URL (e.g., "postgresql+asyncpg://...").
|
|
66
|
+
base: SQLAlchemy DeclarativeBase class containing model metadata.
|
|
67
|
+
echo: Enable SQLAlchemy query logging. Defaults to False.
|
|
68
|
+
expire_on_commit: Expire objects after commit. Defaults to False.
|
|
69
|
+
drop_tables: Drop tables after test. Defaults to True.
|
|
70
|
+
|
|
71
|
+
Yields:
|
|
72
|
+
An AsyncSession ready for database operations.
|
|
73
|
+
|
|
74
|
+
Example:
|
|
75
|
+
```python
|
|
76
|
+
from fastapi_toolsets.pytest import create_db_session
|
|
77
|
+
from app.models import Base
|
|
78
|
+
|
|
79
|
+
DATABASE_URL = "postgresql+asyncpg://user:pass@localhost/test_db"
|
|
80
|
+
|
|
81
|
+
@pytest.fixture
|
|
82
|
+
async def db_session():
|
|
83
|
+
async with create_db_session(DATABASE_URL, Base) as session:
|
|
84
|
+
yield session
|
|
85
|
+
|
|
86
|
+
async def test_create_user(db_session: AsyncSession):
|
|
87
|
+
user = User(name="test")
|
|
88
|
+
db_session.add(user)
|
|
89
|
+
await db_session.commit()
|
|
90
|
+
```
|
|
91
|
+
"""
|
|
92
|
+
engine = create_async_engine(database_url, echo=echo)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
# Create tables
|
|
96
|
+
async with engine.begin() as conn:
|
|
97
|
+
await conn.run_sync(base.metadata.create_all)
|
|
98
|
+
|
|
99
|
+
# Create session using existing db context utility
|
|
100
|
+
session_maker = async_sessionmaker(engine, expire_on_commit=expire_on_commit)
|
|
101
|
+
get_session = create_db_context(session_maker)
|
|
102
|
+
|
|
103
|
+
async with get_session() as session:
|
|
104
|
+
yield session
|
|
105
|
+
|
|
106
|
+
if drop_tables:
|
|
107
|
+
async with engine.begin() as conn:
|
|
108
|
+
await conn.run_sync(base.metadata.drop_all)
|
|
109
|
+
finally:
|
|
110
|
+
await engine.dispose()
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
from .fixtures import (
|
|
2
|
-
Context,
|
|
3
|
-
FixtureRegistry,
|
|
4
|
-
LoadStrategy,
|
|
5
|
-
load_fixtures,
|
|
6
|
-
load_fixtures_by_context,
|
|
7
|
-
)
|
|
8
|
-
from .utils import get_obj_by_attr
|
|
9
|
-
|
|
10
|
-
__all__ = [
|
|
11
|
-
"Context",
|
|
12
|
-
"FixtureRegistry",
|
|
13
|
-
"LoadStrategy",
|
|
14
|
-
"get_obj_by_attr",
|
|
15
|
-
"load_fixtures",
|
|
16
|
-
"load_fixtures_by_context",
|
|
17
|
-
"register_fixtures",
|
|
18
|
-
]
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
# We lazy-load register_fixtures to avoid needing pytest when using fixtures CLI
|
|
22
|
-
def __getattr__(name: str):
|
|
23
|
-
if name == "register_fixtures":
|
|
24
|
-
from .pytest_plugin import register_fixtures
|
|
25
|
-
|
|
26
|
-
return register_fixtures
|
|
27
|
-
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
from collections.abc import Callable, Sequence
|
|
2
|
-
from typing import Any, TypeVar
|
|
3
|
-
|
|
4
|
-
from sqlalchemy.orm import DeclarativeBase
|
|
5
|
-
|
|
6
|
-
T = TypeVar("T", bound=DeclarativeBase)
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def get_obj_by_attr(
|
|
10
|
-
fixtures: Callable[[], Sequence[T]], attr_name: str, value: Any
|
|
11
|
-
) -> T:
|
|
12
|
-
"""Get a SQLAlchemy model instance by matching an attribute value.
|
|
13
|
-
|
|
14
|
-
Args:
|
|
15
|
-
fixtures: A fixture function registered via ``@registry.register``
|
|
16
|
-
that returns a sequence of SQLAlchemy model instances.
|
|
17
|
-
attr_name: Name of the attribute to match against.
|
|
18
|
-
value: Value to match.
|
|
19
|
-
|
|
20
|
-
Returns:
|
|
21
|
-
The first model instance where the attribute matches the given value.
|
|
22
|
-
|
|
23
|
-
Raises:
|
|
24
|
-
StopIteration: If no matching object is found.
|
|
25
|
-
"""
|
|
26
|
-
return next(obj for obj in fixtures() if getattr(obj, attr_name) == value)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/cli/commands/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/cli/commands/fixtures.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/exceptions/__init__.py
RENAMED
|
File without changes
|
{fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/exceptions/exceptions.py
RENAMED
|
File without changes
|
{fastapi_toolsets-0.2.0 → fastapi_toolsets-0.3.0}/src/fastapi_toolsets/exceptions/handler.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|