Compare commits

..
4 Commits
13 changed files with 227 additions and 51 deletions
+10 -2
View File
@@ -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. 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 ### Custom contexts
Plain strings and any `Enum` subclass are accepted wherever a `Context` enum is expected. 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(): def staging_data():
return [Config(key="feature_x", enabled=True)] return [Config(key="feature_x", enabled=True)]
# loads staging_data plus any Context.BASE fixtures
await load_fixtures_by_context(session, fixtures, AppContext.STAGING) await load_fixtures_by_context(session, fixtures, AppContext.STAGING)
``` ```
@@ -108,8 +116,8 @@ def users():
def users(): def users():
return [User(id=2, username="tester")] return [User(id=2, username="tester")]
# loads both admin and tester # loads both admin and tester (Context.BASE is included automatically)
await load_fixtures_by_context(session, fixtures, Context.BASE, Context.TESTING) await load_fixtures_by_context(session, fixtures, Context.TESTING)
``` ```
Registering two variants with overlapping context sets raises `ValueError`. Registering two variants with overlapping context sets raises `ValueError`.
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "fastapi-toolsets" name = "fastapi-toolsets"
version = "5.0.0b1" version = "5.0.0b2"
description = "Production-ready utilities for FastAPI applications" description = "Production-ready utilities for FastAPI applications"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
+1 -1
View File
@@ -24,4 +24,4 @@ Example usage:
return Response(data={"user": user.username}, message="Success") 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( def list_fixtures(
ctx: typer.Context, ctx: typer.Context,
context: Annotated[ context: Annotated[
Context | None, str | None,
typer.Option( typer.Option(
"--context", "--context",
"-c", "-c",
@@ -56,7 +56,7 @@ def list_fixtures(
async def load( async def load(
ctx: typer.Context, ctx: typer.Context,
contexts: Annotated[ contexts: Annotated[
list[Context] | None, list[str] | None,
typer.Argument(help="Contexts to load."), typer.Argument(help="Contexts to load."),
] = None, ] = None,
strategy: Annotated[ strategy: Annotated[
@@ -76,7 +76,7 @@ async def load(
registry = get_fixtures_registry() registry = get_fixtures_registry()
db_context = get_db_context() 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) ordered = registry.resolve_context_dependencies(*context_list)
+42 -30
View File
@@ -57,38 +57,50 @@ 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: async def _reload() -> _M | None:
await session.rollback() await watcher.rollback()
return await session.get(model, pk_value, populate_existing=True) return await watcher.get(model, pk_value, populate_existing=True)
instance = await _reload()
if instance is None:
raise NotFoundError(f"{model.__name__} with pk={pk_value!r} not found")
if columns is not None:
watch_cols = columns
else:
watch_cols = [attr.key for attr in model.__mapper__.column_attrs]
initial = {col: getattr(instance, col) for col in watch_cols}
elapsed = 0.0
while True:
await asyncio.sleep(interval)
elapsed += interval
if timeout is not None and elapsed >= timeout:
raise TimeoutError(
f"No change detected on {model.__name__} "
f"with pk={pk_value!r} within {timeout}s"
)
instance = await _reload() instance = await _reload()
if instance is None: 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} not found")
current = {col: getattr(instance, col) for col in watch_cols} if columns is not None:
if current != initial: watch_cols = columns
return instance else:
watch_cols = [attr.key for attr in model.__mapper__.column_attrs]
initial = {col: getattr(instance, col) for col in watch_cols}
elapsed = 0.0
while True:
await asyncio.sleep(interval)
elapsed += interval
if timeout is not None and elapsed >= timeout:
raise TimeoutError(
f"No change detected on {model.__name__} "
f"with pk={pk_value!r} within {timeout}s"
)
instance = await _reload()
if instance is None:
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()
+10 -6
View File
@@ -17,6 +17,11 @@ def _normalize_contexts(
return [c.value if isinstance(c, Enum) else c for c in 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 @dataclass
class Fixture: class Fixture:
"""A fixture definition with metadata.""" """A fixture definition with metadata."""
@@ -67,8 +72,6 @@ class FixtureRegistry:
@fixtures.register(contexts=[Context.TESTING]) @fixtures.register(contexts=[Context.TESTING])
def users(): def users():
return [User(id=2, username="tester")] 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: Args:
name: Fixture name. name: Fixture name.
*contexts: If given, only return variants whose context set *contexts: If given, only return variants whose context set
intersects with these values. Both :class:`Context` enum intersects with these values (:class:`Context.BASE` variants
values and plain strings are accepted. are always included). Both :class:`Context` enum values and
plain strings are accepted.
Returns: Returns:
List of matching :class:`Fixture` objects (may be empty when a List of matching :class:`Fixture` objects (may be empty when a
@@ -215,7 +219,7 @@ class FixtureRegistry:
variants = self._fixtures[name] variants = self._fixtures[name]
if not contexts: if not contexts:
return list(variants) 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] return [v for v in variants if set(v.contexts) & context_values]
def get_load_variants(self, name: str, *contexts: str | Enum) -> list[Fixture]: 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]: def get_by_context(self, *contexts: str | Enum) -> list[Fixture]:
"""Get fixtures for specific contexts.""" """Get fixtures for specific contexts."""
context_values = set(_normalize_contexts(contexts)) context_values = _context_filter_values(contexts)
return [ return [
f f
for variants in self._fixtures.values() for variants in self._fixtures.values()
+2 -2
View File
@@ -383,8 +383,8 @@ async def load_fixtures_by_context(
Args: Args:
session: Database session session: Database session
registry: Fixture registry registry: Fixture registry
*contexts: Contexts to load (e.g., ``Context.BASE``, ``Context.TESTING``, *contexts: Contexts to load (e.g., ``Context.TESTING``, or plain
or plain strings for custom contexts) strings for custom contexts)
strategy: How to handle existing records strategy: How to handle existing records
Returns: Returns:
+7 -2
View File
@@ -204,6 +204,11 @@ async def _invoke_callback(
await result 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): class EventSession(AsyncSession):
"""AsyncSession subclass that dispatches lifecycle callbacks after commit.""" """AsyncSession subclass that dispatches lifecycle callbacks after commit."""
@@ -253,7 +258,7 @@ class EventSession(AsyncSession):
state is None or state.detached or state.transient state is None or state.detached or state.transient
): # pragma: no cover ): # pragma: no cover
continue continue
await self.refresh(obj) await _reload_if_present(self, obj, state)
for handler in _get_handlers(type(obj), ModelEvent.CREATE): for handler in _get_handlers(type(obj), ModelEvent.CREATE):
await _invoke_callback(handler, obj, ModelEvent.CREATE, None) await _invoke_callback(handler, obj, ModelEvent.CREATE, None)
except Exception as exc: except Exception as exc:
@@ -277,7 +282,7 @@ class EventSession(AsyncSession):
state is None or state.detached or state.transient state is None or state.detached or state.transient
): # pragma: no cover ): # pragma: no cover
continue continue
await self.refresh(obj) await _reload_if_present(self, obj, state)
for handler in _get_handlers(type(obj), ModelEvent.UPDATE): for handler in _get_handlers(type(obj), ModelEvent.UPDATE):
await _invoke_callback(handler, obj, ModelEvent.UPDATE, changes) await _invoke_callback(handler, obj, ModelEvent.UPDATE, changes)
except Exception as exc: except Exception as exc:
+26 -1
View File
@@ -277,6 +277,10 @@ class TestFixturesCli:
'@registry.register(depends_on=["roles"], contexts=[Context.TESTING])\n' '@registry.register(depends_on=["roles"], contexts=[Context.TESTING])\n'
"def users():\n" "def users():\n"
' return [{"id": 1, "name": "alice", "role_id": 1}]\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 # Create db module
@@ -316,7 +320,7 @@ class TestFixturesCli:
assert result.exit_code == 0 assert result.exit_code == 0
assert "roles" in result.output assert "roles" in result.output
assert "users" 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): def test_fixtures_list_with_context(self, cli_env):
"""fixtures list --context filters by context.""" """fixtures list --context filters by context."""
@@ -338,6 +342,27 @@ class TestFixturesCli:
assert "roles" in result.output assert "roles" in result.output
assert "[Dry run - no changes made]" 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): def test_fixtures_load_invalid_strategy(self, cli_env):
"""fixtures load with invalid strategy shows error.""" """fixtures load with invalid strategy shows error."""
tmp_path, cli = cli_env tmp_path, cli = cli_env
+44
View File
@@ -689,6 +689,13 @@ class TestWaitForRowChange:
with pytest.raises(NotFoundError, match="not found"): with pytest.raises(NotFoundError, match="not found"):
await wait_for_row_change(db_session, Role, fake_id, interval=0.05) 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 @pytest.mark.anyio
async def test_timeout_raises(self, db_session: AsyncSession): async def test_timeout_raises(self, db_session: AsyncSession):
"""Raises TimeoutError when no change is detected within timeout.""" """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 wait_for_row_change(db_session, Role, role.id, interval=0.05)
await delete_task 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: class TestCreateDatabase:
"""Tests for create_database.""" """Tests for create_database."""
+28 -1
View File
@@ -266,7 +266,34 @@ class TestFixtureRegistry:
testing_fixtures = registry.get_by_context(Context.TESTING) testing_fixtures = registry.get_by_context(Context.TESTING)
names = {f.name for f in testing_fixtures} 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: class TestIncludeRegistry:
+52 -1
View File
@@ -21,12 +21,12 @@ from fastapi_toolsets.models import (
listens_for, listens_for,
) )
from fastapi_toolsets.models.watched import ( from fastapi_toolsets.models.watched import (
EventSession,
_EVENT_HANDLERS, _EVENT_HANDLERS,
_SESSION_CREATES, _SESSION_CREATES,
_SESSION_DELETES, _SESSION_DELETES,
_SESSION_UPDATES, _SESSION_UPDATES,
_WATCHED_MODELS, _WATCHED_MODELS,
EventSession,
_after_flush, _after_flush,
_after_rollback, _after_rollback,
_get_watched_fields, _get_watched_fields,
@@ -1001,6 +1001,57 @@ class TestEventCallbacks:
assert _test_events == [] 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: class TestTransientObject:
"""Create + delete within the same transaction should fire no events.""" """Create + delete within the same transaction should fire no events."""
Generated
+1 -1
View File
@@ -330,7 +330,7 @@ wheels = [
[[package]] [[package]]
name = "fastapi-toolsets" name = "fastapi-toolsets"
version = "5.0.0b1" version = "5.0.0b2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "asyncpg" }, { name = "asyncpg" },