Compare commits

..
2 Commits
Author SHA1 Message Date
d3vyce ba7d841a8b docs: add v5 migration guide 2026-07-02 13:58:45 -04:00
d3vyce ed0ca7b5dc chore: remove security module 2026-07-01 13:24:39 -04:00
15 changed files with 56 additions and 250 deletions
+2 -10
View File
@@ -65,13 +65,6 @@ 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.
@@ -87,7 +80,6 @@ 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)
```
@@ -116,8 +108,8 @@ def users():
def users():
return [User(id=2, username="tester")]
# loads both admin and tester (Context.BASE is included automatically)
await load_fixtures_by_context(session, fixtures, Context.TESTING)
# loads both admin and tester
await load_fixtures_by_context(session, fixtures, Context.BASE, Context.TESTING)
```
Registering two variants with overlapping context sets raises `ValueError`.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "fastapi-toolsets"
version = "5.0.0b2"
version = "5.0.0b1"
description = "Production-ready utilities for FastAPI applications"
readme = "README.md"
license = "MIT"
+1 -1
View File
@@ -24,4 +24,4 @@ Example usage:
return Response(data={"user": user.username}, message="Success")
"""
__version__ = "5.0.0b2"
__version__ = "5.0.0b1"
@@ -6,7 +6,7 @@ import typer
from rich.console import Console
from rich.table import Table
from ...fixtures import Context, LoadStrategy
from ...fixtures import Context, LoadStrategy, load_fixtures_by_context
from ...logger import get_logger
from ..config import get_db_context, get_fixtures_registry
from ..utils import async_command
@@ -24,7 +24,7 @@ logger = get_logger()
def list_fixtures(
ctx: typer.Context,
context: Annotated[
str | None,
Context | None,
typer.Option(
"--context",
"-c",
@@ -56,7 +56,7 @@ def list_fixtures(
async def load(
ctx: typer.Context,
contexts: Annotated[
list[str] | None,
list[Context] | None,
typer.Argument(help="Contexts to load."),
] = None,
strategy: Annotated[
@@ -71,12 +71,10 @@ async def load(
] = False,
) -> None:
"""Load fixtures into the database."""
from ...fixtures import load_fixtures_by_context
registry = get_fixtures_registry()
db_context = get_db_context()
context_list = contexts or [Context.BASE.value]
context_list = contexts or [Context.BASE]
ordered = registry.resolve_context_dependencies(*context_list)
+1 -2
View File
@@ -1,5 +1,6 @@
"""CLI utility functions."""
import asyncio
import functools
from collections.abc import Callable, Coroutine
from typing import Any, ParamSpec, TypeVar
@@ -23,8 +24,6 @@ def async_command(func: Callable[P, Coroutine[Any, Any, T]]) -> Callable[P, T]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
import asyncio
return asyncio.run(func(*args, **kwargs))
return wrapper
+3 -15
View File
@@ -57,18 +57,10 @@ 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 watcher.rollback()
return await watcher.get(model, pk_value, populate_existing=True)
await session.rollback()
return await session.get(model, pk_value, populate_existing=True)
instance = await _reload()
if instance is None:
@@ -95,12 +87,8 @@ 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()
+3 -18
View File
@@ -1,6 +1,8 @@
"""Fixture system for seeding databases with dependency resolution."""
from .enum import Context, LoadStrategy
from .enum import LoadStrategy
from .registry import Context, FixtureRegistry
from .utils import load_fixtures, load_fixtures_by_context
__all__ = [
"Context",
@@ -9,20 +11,3 @@ __all__ = [
"load_fixtures",
"load_fixtures_by_context",
]
_LAZY = {
"FixtureRegistry": ".registry",
"load_fixtures": ".utils",
"load_fixtures_by_context": ".utils",
}
def __getattr__(name: str):
module_name = _LAZY.get(name)
if module_name is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
module = importlib.import_module(module_name, __name__)
return getattr(module, name)
+6 -10
View File
@@ -17,11 +17,6 @@ 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."""
@@ -72,6 +67,8 @@ 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
```
"""
@@ -203,9 +200,8 @@ class FixtureRegistry:
Args:
name: Fixture name.
*contexts: If given, only return variants whose context set
intersects with these values (:class:`Context.BASE` variants
are always included). Both :class:`Context` enum values and
plain strings are accepted.
intersects with these values. Both :class:`Context` enum
values and plain strings are accepted.
Returns:
List of matching :class:`Fixture` objects (may be empty when a
@@ -219,7 +215,7 @@ class FixtureRegistry:
variants = self._fixtures[name]
if not contexts:
return list(variants)
context_values = _context_filter_values(contexts)
context_values = set(_normalize_contexts(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]:
@@ -301,7 +297,7 @@ class FixtureRegistry:
def get_by_context(self, *contexts: str | Enum) -> list[Fixture]:
"""Get fixtures for specific contexts."""
context_values = _context_filter_values(contexts)
context_values = set(_normalize_contexts(contexts))
return [
f
for variants in self._fixtures.values()
+2 -2
View File
@@ -383,8 +383,8 @@ async def load_fixtures_by_context(
Args:
session: Database session
registry: Fixture registry
*contexts: Contexts to load (e.g., ``Context.TESTING``, or plain
strings for custom contexts)
*contexts: Contexts to load (e.g., ``Context.BASE``, ``Context.TESTING``,
or plain strings for custom contexts)
strategy: How to handle existing records
Returns:
+2 -7
View File
@@ -204,11 +204,6 @@ 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."""
@@ -258,7 +253,7 @@ class EventSession(AsyncSession):
state is None or state.detached or state.transient
): # pragma: no cover
continue
await _reload_if_present(self, obj, state)
await self.refresh(obj)
for handler in _get_handlers(type(obj), ModelEvent.CREATE):
await _invoke_callback(handler, obj, ModelEvent.CREATE, None)
except Exception as exc:
@@ -282,7 +277,7 @@ class EventSession(AsyncSession):
state is None or state.detached or state.transient
): # pragma: no cover
continue
await _reload_if_present(self, obj, state)
await self.refresh(obj)
for handler in _get_handlers(type(obj), ModelEvent.UPDATE):
await _invoke_callback(handler, obj, ModelEvent.UPDATE, changes)
except Exception as exc:
+1 -26
View File
@@ -277,10 +277,6 @@ 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
@@ -320,7 +316,7 @@ class TestFixturesCli:
assert result.exit_code == 0
assert "roles" in result.output
assert "users" in result.output
assert "Total: 3 fixture(s)" in result.output
assert "Total: 2 fixture(s)" in result.output
def test_fixtures_list_with_context(self, cli_env):
"""fixtures list --context filters by context."""
@@ -342,27 +338,6 @@ 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
-44
View File
@@ -689,13 +689,6 @@ 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."""
@@ -788,43 +781,6 @@ 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."""
+1 -28
View File
@@ -266,34 +266,7 @@ class TestFixtureRegistry:
testing_fixtures = registry.get_by_context(Context.TESTING)
names = {f.name for f in testing_fixtures}
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"]]
assert names == {"test_data"}
class TestIncludeRegistry:
+1 -52
View File
@@ -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,57 +1001,6 @@ 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."""
Generated
+1 -1
View File
@@ -330,7 +330,7 @@ wheels = [
[[package]]
name = "fastapi-toolsets"
version = "5.0.0b2"
version = "5.0.0b1"
source = { editable = "." }
dependencies = [
{ name = "asyncpg" },