mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-08-05 08:04:08 +00:00
Compare commits
4
Commits
44ba5bdd4b
...
v5.0.0b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06d49d5dc4
|
||
|
|
de06839a16 | ||
|
|
ff367e4281 | ||
|
|
9cb4c1474f |
+10
-2
@@ -65,6 +65,13 @@ Both functions return a `dict[str, list[...]]` mapping each fixture name to the
|
||||
|
||||
A fixture with no `contexts` defined takes `Context.BASE` by default.
|
||||
|
||||
`Context.BASE` fixtures are always included alongside whatever context you load or list — there's no way to load a non-base context in isolation:
|
||||
|
||||
```python
|
||||
# also loads any Context.BASE fixtures, even though only TESTING is requested
|
||||
await load_fixtures_by_context(session, fixtures, Context.TESTING)
|
||||
```
|
||||
|
||||
### Custom contexts
|
||||
|
||||
Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is expected.
|
||||
@@ -80,6 +87,7 @@ class AppContext(str, Enum):
|
||||
def staging_data():
|
||||
return [Config(key="feature_x", enabled=True)]
|
||||
|
||||
# loads staging_data plus any Context.BASE fixtures
|
||||
await load_fixtures_by_context(session, fixtures, AppContext.STAGING)
|
||||
```
|
||||
|
||||
@@ -108,8 +116,8 @@ def users():
|
||||
def users():
|
||||
return [User(id=2, username="tester")]
|
||||
|
||||
# loads both admin and tester
|
||||
await load_fixtures_by_context(session, fixtures, Context.BASE, Context.TESTING)
|
||||
# loads both admin and tester (Context.BASE is included automatically)
|
||||
await load_fixtures_by_context(session, fixtures, Context.TESTING)
|
||||
```
|
||||
|
||||
Registering two variants with overlapping context sets raises `ValueError`.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "fastapi-toolsets"
|
||||
version = "5.0.0b1"
|
||||
version = "5.0.0b2"
|
||||
description = "Production-ready utilities for FastAPI applications"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -24,4 +24,4 @@ Example usage:
|
||||
return Response(data={"user": user.username}, message="Success")
|
||||
"""
|
||||
|
||||
__version__ = "5.0.0b1"
|
||||
__version__ = "5.0.0b2"
|
||||
|
||||
@@ -24,7 +24,7 @@ logger = get_logger()
|
||||
def list_fixtures(
|
||||
ctx: typer.Context,
|
||||
context: Annotated[
|
||||
Context | None,
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--context",
|
||||
"-c",
|
||||
@@ -56,7 +56,7 @@ def list_fixtures(
|
||||
async def load(
|
||||
ctx: typer.Context,
|
||||
contexts: Annotated[
|
||||
list[Context] | None,
|
||||
list[str] | None,
|
||||
typer.Argument(help="Contexts to load."),
|
||||
] = None,
|
||||
strategy: Annotated[
|
||||
@@ -76,7 +76,7 @@ async def load(
|
||||
registry = get_fixtures_registry()
|
||||
db_context = get_db_context()
|
||||
|
||||
context_list = contexts or [Context.BASE]
|
||||
context_list = contexts or [Context.BASE.value]
|
||||
|
||||
ordered = registry.resolve_context_dependencies(*context_list)
|
||||
|
||||
|
||||
@@ -57,10 +57,18 @@ async def wait_for_row_change(
|
||||
)
|
||||
```
|
||||
"""
|
||||
bind = getattr(session, "bind", None)
|
||||
if bind is None:
|
||||
raise TypeError(
|
||||
"wait_for_row_change requires a session bound to an engine "
|
||||
"(session.bind is None)"
|
||||
)
|
||||
watcher = AsyncSession(bind=bind)
|
||||
try:
|
||||
|
||||
async def _reload() -> _M | None:
|
||||
await session.rollback()
|
||||
return await session.get(model, pk_value, populate_existing=True)
|
||||
await watcher.rollback()
|
||||
return await watcher.get(model, pk_value, populate_existing=True)
|
||||
|
||||
instance = await _reload()
|
||||
if instance is None:
|
||||
@@ -87,8 +95,12 @@ async def wait_for_row_change(
|
||||
instance = await _reload()
|
||||
|
||||
if instance is None:
|
||||
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} was deleted")
|
||||
raise NotFoundError(
|
||||
f"{model.__name__} with pk={pk_value!r} was deleted"
|
||||
)
|
||||
|
||||
current = {col: getattr(instance, col) for col in watch_cols}
|
||||
if current != initial:
|
||||
return instance
|
||||
finally:
|
||||
await watcher.close()
|
||||
|
||||
@@ -17,6 +17,11 @@ def _normalize_contexts(
|
||||
return [c.value if isinstance(c, Enum) else c for c in contexts]
|
||||
|
||||
|
||||
def _context_filter_values(contexts: tuple[str | Enum, ...]) -> set[str]:
|
||||
"""Normalize *contexts* for filtering, always including Context.BASE."""
|
||||
return set(_normalize_contexts(contexts)) | {Context.BASE.value}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Fixture:
|
||||
"""A fixture definition with metadata."""
|
||||
@@ -67,8 +72,6 @@ class FixtureRegistry:
|
||||
@fixtures.register(contexts=[Context.TESTING])
|
||||
def users():
|
||||
return [User(id=2, username="tester")]
|
||||
# load_fixtures_by_context(..., Context.BASE, Context.TESTING)
|
||||
# → loads both User(admin) and User(tester) under the "users" name
|
||||
```
|
||||
"""
|
||||
|
||||
@@ -200,8 +203,9 @@ class FixtureRegistry:
|
||||
Args:
|
||||
name: Fixture name.
|
||||
*contexts: If given, only return variants whose context set
|
||||
intersects with these values. Both :class:`Context` enum
|
||||
values and plain strings are accepted.
|
||||
intersects with these values (:class:`Context.BASE` variants
|
||||
are always included). Both :class:`Context` enum values and
|
||||
plain strings are accepted.
|
||||
|
||||
Returns:
|
||||
List of matching :class:`Fixture` objects (may be empty when a
|
||||
@@ -215,7 +219,7 @@ class FixtureRegistry:
|
||||
variants = self._fixtures[name]
|
||||
if not contexts:
|
||||
return list(variants)
|
||||
context_values = set(_normalize_contexts(contexts))
|
||||
context_values = _context_filter_values(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]:
|
||||
@@ -297,7 +301,7 @@ class FixtureRegistry:
|
||||
|
||||
def get_by_context(self, *contexts: str | Enum) -> list[Fixture]:
|
||||
"""Get fixtures for specific contexts."""
|
||||
context_values = set(_normalize_contexts(contexts))
|
||||
context_values = _context_filter_values(contexts)
|
||||
return [
|
||||
f
|
||||
for variants in self._fixtures.values()
|
||||
|
||||
@@ -383,8 +383,8 @@ async def load_fixtures_by_context(
|
||||
Args:
|
||||
session: Database session
|
||||
registry: Fixture registry
|
||||
*contexts: Contexts to load (e.g., ``Context.BASE``, ``Context.TESTING``,
|
||||
or plain strings for custom contexts)
|
||||
*contexts: Contexts to load (e.g., ``Context.TESTING``, or plain
|
||||
strings for custom contexts)
|
||||
strategy: How to handle existing records
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -204,6 +204,11 @@ async def _invoke_callback(
|
||||
await result
|
||||
|
||||
|
||||
async def _reload_if_present(session: AsyncSession, obj: Any, state: Any) -> None:
|
||||
"""Re-populate *obj* from the DB if its row still exists."""
|
||||
await session.get(type(obj), state.key[1], populate_existing=True)
|
||||
|
||||
|
||||
class EventSession(AsyncSession):
|
||||
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
|
||||
|
||||
@@ -253,7 +258,7 @@ class EventSession(AsyncSession):
|
||||
state is None or state.detached or state.transient
|
||||
): # pragma: no cover
|
||||
continue
|
||||
await self.refresh(obj)
|
||||
await _reload_if_present(self, obj, state)
|
||||
for handler in _get_handlers(type(obj), ModelEvent.CREATE):
|
||||
await _invoke_callback(handler, obj, ModelEvent.CREATE, None)
|
||||
except Exception as exc:
|
||||
@@ -277,7 +282,7 @@ class EventSession(AsyncSession):
|
||||
state is None or state.detached or state.transient
|
||||
): # pragma: no cover
|
||||
continue
|
||||
await self.refresh(obj)
|
||||
await _reload_if_present(self, obj, state)
|
||||
for handler in _get_handlers(type(obj), ModelEvent.UPDATE):
|
||||
await _invoke_callback(handler, obj, ModelEvent.UPDATE, changes)
|
||||
except Exception as exc:
|
||||
|
||||
+26
-1
@@ -277,6 +277,10 @@ class TestFixturesCli:
|
||||
'@registry.register(depends_on=["roles"], contexts=[Context.TESTING])\n'
|
||||
"def users():\n"
|
||||
' return [{"id": 1, "name": "alice", "role_id": 1}]\n'
|
||||
"\n"
|
||||
'@registry.register(contexts=["staging"])\n'
|
||||
"def staging_only():\n"
|
||||
' return [{"id": 3, "name": "staging-user"}]\n'
|
||||
)
|
||||
|
||||
# Create db module
|
||||
@@ -316,7 +320,7 @@ class TestFixturesCli:
|
||||
assert result.exit_code == 0
|
||||
assert "roles" in result.output
|
||||
assert "users" in result.output
|
||||
assert "Total: 2 fixture(s)" in result.output
|
||||
assert "Total: 3 fixture(s)" in result.output
|
||||
|
||||
def test_fixtures_list_with_context(self, cli_env):
|
||||
"""fixtures list --context filters by context."""
|
||||
@@ -338,6 +342,27 @@ class TestFixturesCli:
|
||||
assert "roles" in result.output
|
||||
assert "[Dry run - no changes made]" in result.output
|
||||
|
||||
def test_fixtures_list_with_custom_context(self, cli_env):
|
||||
"""fixtures list --context accepts contexts outside the Context enum, and
|
||||
always includes base fixtures alongside the requested context."""
|
||||
tmp_path, cli = cli_env
|
||||
result = runner.invoke(cli, ["fixtures", "list", "--context", "staging"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "staging_only" in result.output
|
||||
assert "roles" in result.output
|
||||
assert "Total: 2 fixture(s)" in result.output
|
||||
|
||||
def test_fixtures_load_custom_context_dry_run(self, cli_env):
|
||||
"""fixtures load accepts a custom context argument outside the Context enum,
|
||||
and always loads base fixtures alongside it."""
|
||||
tmp_path, cli = cli_env
|
||||
result = runner.invoke(cli, ["fixtures", "load", "staging", "--dry-run"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "staging_only" in result.output
|
||||
assert "roles" in result.output
|
||||
|
||||
def test_fixtures_load_invalid_strategy(self, cli_env):
|
||||
"""fixtures load with invalid strategy shows error."""
|
||||
tmp_path, cli = cli_env
|
||||
|
||||
@@ -689,6 +689,13 @@ class TestWaitForRowChange:
|
||||
with pytest.raises(NotFoundError, match="not found"):
|
||||
await wait_for_row_change(db_session, Role, fake_id, interval=0.05)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unbound_session_raises_type_error(self):
|
||||
"""Raises TypeError when the session has no bind to open a watcher on."""
|
||||
unbound = AsyncSession()
|
||||
with pytest.raises(TypeError, match="requires a session bound to an engine"):
|
||||
await wait_for_row_change(unbound, Role, uuid.uuid4())
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_timeout_raises(self, db_session: AsyncSession):
|
||||
"""Raises TimeoutError when no change is detected within timeout."""
|
||||
@@ -781,6 +788,43 @@ class TestWaitForRowChange:
|
||||
await wait_for_row_change(db_session, Role, role.id, interval=0.05)
|
||||
await delete_task
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_does_not_disturb_ambient_transaction(
|
||||
self, db_session: AsyncSession, engine
|
||||
):
|
||||
"""A read-only ambient transaction around the call survives untouched."""
|
||||
role = Role(name="ambient_role")
|
||||
db_session.add(role)
|
||||
await db_session.commit()
|
||||
|
||||
async def update_later():
|
||||
await asyncio.sleep(0.15)
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with factory() as other:
|
||||
r = await other.get(Role, role.id)
|
||||
assert r is not None
|
||||
r.name = "ambient_updated"
|
||||
await other.commit()
|
||||
|
||||
update_task = asyncio.create_task(update_later())
|
||||
async with transaction(db_session):
|
||||
# A read before the watch, establishing an ambient transaction
|
||||
# that must remain usable once wait_for_row_change returns.
|
||||
await db_session.get(Role, role.id)
|
||||
result = await wait_for_row_change(
|
||||
db_session, Role, role.id, interval=0.05, timeout=2.0
|
||||
)
|
||||
await update_task
|
||||
assert result.name == "ambient_updated"
|
||||
# The ambient transaction must still be open and usable here.
|
||||
assert db_session.in_transaction()
|
||||
other_role = Role(name="added_within_ambient_tx")
|
||||
db_session.add(other_role)
|
||||
|
||||
# transaction() committed cleanly on exit; the write above landed.
|
||||
check = await db_session.get(Role, other_role.id)
|
||||
assert check is not None
|
||||
|
||||
|
||||
class TestCreateDatabase:
|
||||
"""Tests for create_database."""
|
||||
|
||||
+28
-1
@@ -266,7 +266,34 @@ class TestFixtureRegistry:
|
||||
|
||||
testing_fixtures = registry.get_by_context(Context.TESTING)
|
||||
names = {f.name for f in testing_fixtures}
|
||||
assert names == {"test_data"}
|
||||
assert names == {"test_data", "base_data"}
|
||||
|
||||
def test_get_by_context_always_includes_base(self):
|
||||
"""Context.BASE fixtures load even for a fully custom context."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=[Context.BASE])
|
||||
def base_data():
|
||||
return []
|
||||
|
||||
@registry.register(contexts=["staging"])
|
||||
def staging_data():
|
||||
return []
|
||||
|
||||
names = {f.name for f in registry.get_by_context("staging")}
|
||||
assert names == {"staging_data", "base_data"}
|
||||
|
||||
def test_get_load_variants_falls_back_to_all_when_context_has_no_match(self):
|
||||
"""get_load_variants returns every variant if none match the requested
|
||||
context (and none are Context.BASE either)."""
|
||||
registry = FixtureRegistry()
|
||||
|
||||
@registry.register(contexts=["staging"])
|
||||
def env_data():
|
||||
return []
|
||||
|
||||
variants = registry.get_load_variants("env_data", "production")
|
||||
assert [v.contexts for v in variants] == [["staging"]]
|
||||
|
||||
|
||||
class TestIncludeRegistry:
|
||||
|
||||
+52
-1
@@ -21,12 +21,12 @@ from fastapi_toolsets.models import (
|
||||
listens_for,
|
||||
)
|
||||
from fastapi_toolsets.models.watched import (
|
||||
EventSession,
|
||||
_EVENT_HANDLERS,
|
||||
_SESSION_CREATES,
|
||||
_SESSION_DELETES,
|
||||
_SESSION_UPDATES,
|
||||
_WATCHED_MODELS,
|
||||
EventSession,
|
||||
_after_flush,
|
||||
_after_rollback,
|
||||
_get_watched_fields,
|
||||
@@ -1001,6 +1001,57 @@ class TestEventCallbacks:
|
||||
|
||||
assert _test_events == []
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_survives_row_deleted_before_reload(self, mixin_session):
|
||||
"""A row deleted by another transaction right after commit still fires CREATE."""
|
||||
keep = WatchedModel(status="active", other="x")
|
||||
doomed = WatchedModel(status="active", other="x")
|
||||
mixin_session.add_all([keep, doomed])
|
||||
await mixin_session.flush()
|
||||
doomed_id = doomed.id
|
||||
|
||||
raced = {"done": False}
|
||||
|
||||
async def kill_doomed_row_once():
|
||||
if raced["done"]:
|
||||
return
|
||||
raced["done"] = True
|
||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
async with async_sessionmaker(engine)() as other:
|
||||
row = await other.get(WatchedModel, doomed_id)
|
||||
await other.delete(row)
|
||||
await other.commit()
|
||||
await engine.dispose()
|
||||
|
||||
real_get = mixin_session.get
|
||||
real_refresh = mixin_session.refresh
|
||||
|
||||
def _matches_doomed(pk):
|
||||
return pk == doomed_id or (isinstance(pk, tuple) and pk[0] == doomed_id)
|
||||
|
||||
async def racing_get(model, pk, *args, **kwargs):
|
||||
if _matches_doomed(pk):
|
||||
await kill_doomed_row_once()
|
||||
return await real_get(model, pk, *args, **kwargs)
|
||||
|
||||
async def racing_refresh(obj, *args, **kwargs):
|
||||
if getattr(obj, "id", None) == doomed_id:
|
||||
await kill_doomed_row_once()
|
||||
return await real_refresh(obj, *args, **kwargs)
|
||||
|
||||
# Patch both possible reload mechanisms (session.get / session.refresh)
|
||||
# so this test still exercises the race regardless of which one
|
||||
# EventSession.commit() uses internally to pick up server defaults.
|
||||
mixin_session.get = racing_get
|
||||
mixin_session.refresh = racing_refresh
|
||||
with patch.object(_watched_module._logger, "error") as mock_error:
|
||||
await mixin_session.commit()
|
||||
mock_error.assert_not_called()
|
||||
|
||||
assert raced["done"]
|
||||
created_ids = {e["obj_id"] for e in _test_events if e["event"] == "create"}
|
||||
assert created_ids == {keep.id, doomed_id}
|
||||
|
||||
|
||||
class TestTransientObject:
|
||||
"""Create + delete within the same transaction should fire no events."""
|
||||
|
||||
Reference in New Issue
Block a user