mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-04-16 23:02:29 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
337985ef38
|
|||
|
|
b5e6dfe6fe | ||
|
|
6681b7ade7 | ||
|
|
6981c33dc8 | ||
|
|
0c7a99039c | ||
|
|
bcb5b0bfda |
@@ -138,6 +138,23 @@ Server-side defaults (e.g. `id`, `created_at`) are fully populated in all callba
|
|||||||
| `@watch("status", "role")` | Only fires when `status` or `role` changes |
|
| `@watch("status", "role")` | Only fires when `status` or `role` changes |
|
||||||
| *(no decorator)* | Fires when **any** mapped field changes |
|
| *(no decorator)* | Fires when **any** mapped field changes |
|
||||||
|
|
||||||
|
`@watch` is inherited through the class hierarchy. If a subclass does not declare its own `@watch`, it uses the filter from the nearest decorated parent. Applying `@watch` on the subclass overrides the parent's filter:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@watch("status")
|
||||||
|
class Order(Base, UUIDMixin, WatchedFieldsMixin):
|
||||||
|
...
|
||||||
|
|
||||||
|
class UrgentOrder(Order):
|
||||||
|
# inherits @watch("status") — on_update fires only for status changes
|
||||||
|
...
|
||||||
|
|
||||||
|
@watch("priority")
|
||||||
|
class PriorityOrder(Order):
|
||||||
|
# overrides parent — on_update fires only for priority changes
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
#### Option 1 — catch-all with `on_event`
|
#### Option 1 — catch-all with `on_event`
|
||||||
|
|
||||||
Override `on_event` to handle all event types in one place. The specific methods delegate here by default:
|
Override `on_event` to handle all event types in one place. The specific methods delegate here by default:
|
||||||
@@ -197,6 +214,25 @@ The `changes` dict maps each watched field that changed to `{"old": ..., "new":
|
|||||||
|
|
||||||
!!! warning "Callbacks fire only for ORM-level changes. Rows updated via raw SQL (`UPDATE ... SET ...`) are not detected."
|
!!! warning "Callbacks fire only for ORM-level changes. Rows updated via raw SQL (`UPDATE ... SET ...`) are not detected."
|
||||||
|
|
||||||
|
!!! warning "Callbacks fire after the **outermost** transaction commits."
|
||||||
|
If you create several related objects using `CrudFactory.create` and need
|
||||||
|
callbacks to see all of them (including associations), wrap the whole
|
||||||
|
operation in a single [`get_transaction`](db.md) block. Without it, each
|
||||||
|
`create` call commits independently and `on_create` fires before the
|
||||||
|
remaining objects exist.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fastapi_toolsets.db import get_transaction
|
||||||
|
|
||||||
|
async with get_transaction(session):
|
||||||
|
order = await OrderCrud.create(session, order_data)
|
||||||
|
item = await ItemCrud.create(session, item_data)
|
||||||
|
await session.refresh(order, attribute_names=["items"])
|
||||||
|
order.items.append(item)
|
||||||
|
# on_create fires here for both order and item,
|
||||||
|
# with the full association already committed.
|
||||||
|
```
|
||||||
|
|
||||||
## Composing mixins
|
## Composing mixins
|
||||||
|
|
||||||
All mixins can be combined in any order. The only constraint is that exactly one primary key must be defined — either via `UUIDMixin` or directly on the model.
|
All mixins can be combined in any order. The only constraint is that exactly one primary key must be defined — either via `UUIDMixin` or directly on the model.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "2.4.1"
|
version = "2.4.2"
|
||||||
description = "Production-ready utilities for FastAPI applications"
|
description = "Production-ready utilities for FastAPI applications"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ Example usage:
|
|||||||
return Response(data={"user": user.username}, message="Success")
|
return Response(data={"user": user.username}, message="Success")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "2.4.1"
|
__version__ = "2.4.2"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Field-change monitoring via SQLAlchemy session events."""
|
"""Field-change monitoring via SQLAlchemy session events."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import inspect
|
||||||
import weakref
|
import weakref
|
||||||
from collections.abc import Awaitable
|
from collections.abc import Awaitable
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -25,6 +26,7 @@ _SESSION_PENDING_NEW = "_ft_pending_new"
|
|||||||
_SESSION_CREATES = "_ft_creates"
|
_SESSION_CREATES = "_ft_creates"
|
||||||
_SESSION_DELETES = "_ft_deletes"
|
_SESSION_DELETES = "_ft_deletes"
|
||||||
_SESSION_UPDATES = "_ft_updates"
|
_SESSION_UPDATES = "_ft_updates"
|
||||||
|
_SESSION_SAVEPOINT_DEPTH = "_ft_sp_depth"
|
||||||
|
|
||||||
|
|
||||||
class ModelEvent(str, Enum):
|
class ModelEvent(str, Enum):
|
||||||
@@ -65,6 +67,14 @@ def _snapshot_column_attrs(obj: Any) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_watched_fields(cls: type) -> list[str] | None:
|
||||||
|
"""Return the watched fields for *cls*, walking the MRO to inherit from parents."""
|
||||||
|
for klass in cls.__mro__:
|
||||||
|
if klass in _WATCHED_FIELDS:
|
||||||
|
return _WATCHED_FIELDS[klass]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _upsert_changes(
|
def _upsert_changes(
|
||||||
pending: dict[int, tuple[Any, dict[str, dict[str, Any]]]],
|
pending: dict[int, tuple[Any, dict[str, dict[str, Any]]]],
|
||||||
obj: Any,
|
obj: Any,
|
||||||
@@ -83,6 +93,22 @@ def _upsert_changes(
|
|||||||
pending[key] = (obj, changes)
|
pending[key] = (obj, changes)
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(AsyncSession.sync_session_class, "after_transaction_create")
|
||||||
|
def _after_transaction_create(session: Any, transaction: Any) -> None:
|
||||||
|
if transaction.nested:
|
||||||
|
session.info[_SESSION_SAVEPOINT_DEPTH] = (
|
||||||
|
session.info.get(_SESSION_SAVEPOINT_DEPTH, 0) + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(AsyncSession.sync_session_class, "after_transaction_end")
|
||||||
|
def _after_transaction_end(session: Any, transaction: Any) -> None:
|
||||||
|
if transaction.nested:
|
||||||
|
depth = session.info.get(_SESSION_SAVEPOINT_DEPTH, 0)
|
||||||
|
if depth > 0: # pragma: no branch
|
||||||
|
session.info[_SESSION_SAVEPOINT_DEPTH] = depth - 1
|
||||||
|
|
||||||
|
|
||||||
@event.listens_for(AsyncSession.sync_session_class, "after_flush")
|
@event.listens_for(AsyncSession.sync_session_class, "after_flush")
|
||||||
def _after_flush(session: Any, flush_context: Any) -> None:
|
def _after_flush(session: Any, flush_context: Any) -> None:
|
||||||
# New objects: capture references while session.new is still populated.
|
# New objects: capture references while session.new is still populated.
|
||||||
@@ -102,7 +128,7 @@ def _after_flush(session: Any, flush_context: Any) -> None:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# None = not in dict = watch all fields; list = specific fields only
|
# None = not in dict = watch all fields; list = specific fields only
|
||||||
watched = _WATCHED_FIELDS.get(type(obj))
|
watched = _get_watched_fields(type(obj))
|
||||||
changes: dict[str, dict[str, Any]] = {}
|
changes: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
attrs = (
|
attrs = (
|
||||||
@@ -169,7 +195,7 @@ def _schedule_with_snapshot(
|
|||||||
_sa_set_committed_value(obj, key, value)
|
_sa_set_committed_value(obj, key, value)
|
||||||
try:
|
try:
|
||||||
result = fn(*args)
|
result = fn(*args)
|
||||||
if asyncio.iscoroutine(result):
|
if inspect.isawaitable(result):
|
||||||
await result
|
await result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||||
@@ -180,12 +206,24 @@ def _schedule_with_snapshot(
|
|||||||
|
|
||||||
@event.listens_for(AsyncSession.sync_session_class, "after_commit")
|
@event.listens_for(AsyncSession.sync_session_class, "after_commit")
|
||||||
def _after_commit(session: Any) -> None:
|
def _after_commit(session: Any) -> None:
|
||||||
|
if session.info.get(_SESSION_SAVEPOINT_DEPTH, 0) > 0:
|
||||||
|
return
|
||||||
|
|
||||||
creates: list[Any] = session.info.pop(_SESSION_CREATES, [])
|
creates: list[Any] = session.info.pop(_SESSION_CREATES, [])
|
||||||
deletes: list[Any] = session.info.pop(_SESSION_DELETES, [])
|
deletes: list[Any] = session.info.pop(_SESSION_DELETES, [])
|
||||||
field_changes: dict[int, tuple[Any, dict[str, dict[str, Any]]]] = session.info.pop(
|
field_changes: dict[int, tuple[Any, dict[str, dict[str, Any]]]] = session.info.pop(
|
||||||
_SESSION_UPDATES, {}
|
_SESSION_UPDATES, {}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if creates and deletes:
|
||||||
|
transient_ids = {id(o) for o in creates} & {id(o) for o in deletes}
|
||||||
|
if transient_ids:
|
||||||
|
creates = [o for o in creates if id(o) not in transient_ids]
|
||||||
|
deletes = [o for o in deletes if id(o) not in transient_ids]
|
||||||
|
field_changes = {
|
||||||
|
k: v for k, v in field_changes.items() if k not in transient_ids
|
||||||
|
}
|
||||||
|
|
||||||
if not creates and not deletes and not field_changes:
|
if not creates and not deletes and not field_changes:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -321,30 +321,3 @@ async def db_session(engine):
|
|||||||
# Drop tables after test
|
# Drop tables after test
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_role_data() -> RoleCreate:
|
|
||||||
"""Sample role creation data."""
|
|
||||||
return RoleCreate(name="admin")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_user_data() -> UserCreate:
|
|
||||||
"""Sample user creation data."""
|
|
||||||
return UserCreate(
|
|
||||||
username="testuser",
|
|
||||||
email="test@example.com",
|
|
||||||
is_active=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def sample_post_data() -> PostCreate:
|
|
||||||
"""Sample post creation data."""
|
|
||||||
return PostCreate(
|
|
||||||
title="Test Post",
|
|
||||||
content="Test content",
|
|
||||||
is_published=True,
|
|
||||||
author_id=uuid.uuid4(),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import datetime
|
|||||||
import pytest
|
import pytest
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from docs_src.examples.pagination_search.db import get_db
|
from docs_src.examples.pagination_search.db import get_db
|
||||||
from docs_src.examples.pagination_search.models import Article, Base, Category
|
from docs_src.examples.pagination_search.models import Article, Base, Category
|
||||||
from docs_src.examples.pagination_search.routes import router
|
from docs_src.examples.pagination_search.routes import router
|
||||||
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
from fastapi_toolsets.exceptions import init_exceptions_handlers
|
||||||
|
from fastapi_toolsets.pytest import create_db_session
|
||||||
|
|
||||||
from .conftest import DATABASE_URL
|
from .conftest import DATABASE_URL
|
||||||
|
|
||||||
@@ -35,20 +36,8 @@ def build_app(session: AsyncSession) -> FastAPI:
|
|||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
async def ex_db_session():
|
async def ex_db_session():
|
||||||
"""Isolated session for the example models (separate tables from conftest)."""
|
"""Isolated session for the example models (separate tables from conftest)."""
|
||||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
async with create_db_session(DATABASE_URL, Base) as session:
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
|
||||||
|
|
||||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
session = session_factory()
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield session
|
yield session
|
||||||
finally:
|
|
||||||
await session.close()
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(Base.metadata.drop_all)
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from fastapi_toolsets.fixtures import (
|
|||||||
|
|
||||||
from fastapi_toolsets.fixtures.utils import _get_primary_key
|
from fastapi_toolsets.fixtures.utils import _get_primary_key
|
||||||
|
|
||||||
from .conftest import IntRole, Permission, Role, User
|
from .conftest import IntRole, Permission, Role, RoleCrud, User, UserCrud
|
||||||
|
|
||||||
|
|
||||||
class TestContext:
|
class TestContext:
|
||||||
@@ -447,8 +447,6 @@ class TestLoadFixtures:
|
|||||||
assert "roles" in result
|
assert "roles" in result
|
||||||
assert len(result["roles"]) == 2
|
assert len(result["roles"]) == 2
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 2
|
assert count == 2
|
||||||
|
|
||||||
@@ -479,8 +477,6 @@ class TestLoadFixtures:
|
|||||||
assert "roles" in result
|
assert "roles" in result
|
||||||
assert "users" in result
|
assert "users" in result
|
||||||
|
|
||||||
from .conftest import RoleCrud, UserCrud
|
|
||||||
|
|
||||||
assert await RoleCrud.count(db_session) == 1
|
assert await RoleCrud.count(db_session) == 1
|
||||||
assert await UserCrud.count(db_session) == 1
|
assert await UserCrud.count(db_session) == 1
|
||||||
|
|
||||||
@@ -497,8 +493,6 @@ class TestLoadFixtures:
|
|||||||
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
|
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
|
||||||
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
|
await load_fixtures(db_session, registry, "roles", strategy=LoadStrategy.MERGE)
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 1
|
assert count == 1
|
||||||
|
|
||||||
@@ -526,8 +520,6 @@ class TestLoadFixtures:
|
|||||||
db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING
|
db_session, registry, "roles", strategy=LoadStrategy.SKIP_EXISTING
|
||||||
)
|
)
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
role = await RoleCrud.first(db_session, [Role.id == role_id])
|
role = await RoleCrud.first(db_session, [Role.id == role_id])
|
||||||
assert role is not None
|
assert role is not None
|
||||||
assert role.name == "original"
|
assert role.name == "original"
|
||||||
@@ -553,8 +545,6 @@ class TestLoadFixtures:
|
|||||||
assert "roles" in result
|
assert "roles" in result
|
||||||
assert len(result["roles"]) == 2
|
assert len(result["roles"]) == 2
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 2
|
assert count == 2
|
||||||
|
|
||||||
@@ -594,8 +584,6 @@ class TestLoadFixtures:
|
|||||||
assert "roles" in result
|
assert "roles" in result
|
||||||
assert "other_roles" in result
|
assert "other_roles" in result
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 2
|
assert count == 2
|
||||||
|
|
||||||
@@ -660,8 +648,6 @@ class TestLoadFixturesByContext:
|
|||||||
|
|
||||||
await load_fixtures_by_context(db_session, registry, Context.BASE)
|
await load_fixtures_by_context(db_session, registry, Context.BASE)
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 1
|
assert count == 1
|
||||||
|
|
||||||
@@ -688,8 +674,6 @@ class TestLoadFixturesByContext:
|
|||||||
db_session, registry, Context.BASE, Context.TESTING
|
db_session, registry, Context.BASE, Context.TESTING
|
||||||
)
|
)
|
||||||
|
|
||||||
from .conftest import RoleCrud
|
|
||||||
|
|
||||||
count = await RoleCrud.count(db_session)
|
count = await RoleCrud.count(db_session)
|
||||||
assert count == 2
|
assert count == 2
|
||||||
|
|
||||||
@@ -717,8 +701,6 @@ class TestLoadFixturesByContext:
|
|||||||
|
|
||||||
await load_fixtures_by_context(db_session, registry, Context.TESTING)
|
await load_fixtures_by_context(db_session, registry, Context.TESTING)
|
||||||
|
|
||||||
from .conftest import RoleCrud, UserCrud
|
|
||||||
|
|
||||||
assert await RoleCrud.count(db_session) == 1
|
assert await RoleCrud.count(db_session) == 1
|
||||||
assert await UserCrud.count(db_session) == 1
|
assert await UserCrud.count(db_session) == 1
|
||||||
|
|
||||||
|
|||||||
@@ -171,8 +171,15 @@ class TestPytestImportGuard:
|
|||||||
class TestCliImportGuard:
|
class TestCliImportGuard:
|
||||||
"""Tests for CLI module import guard when typer is missing."""
|
"""Tests for CLI module import guard when typer is missing."""
|
||||||
|
|
||||||
def test_import_raises_without_typer(self):
|
@pytest.mark.parametrize(
|
||||||
"""Importing cli.app raises when typer is missing."""
|
"expected_match",
|
||||||
|
[
|
||||||
|
"typer",
|
||||||
|
r"pip install fastapi-toolsets\[cli\]",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_import_raises_without_typer(self, expected_match):
|
||||||
|
"""Importing cli.app raises when typer is missing, with an informative error message."""
|
||||||
saved, blocking_import = _reload_without_package(
|
saved, blocking_import = _reload_without_package(
|
||||||
"fastapi_toolsets.cli.app", ["typer"]
|
"fastapi_toolsets.cli.app", ["typer"]
|
||||||
)
|
)
|
||||||
@@ -186,33 +193,7 @@ class TestCliImportGuard:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with patch("builtins.__import__", side_effect=blocking_import):
|
with patch("builtins.__import__", side_effect=blocking_import):
|
||||||
with pytest.raises(ImportError, match="typer"):
|
with pytest.raises(ImportError, match=expected_match):
|
||||||
importlib.import_module("fastapi_toolsets.cli.app")
|
|
||||||
finally:
|
|
||||||
for key in list(sys.modules):
|
|
||||||
if key.startswith("fastapi_toolsets.cli.app") or key.startswith(
|
|
||||||
"fastapi_toolsets.cli.config"
|
|
||||||
):
|
|
||||||
sys.modules.pop(key, None)
|
|
||||||
sys.modules.update(saved)
|
|
||||||
|
|
||||||
def test_error_message_suggests_cli_extra(self):
|
|
||||||
"""Error message suggests installing the cli extra."""
|
|
||||||
saved, blocking_import = _reload_without_package(
|
|
||||||
"fastapi_toolsets.cli.app", ["typer"]
|
|
||||||
)
|
|
||||||
config_keys = [
|
|
||||||
k for k in sys.modules if k.startswith("fastapi_toolsets.cli.config")
|
|
||||||
]
|
|
||||||
for key in config_keys:
|
|
||||||
if key not in saved:
|
|
||||||
saved[key] = sys.modules.pop(key)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with patch("builtins.__import__", side_effect=blocking_import):
|
|
||||||
with pytest.raises(
|
|
||||||
ImportError, match=r"pip install fastapi-toolsets\[cli\]"
|
|
||||||
):
|
|
||||||
importlib.import_module("fastapi_toolsets.cli.app")
|
importlib.import_module("fastapi_toolsets.cli.app")
|
||||||
finally:
|
finally:
|
||||||
for key in list(sys.modules):
|
for key in list(sys.modules):
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Tests for fastapi_toolsets.metrics module."""
|
"""Tests for fastapi_toolsets.metrics module."""
|
||||||
|
|
||||||
import os
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
@@ -287,6 +286,16 @@ class TestIncludeRegistry:
|
|||||||
class TestInitMetrics:
|
class TestInitMetrics:
|
||||||
"""Tests for init_metrics function."""
|
"""Tests for init_metrics function."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def metrics_client(self):
|
||||||
|
"""Create a FastAPI app with MetricsRegistry and return a TestClient."""
|
||||||
|
app = FastAPI()
|
||||||
|
registry = MetricsRegistry()
|
||||||
|
init_metrics(app, registry)
|
||||||
|
client = TestClient(app)
|
||||||
|
yield client
|
||||||
|
client.close()
|
||||||
|
|
||||||
def test_returns_app(self):
|
def test_returns_app(self):
|
||||||
"""Returns the FastAPI app."""
|
"""Returns the FastAPI app."""
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@@ -294,26 +303,14 @@ class TestInitMetrics:
|
|||||||
result = init_metrics(app, registry)
|
result = init_metrics(app, registry)
|
||||||
assert result is app
|
assert result is app
|
||||||
|
|
||||||
def test_metrics_endpoint_responds(self):
|
def test_metrics_endpoint_responds(self, metrics_client):
|
||||||
"""The /metrics endpoint returns 200."""
|
"""The /metrics endpoint returns 200."""
|
||||||
app = FastAPI()
|
response = metrics_client.get("/metrics")
|
||||||
registry = MetricsRegistry()
|
|
||||||
init_metrics(app, registry)
|
|
||||||
|
|
||||||
client = TestClient(app)
|
|
||||||
response = client.get("/metrics")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
def test_metrics_endpoint_content_type(self):
|
def test_metrics_endpoint_content_type(self, metrics_client):
|
||||||
"""The /metrics endpoint returns prometheus content type."""
|
"""The /metrics endpoint returns prometheus content type."""
|
||||||
app = FastAPI()
|
response = metrics_client.get("/metrics")
|
||||||
registry = MetricsRegistry()
|
|
||||||
init_metrics(app, registry)
|
|
||||||
|
|
||||||
client = TestClient(app)
|
|
||||||
response = client.get("/metrics")
|
|
||||||
|
|
||||||
assert "text/plain" in response.headers["content-type"]
|
assert "text/plain" in response.headers["content-type"]
|
||||||
|
|
||||||
def test_custom_path(self):
|
def test_custom_path(self):
|
||||||
@@ -445,36 +442,33 @@ class TestInitMetrics:
|
|||||||
class TestMultiProcessMode:
|
class TestMultiProcessMode:
|
||||||
"""Tests for multi-process Prometheus mode."""
|
"""Tests for multi-process Prometheus mode."""
|
||||||
|
|
||||||
def test_multiprocess_with_env_var(self):
|
def test_multiprocess_with_env_var(self, monkeypatch):
|
||||||
"""Multi-process mode works when PROMETHEUS_MULTIPROC_DIR is set."""
|
"""Multi-process mode works when PROMETHEUS_MULTIPROC_DIR is set."""
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
os.environ["PROMETHEUS_MULTIPROC_DIR"] = tmpdir
|
monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", tmpdir)
|
||||||
try:
|
# Use a separate registry to avoid conflicts with default
|
||||||
# Use a separate registry to avoid conflicts with default
|
prom_registry = CollectorRegistry()
|
||||||
prom_registry = CollectorRegistry()
|
app = FastAPI()
|
||||||
app = FastAPI()
|
registry = MetricsRegistry()
|
||||||
registry = MetricsRegistry()
|
|
||||||
|
|
||||||
@registry.register
|
@registry.register
|
||||||
def mp_counter():
|
def mp_counter():
|
||||||
return Counter(
|
return Counter(
|
||||||
"mp_test_counter",
|
"mp_test_counter",
|
||||||
"A multiprocess counter",
|
"A multiprocess counter",
|
||||||
registry=prom_registry,
|
registry=prom_registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
init_metrics(app, registry)
|
init_metrics(app, registry)
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
response = client.get("/metrics")
|
response = client.get("/metrics")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
finally:
|
|
||||||
del os.environ["PROMETHEUS_MULTIPROC_DIR"]
|
|
||||||
|
|
||||||
def test_single_process_without_env_var(self):
|
def test_single_process_without_env_var(self, monkeypatch):
|
||||||
"""Single-process mode when PROMETHEUS_MULTIPROC_DIR is not set."""
|
"""Single-process mode when PROMETHEUS_MULTIPROC_DIR is not set."""
|
||||||
os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None)
|
monkeypatch.delenv("PROMETHEUS_MULTIPROC_DIR", raising=False)
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
registry = MetricsRegistry()
|
registry = MetricsRegistry()
|
||||||
|
|||||||
@@ -6,27 +6,28 @@ from contextlib import suppress
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import fastapi_toolsets.models.watched as _watched_module
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import String
|
from sqlalchemy import String
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
from fastapi_toolsets.pytest import create_db_session
|
||||||
|
|
||||||
|
import fastapi_toolsets.models.watched as _watched_module
|
||||||
from fastapi_toolsets.models import (
|
from fastapi_toolsets.models import (
|
||||||
CreatedAtMixin,
|
CreatedAtMixin,
|
||||||
ModelEvent,
|
ModelEvent,
|
||||||
TimestampMixin,
|
TimestampMixin,
|
||||||
|
UpdatedAtMixin,
|
||||||
UUIDMixin,
|
UUIDMixin,
|
||||||
UUIDv7Mixin,
|
UUIDv7Mixin,
|
||||||
UpdatedAtMixin,
|
|
||||||
WatchedFieldsMixin,
|
WatchedFieldsMixin,
|
||||||
watch,
|
watch,
|
||||||
)
|
)
|
||||||
from fastapi_toolsets.models.watched import (
|
from fastapi_toolsets.models.watched import (
|
||||||
_SESSION_CREATES,
|
_SESSION_CREATES,
|
||||||
_SESSION_DELETES,
|
_SESSION_DELETES,
|
||||||
_SESSION_UPDATES,
|
|
||||||
_SESSION_PENDING_NEW,
|
_SESSION_PENDING_NEW,
|
||||||
|
_SESSION_UPDATES,
|
||||||
_after_commit,
|
_after_commit,
|
||||||
_after_flush,
|
_after_flush,
|
||||||
_after_flush_postexec,
|
_after_flush_postexec,
|
||||||
@@ -81,8 +82,6 @@ class FullMixinModel(MixinBase, UUIDMixin, UpdatedAtMixin):
|
|||||||
name: Mapped[str] = mapped_column(String(50))
|
name: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
|
||||||
# --- WatchedFieldsMixin test models ---
|
|
||||||
|
|
||||||
_test_events: list[dict] = []
|
_test_events: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
@@ -145,6 +144,66 @@ class NonWatchedModel(MixinBase):
|
|||||||
value: Mapped[str] = mapped_column(String(50))
|
value: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
|
||||||
|
_poly_events: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
class PolyAnimal(MixinBase, UUIDMixin, WatchedFieldsMixin):
|
||||||
|
"""Base class for STI polymorphism tests."""
|
||||||
|
|
||||||
|
__tablename__ = "mixin_poly_animals"
|
||||||
|
__mapper_args__ = {"polymorphic_on": "kind", "polymorphic_identity": "animal"}
|
||||||
|
|
||||||
|
kind: Mapped[str] = mapped_column(String(50))
|
||||||
|
name: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
async def on_create(self) -> None:
|
||||||
|
_poly_events.append(
|
||||||
|
{"event": "create", "type": type(self).__name__, "obj_id": self.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def on_delete(self) -> None:
|
||||||
|
_poly_events.append(
|
||||||
|
{"event": "delete", "type": type(self).__name__, "obj_id": self.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PolyDog(PolyAnimal):
|
||||||
|
"""STI subclass — shares the same table as PolyAnimal."""
|
||||||
|
|
||||||
|
__mapper_args__ = {"polymorphic_identity": "dog"}
|
||||||
|
|
||||||
|
|
||||||
|
_watch_inherit_events: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
|
@watch("status")
|
||||||
|
class WatchParent(MixinBase, UUIDMixin, WatchedFieldsMixin):
|
||||||
|
"""Base class with @watch("status") — subclasses should inherit this filter."""
|
||||||
|
|
||||||
|
__tablename__ = "mixin_watch_parent"
|
||||||
|
__mapper_args__ = {"polymorphic_on": "kind", "polymorphic_identity": "parent"}
|
||||||
|
|
||||||
|
kind: Mapped[str] = mapped_column(String(50))
|
||||||
|
status: Mapped[str] = mapped_column(String(50))
|
||||||
|
other: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
async def on_update(self, changes: dict) -> None:
|
||||||
|
_watch_inherit_events.append({"type": type(self).__name__, "changes": changes})
|
||||||
|
|
||||||
|
|
||||||
|
class WatchChild(WatchParent):
|
||||||
|
"""STI subclass that does NOT redeclare @watch — should inherit parent's filter."""
|
||||||
|
|
||||||
|
__mapper_args__ = {"polymorphic_identity": "child"}
|
||||||
|
|
||||||
|
|
||||||
|
@watch("other")
|
||||||
|
class WatchOverride(WatchParent):
|
||||||
|
"""STI subclass that overrides @watch with a different field."""
|
||||||
|
|
||||||
|
__mapper_args__ = {"polymorphic_identity": "override"}
|
||||||
|
|
||||||
|
|
||||||
_attr_access_events: list[dict] = []
|
_attr_access_events: list[dict] = []
|
||||||
|
|
||||||
|
|
||||||
@@ -172,6 +231,7 @@ class AttrAccessModel(MixinBase, UUIDMixin, WatchedFieldsMixin):
|
|||||||
|
|
||||||
|
|
||||||
_sync_events: list[dict] = []
|
_sync_events: list[dict] = []
|
||||||
|
_future_events: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
@watch("status")
|
@watch("status")
|
||||||
@@ -192,41 +252,33 @@ class SyncCallbackModel(MixinBase, UUIDMixin, WatchedFieldsMixin):
|
|||||||
_sync_events.append({"event": "update", "changes": changes})
|
_sync_events.append({"event": "update", "changes": changes})
|
||||||
|
|
||||||
|
|
||||||
|
class FutureCallbackModel(MixinBase, UUIDMixin, WatchedFieldsMixin):
|
||||||
|
"""Model whose on_create returns an asyncio.Task (awaitable, not a coroutine)."""
|
||||||
|
|
||||||
|
__tablename__ = "mixin_future_callback_models"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(50))
|
||||||
|
|
||||||
|
def on_create(self) -> "asyncio.Task[None]":
|
||||||
|
async def _work() -> None:
|
||||||
|
_future_events.append("created")
|
||||||
|
|
||||||
|
return asyncio.ensure_future(_work())
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
async def mixin_session():
|
async def mixin_session():
|
||||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
async with create_db_session(DATABASE_URL, MixinBase) as session:
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(MixinBase.metadata.create_all)
|
|
||||||
|
|
||||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
||||||
session = session_factory()
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield session
|
yield session
|
||||||
finally:
|
|
||||||
await session.close()
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(MixinBase.metadata.drop_all)
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
@pytest.fixture(scope="function")
|
||||||
async def mixin_session_expire():
|
async def mixin_session_expire():
|
||||||
"""Session with expire_on_commit=True (the default) to exercise attribute access after commit."""
|
"""Session with expire_on_commit=True (the default) to exercise attribute access after commit."""
|
||||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
async with create_db_session(
|
||||||
async with engine.begin() as conn:
|
DATABASE_URL, MixinBase, expire_on_commit=True
|
||||||
await conn.run_sync(MixinBase.metadata.create_all)
|
) as session:
|
||||||
|
|
||||||
session_factory = async_sessionmaker(engine, expire_on_commit=True)
|
|
||||||
session = session_factory()
|
|
||||||
|
|
||||||
try:
|
|
||||||
yield session
|
yield session
|
||||||
finally:
|
|
||||||
await session.close()
|
|
||||||
async with engine.begin() as conn:
|
|
||||||
await conn.run_sync(MixinBase.metadata.drop_all)
|
|
||||||
await engine.dispose()
|
|
||||||
|
|
||||||
|
|
||||||
class TestUUIDMixin:
|
class TestUUIDMixin:
|
||||||
@@ -473,6 +525,67 @@ class TestWatchDecorator:
|
|||||||
watch()
|
watch()
|
||||||
|
|
||||||
|
|
||||||
|
class TestWatchInheritance:
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_events(self):
|
||||||
|
_watch_inherit_events.clear()
|
||||||
|
yield
|
||||||
|
_watch_inherit_events.clear()
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_child_inherits_parent_watch_filter(self, mixin_session):
|
||||||
|
"""Subclass without @watch inherits the parent's field filter."""
|
||||||
|
obj = WatchChild(status="initial", other="x")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
obj.other = "changed" # not watched by parent's @watch("status")
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert _watch_inherit_events == []
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_child_triggers_on_watched_field(self, mixin_session):
|
||||||
|
"""Subclass without @watch triggers on_update for the parent's watched field."""
|
||||||
|
obj = WatchChild(status="initial", other="x")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
obj.status = "updated"
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert len(_watch_inherit_events) == 1
|
||||||
|
assert _watch_inherit_events[0]["type"] == "WatchChild"
|
||||||
|
assert "status" in _watch_inherit_events[0]["changes"]
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_subclass_override_takes_precedence(self, mixin_session):
|
||||||
|
"""Subclass @watch overrides the parent's field filter."""
|
||||||
|
obj = WatchOverride(status="initial", other="x")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
obj.status = (
|
||||||
|
"changed" # watched by parent but overridden by child's @watch("other")
|
||||||
|
)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert _watch_inherit_events == []
|
||||||
|
|
||||||
|
obj.other = "changed"
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert len(_watch_inherit_events) == 1
|
||||||
|
assert "other" in _watch_inherit_events[0]["changes"]
|
||||||
|
|
||||||
|
|
||||||
class TestUpsertChanges:
|
class TestUpsertChanges:
|
||||||
def test_inserts_new_entry(self):
|
def test_inserts_new_entry(self):
|
||||||
"""New key is inserted with the full changes dict."""
|
"""New key is inserted with the full changes dict."""
|
||||||
@@ -871,6 +984,119 @@ class TestWatchedFieldsMixin:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestTransientObject:
|
||||||
|
"""Create + delete within the same transaction should fire no events."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_events(self):
|
||||||
|
_test_events.clear()
|
||||||
|
yield
|
||||||
|
_test_events.clear()
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_no_events_when_created_and_deleted_in_same_transaction(
|
||||||
|
self, mixin_session
|
||||||
|
):
|
||||||
|
"""Neither on_create nor on_delete fires when the object never survives a commit."""
|
||||||
|
obj = WatchedModel(status="active", other="x")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.flush()
|
||||||
|
await mixin_session.delete(obj)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert _test_events == []
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_other_objects_unaffected(self, mixin_session):
|
||||||
|
"""on_create still fires for objects that are not deleted in the same transaction."""
|
||||||
|
survivor = WatchedModel(status="active", other="x")
|
||||||
|
transient = WatchedModel(status="gone", other="y")
|
||||||
|
mixin_session.add(survivor)
|
||||||
|
mixin_session.add(transient)
|
||||||
|
await mixin_session.flush()
|
||||||
|
await mixin_session.delete(transient)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
creates = [e for e in _test_events if e["event"] == "create"]
|
||||||
|
deletes = [e for e in _test_events if e["event"] == "delete"]
|
||||||
|
assert len(creates) == 1
|
||||||
|
assert creates[0]["obj_id"] == survivor.id
|
||||||
|
assert deletes == []
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_distinct_create_and_delete_both_fire(self, mixin_session):
|
||||||
|
"""on_create and on_delete both fire when different objects are created and deleted."""
|
||||||
|
existing = WatchedModel(status="old", other="x")
|
||||||
|
mixin_session.add(existing)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
_test_events.clear()
|
||||||
|
|
||||||
|
new_obj = WatchedModel(status="new", other="y")
|
||||||
|
mixin_session.add(new_obj)
|
||||||
|
await mixin_session.delete(existing)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
creates = [e for e in _test_events if e["event"] == "create"]
|
||||||
|
deletes = [e for e in _test_events if e["event"] == "delete"]
|
||||||
|
assert len(creates) == 1
|
||||||
|
assert len(deletes) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestPolymorphism:
|
||||||
|
"""WatchedFieldsMixin with STI (Single Table Inheritance)."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_events(self):
|
||||||
|
_poly_events.clear()
|
||||||
|
yield
|
||||||
|
_poly_events.clear()
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_on_create_fires_once_for_subclass(self, mixin_session):
|
||||||
|
"""on_create fires exactly once for a STI subclass instance."""
|
||||||
|
dog = PolyDog(name="Rex")
|
||||||
|
mixin_session.add(dog)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert len(_poly_events) == 1
|
||||||
|
assert _poly_events[0]["event"] == "create"
|
||||||
|
assert _poly_events[0]["type"] == "PolyDog"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_on_delete_fires_for_subclass(self, mixin_session):
|
||||||
|
"""on_delete fires for a STI subclass instance."""
|
||||||
|
dog = PolyDog(name="Rex")
|
||||||
|
mixin_session.add(dog)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
_poly_events.clear()
|
||||||
|
|
||||||
|
await mixin_session.delete(dog)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert len(_poly_events) == 1
|
||||||
|
assert _poly_events[0]["event"] == "delete"
|
||||||
|
assert _poly_events[0]["type"] == "PolyDog"
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_transient_subclass_fires_no_events(self, mixin_session):
|
||||||
|
"""Create + delete of a STI subclass in one transaction fires no events."""
|
||||||
|
dog = PolyDog(name="Rex")
|
||||||
|
mixin_session.add(dog)
|
||||||
|
await mixin_session.flush()
|
||||||
|
await mixin_session.delete(dog)
|
||||||
|
await mixin_session.commit()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert _poly_events == []
|
||||||
|
|
||||||
|
|
||||||
class TestWatchAll:
|
class TestWatchAll:
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def clear_events(self):
|
def clear_events(self):
|
||||||
@@ -968,6 +1194,28 @@ class TestSyncCallbacks:
|
|||||||
assert updates[0]["changes"]["status"] == {"old": "initial", "new": "updated"}
|
assert updates[0]["changes"]["status"] == {"old": "initial", "new": "updated"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestFutureCallbacks:
|
||||||
|
"""Callbacks returning a non-coroutine awaitable (asyncio.Task / Future)."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_events(self):
|
||||||
|
_future_events.clear()
|
||||||
|
yield
|
||||||
|
_future_events.clear()
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_task_callback_is_awaited(self, mixin_session):
|
||||||
|
"""on_create returning an asyncio.Task is awaited and its work completes."""
|
||||||
|
obj = FutureCallbackModel(name="test")
|
||||||
|
mixin_session.add(obj)
|
||||||
|
await mixin_session.commit()
|
||||||
|
# Two turns: one for _run() to execute, one for the inner _work() task.
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert _future_events == ["created"]
|
||||||
|
|
||||||
|
|
||||||
class TestAttributeAccessInCallbacks:
|
class TestAttributeAccessInCallbacks:
|
||||||
"""Verify that self attributes are accessible inside every callback type.
|
"""Verify that self attributes are accessible inside every callback type.
|
||||||
|
|
||||||
|
|||||||
2
uv.lock
generated
2
uv.lock
generated
@@ -251,7 +251,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi-toolsets"
|
name = "fastapi-toolsets"
|
||||||
version = "2.4.1"
|
version = "2.4.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asyncpg" },
|
{ name = "asyncpg" },
|
||||||
|
|||||||
Reference in New Issue
Block a user