mirror of
https://github.com/d3vyce/fastapi-toolsets.git
synced 2026-09-19 19:29:55 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59fcbdae50 |
+1
-1
@@ -54,7 +54,7 @@ db = Database(
|
||||
|
||||
## Committing before the response
|
||||
|
||||
[`db.install(app)`](../reference/db.md#fastapi_toolsets.db.Database) adds a middleware that commits the request's session when the response starts, after the endpoint returns and before the body is sent. The dependency commits only if the middleware did not: when a function-scoped dependency unwinds before the response, or when the response never passes through the middleware. Either way the request is committed exactly once.
|
||||
[`db.install(app)`](../reference/db.md#fastapi_toolsets.db.Database) adds a middleware that commits the request's session when the response starts, after the endpoint returns and before the body is sent. With the middleware installed, the dependency does not commit again.
|
||||
|
||||
The request is committed as a single transaction:
|
||||
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ exclude = ["*.md"]
|
||||
extend-select = ["E712"]
|
||||
|
||||
[tool.ruff.lint.flake8-bugbear]
|
||||
extend-immutable-calls = ["fastapi.Depends", "fastapi.Security"]
|
||||
extend-immutable-calls = ["fastapi.Depends"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["RUF012", "RUF059", "SIM117", "DTZ001", "S110", "BLE001"]
|
||||
|
||||
@@ -160,11 +160,9 @@ def build_search_filters(
|
||||
column = field
|
||||
|
||||
# Build the filter (cast to String only when needed, to preserve
|
||||
# pg_trgm GIN index usability on already-String columns).
|
||||
# pg_trgm GIN index usability on already-String columns)
|
||||
column_as_string = (
|
||||
column
|
||||
if isinstance(column.type, String) and not isinstance(column.type, Enum)
|
||||
else column.cast(String)
|
||||
column if isinstance(column.type, String) else column.cast(String)
|
||||
)
|
||||
if config.case_sensitive:
|
||||
filters.append(column_as_string.like(f"%{query}%"))
|
||||
|
||||
@@ -66,8 +66,10 @@ class _CommitOnResponseMiddleware:
|
||||
|
||||
async def send_wrapper(message: Message) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
# ``scope["state"]`` is the same dict ``request.state`` writes
|
||||
# to, so this is the session stashed by the dependency.
|
||||
state = scope.get("state")
|
||||
session = state.pop(self.state_attr, None) if state else None
|
||||
session = state.get(self.state_attr) if state else None
|
||||
if session is not None and session.in_transaction():
|
||||
await session.commit()
|
||||
await send(message)
|
||||
@@ -156,6 +158,7 @@ class Database:
|
||||
# Private, per-instance state attribute; cannot collide with another
|
||||
# Database or be mismatched against the middleware.
|
||||
self._state_attr = f"_ft_db_session_{id(self):x}"
|
||||
self._middleware_installed = False
|
||||
self._disposed = False
|
||||
|
||||
async def _dispose(self) -> None:
|
||||
@@ -203,6 +206,7 @@ class Database:
|
||||
```
|
||||
"""
|
||||
app.add_middleware(_CommitOnResponseMiddleware, state_attr=self._state_attr)
|
||||
self._middleware_installed = True
|
||||
|
||||
inner_lifespan = app.router.lifespan_context
|
||||
|
||||
@@ -239,17 +243,10 @@ class Database:
|
||||
return await UserCrud.get(session, [User.id == user_id])
|
||||
```
|
||||
"""
|
||||
borrowed = getattr(request.state, self._state_attr, None)
|
||||
if borrowed is not None:
|
||||
yield borrowed
|
||||
return
|
||||
async with self._open() as session:
|
||||
setattr(request.state, self._state_attr, session)
|
||||
yield session
|
||||
if (
|
||||
getattr(request.state, self._state_attr, None) is session
|
||||
and session.in_transaction()
|
||||
):
|
||||
if not self._middleware_installed and session.in_transaction():
|
||||
await session.commit()
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
@@ -8,7 +8,6 @@ from typing import Any
|
||||
from sqlalchemy import event, select, tuple_
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.orm.attributes import set_committed_value as _sa_set_committed_value
|
||||
|
||||
from ..logger import get_logger
|
||||
@@ -190,44 +189,17 @@ async def _invoke_callback(
|
||||
await result
|
||||
|
||||
|
||||
def _loaded_relationships(obj: Any) -> set[str]:
|
||||
"""Relationship keys currently loaded on *obj*."""
|
||||
state = sa_inspect(obj)
|
||||
unloaded = state.unloaded
|
||||
return {
|
||||
rel.key
|
||||
for rel in state.mapper.relationships
|
||||
if rel.key not in unloaded and rel.lazy not in ("dynamic", "write_only")
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_loaded_relationships(session: Any) -> dict[int, set[str]]:
|
||||
"""Record loaded relationships for the tracked objects, keyed by ``id``."""
|
||||
objs = list(session.info.get(_SESSION_CREATES, []))
|
||||
objs += [obj for obj, _ in session.info.get(_SESSION_UPDATES, {}).values()]
|
||||
return {id(obj): _loaded_relationships(obj) for obj in objs}
|
||||
|
||||
|
||||
async def _batch_reload(
|
||||
session: AsyncSession,
|
||||
model: type,
|
||||
objs: list[Any],
|
||||
preloaded: dict[int, set[str]],
|
||||
session: AsyncSession, model: type, pk_tuples: list[tuple[Any, ...]]
|
||||
) -> None:
|
||||
"""Re-populate all rows of *model* in one round trip."""
|
||||
"""Re-populate all rows of *model* identified by *pk_tuples* in one round trip."""
|
||||
pk_cols = sa_inspect(model, raiseerr=True).primary_key
|
||||
pk_tuples = [sa_inspect(obj).key[1] for obj in objs]
|
||||
where = (
|
||||
pk_cols[0].in_([pk[0] for pk in pk_tuples])
|
||||
if len(pk_cols) == 1
|
||||
else tuple_(*pk_cols).in_(pk_tuples)
|
||||
)
|
||||
q = select(model).where(where).execution_options(populate_existing=True)
|
||||
loaded: set[str] = set()
|
||||
for obj in objs:
|
||||
loaded |= preloaded.get(id(obj), set())
|
||||
if loaded:
|
||||
q = q.options(*(selectinload(getattr(model, key)) for key in loaded))
|
||||
await session.execute(q)
|
||||
|
||||
|
||||
@@ -235,7 +207,6 @@ class EventSession(AsyncSession):
|
||||
"""AsyncSession subclass that dispatches lifecycle callbacks after commit."""
|
||||
|
||||
async def commit(self) -> None:
|
||||
preloaded = _snapshot_loaded_relationships(self)
|
||||
await super().commit()
|
||||
|
||||
creates: list[Any] = self.info.pop(_SESSION_CREATES, [])
|
||||
@@ -278,25 +249,25 @@ class EventSession(AsyncSession):
|
||||
# session.get() per object.
|
||||
create_items: list[Any] = []
|
||||
update_items: list[tuple[Any, dict[str, dict[str, Any]]]] = []
|
||||
objs_by_type: dict[type, list[Any]] = {}
|
||||
pk_by_type: dict[type, list[tuple[Any, ...]]] = {}
|
||||
|
||||
for obj in creates:
|
||||
state = sa_inspect(obj, raiseerr=False)
|
||||
if state is None or state.detached or state.transient: # pragma: no cover
|
||||
continue
|
||||
create_items.append(obj)
|
||||
objs_by_type.setdefault(type(obj), []).append(obj)
|
||||
pk_by_type.setdefault(type(obj), []).append(state.key[1])
|
||||
|
||||
for obj, changes in field_changes.values():
|
||||
state = sa_inspect(obj, raiseerr=False)
|
||||
if state is None or state.detached or state.transient: # pragma: no cover
|
||||
continue
|
||||
update_items.append((obj, changes))
|
||||
objs_by_type.setdefault(type(obj), []).append(obj)
|
||||
pk_by_type.setdefault(type(obj), []).append(state.key[1])
|
||||
|
||||
for model, objs in objs_by_type.items():
|
||||
for model, pk_tuples in pk_by_type.items():
|
||||
try:
|
||||
await _batch_reload(self, model, objs, preloaded)
|
||||
await _batch_reload(self, model, pk_tuples)
|
||||
except Exception as exc:
|
||||
_logger.error(_CALLBACK_ERROR_MSG, exc_info=exc)
|
||||
|
||||
|
||||
@@ -388,76 +388,6 @@ class TestBuildSearchFilters:
|
||||
|
||||
assert "CAST" in str(filters[0])
|
||||
|
||||
def test_casts_enum_column(self):
|
||||
"""Enum subclasses String but maps to a native DB enum, which has no ILIKE."""
|
||||
from fastapi_toolsets.crud.search import build_search_filters
|
||||
|
||||
filters, _ = build_search_filters(Order, "PEND", search_fields=[Order.status])
|
||||
|
||||
assert "CAST" in str(filters[0])
|
||||
|
||||
|
||||
class TestSearchEnumColumn:
|
||||
"""Searching an enum column must reach the database, not just build SQL."""
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_int_backed_enum(self, db_session: AsyncSession):
|
||||
"""Enum(int, Enum) stores names, so the cast makes 'PEND' match PENDING."""
|
||||
await OrderCrud.create(
|
||||
db_session, OrderCreate(name="a", status=OrderStatus.PENDING)
|
||||
)
|
||||
await OrderCrud.create(
|
||||
db_session, OrderCreate(name="b", status=OrderStatus.SHIPPED)
|
||||
)
|
||||
|
||||
result = await OrderCrud.offset_paginate(
|
||||
db_session,
|
||||
search="PEND",
|
||||
search_fields=[Order.status],
|
||||
schema=OrderRead,
|
||||
)
|
||||
|
||||
assert result.pagination.total_count == 1
|
||||
assert result.data[0].status is OrderStatus.PENDING
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_str_backed_enum(self, db_session: AsyncSession):
|
||||
"""Same for Enum(str, Enum) — still a native DB enum, still needs the cast."""
|
||||
await OrderCrud.create(
|
||||
db_session,
|
||||
OrderCreate(name="a", status=OrderStatus.PENDING, color=Color.BLUE),
|
||||
)
|
||||
await OrderCrud.create(
|
||||
db_session,
|
||||
OrderCreate(name="b", status=OrderStatus.PENDING, color=Color.RED),
|
||||
)
|
||||
|
||||
result = await OrderCrud.offset_paginate(
|
||||
db_session,
|
||||
search="BLU",
|
||||
search_fields=[Order.color],
|
||||
schema=OrderRead,
|
||||
)
|
||||
|
||||
assert result.pagination.total_count == 1
|
||||
assert result.data[0].color is Color.BLUE
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_search_mixed_enum_and_string_columns(self, db_session: AsyncSession):
|
||||
"""An enum column alongside a plain String column (the get_searchable_fields shape)."""
|
||||
await OrderCrud.create(
|
||||
db_session, OrderCreate(name="widget", status=OrderStatus.SHIPPED)
|
||||
)
|
||||
|
||||
result = await OrderCrud.offset_paginate(
|
||||
db_session,
|
||||
search="widget",
|
||||
search_fields=[Order.name, Order.status, Order.color],
|
||||
schema=OrderRead,
|
||||
)
|
||||
|
||||
assert result.pagination.total_count == 1
|
||||
|
||||
|
||||
class TestSearchConfig:
|
||||
"""Tests for SearchConfig options."""
|
||||
|
||||
+9
-128
@@ -6,7 +6,7 @@ from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import Depends, FastAPI, Security
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.responses import StreamingResponse
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from pydantic import PostgresDsn
|
||||
@@ -287,37 +287,23 @@ class TestDatabaseDependency:
|
||||
break
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_second_resolution_borrows_session(self, engine):
|
||||
"""A second ``Depends(db)`` in one request reuses the stashed session."""
|
||||
db = Database(engine=engine)
|
||||
request = _make_request()
|
||||
async def test_skips_commit_when_middleware_installed(self, engine, session_maker):
|
||||
"""With ``install()``, the dependency must NOT commit — the middleware owns it.
|
||||
|
||||
owner_gen = db(request)
|
||||
owner = await anext(owner_gen)
|
||||
borrower_gen = db(request)
|
||||
assert await anext(borrower_gen) is owner
|
||||
|
||||
with pytest.raises(StopAsyncIteration): # teardown runs borrower-first
|
||||
await anext(borrower_gen)
|
||||
assert owner.in_transaction() # the borrower must not close what it borrowed
|
||||
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
await anext(owner_gen)
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_commits_when_middleware_did_not_run(self, engine, session_maker):
|
||||
"""``install()`` is per-``Database``, but the commit is per-request."""
|
||||
Here no middleware actually runs (we call the dependency directly), so the
|
||||
open transaction is rolled back on session close and nothing persists.
|
||||
"""
|
||||
db = Database(engine=engine)
|
||||
db.install(FastAPI())
|
||||
|
||||
async for session in db(_make_request()):
|
||||
role = Role(name="mw_never_ran")
|
||||
role = Role(name="mw_owns_commit")
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
async with session_maker() as verify:
|
||||
result = await RoleCrud.first(verify, [Role.name == "mw_never_ran"])
|
||||
assert result is not None
|
||||
result = await RoleCrud.first(verify, [Role.name == "mw_owns_commit"])
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestDatabaseSession:
|
||||
@@ -1537,55 +1523,6 @@ def _build_app(db: Database) -> FastAPI:
|
||||
await session.commit()
|
||||
return {"id": str(role.id), "name": role.name}
|
||||
|
||||
async def _scoped_writer(
|
||||
body: RoleCreate, session: AsyncSession = Security(db, scopes=["roles:write"])
|
||||
) -> int:
|
||||
# Security scopes give this a different dependency cache key than the
|
||||
# endpoint's plain ``Depends(db)``. Without borrowing it opens a second
|
||||
# session, and whichever one the middleware does not hold is discarded.
|
||||
await RoleCrud.create(session, RoleCreate(name=f"{body.name}_sub"))
|
||||
return id(session)
|
||||
|
||||
@app.post("/roles-two-cache-keys")
|
||||
async def create_via_two_cache_keys(
|
||||
body: RoleCreate,
|
||||
sub_session_id: int = Depends(_scoped_writer),
|
||||
session: AsyncSession = Depends(db),
|
||||
) -> dict:
|
||||
await RoleCrud.create(session, body)
|
||||
return {"same_session": sub_session_id == id(session)}
|
||||
|
||||
async def _fn_writer(
|
||||
body: RoleCreate, session: AsyncSession = Depends(db, scope="function")
|
||||
) -> None:
|
||||
# ``scope="function"`` unwinds before the response is sent, taking the
|
||||
# session with it — so the commit cannot be left to the middleware.
|
||||
await RoleCrud.create(session, RoleCreate(name=f"{body.name}_fn"))
|
||||
|
||||
@app.post("/roles-function-scope")
|
||||
async def create_with_function_scope(
|
||||
body: RoleCreate,
|
||||
boom: bool = False,
|
||||
_: None = Depends(_fn_writer),
|
||||
session: AsyncSession = Depends(db),
|
||||
) -> dict:
|
||||
await RoleCrud.create(session, body)
|
||||
if boom:
|
||||
raise RuntimeError("boom after write")
|
||||
return {"ok": True}
|
||||
|
||||
@app.post("/roles-function-scope-borrower")
|
||||
async def function_scope_borrows(
|
||||
body: RoleCreate,
|
||||
session: AsyncSession = Depends(db),
|
||||
_: None = Depends(_fn_writer),
|
||||
) -> dict:
|
||||
# Flipped order: the request-scoped dependency owns the session and the
|
||||
# function-scoped one borrows it. The borrower unwinds early but must not
|
||||
# commit or close — the commit still belongs to the middleware.
|
||||
await RoleCrud.create(session, body)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/roles-stream/{name}")
|
||||
async def stream_role(
|
||||
name: str, session: AsyncSession = Depends(db)
|
||||
@@ -1682,62 +1619,6 @@ class TestCommitIntegration:
|
||||
# The write made before the stream began is durably committed.
|
||||
assert await _row_exists(session_maker, "streamed_role")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_two_cache_keys_share_one_session(self, engine, session_maker):
|
||||
"""Two resolutions of ``Depends(db)`` in one request must share a session."""
|
||||
app = _build_app(Database(engine=engine))
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post("/roles-two-cache-keys", json={"name": "two_keys"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["same_session"] is True
|
||||
assert await _row_exists(session_maker, "two_keys")
|
||||
assert await _row_exists(session_maker, "two_keys_sub")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_function_scope_commits_before_response(self, engine, session_maker):
|
||||
"""``scope="function"`` unwinds before response-start, so the dependency
|
||||
commits on its way out instead of leaving it to the middleware."""
|
||||
app = _build_app(Database(engine=engine))
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post("/roles-function-scope", json={"name": "fn_scope"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert await _row_exists(session_maker, "fn_scope")
|
||||
assert await _row_exists(session_maker, "fn_scope_fn")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_function_scope_borrower_leaves_commit_to_middleware(
|
||||
self, engine, session_maker
|
||||
):
|
||||
"""A function-scoped *borrower* unwinds early but owns nothing."""
|
||||
app = _build_app(Database(engine=engine))
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/roles-function-scope-borrower", json={"name": "fn_borrow"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert await _row_exists(session_maker, "fn_borrow")
|
||||
assert await _row_exists(session_maker, "fn_borrow_fn")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_function_scope_error_rolls_back(self, engine, session_maker):
|
||||
"""The early commit must still not fire when the request fails."""
|
||||
app = _build_app(Database(engine=engine))
|
||||
transport = ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(
|
||||
"/roles-function-scope?boom=true", json={"name": "fn_ghost"}
|
||||
)
|
||||
|
||||
assert resp.status_code == 500
|
||||
assert not await _row_exists(session_maker, "fn_ghost")
|
||||
assert not await _row_exists(session_maker, "fn_ghost_fn")
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_multi_write_atomicity(self, engine, session_maker):
|
||||
"""When the 2nd write fails, the 1st must roll back too (one txn)."""
|
||||
|
||||
+6
-90
@@ -6,16 +6,9 @@ from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import ForeignKey, String, select
|
||||
from sqlalchemy import inspect as sa_inspect
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import (
|
||||
DeclarativeBase,
|
||||
Mapped,
|
||||
mapped_column,
|
||||
relationship,
|
||||
selectinload,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
import fastapi_toolsets.models.watched as _watched_module
|
||||
from fastapi_toolsets.models import (
|
||||
@@ -114,27 +107,6 @@ async def _watched_on_update(obj, event_type, changes):
|
||||
_test_events.append({"event": "update", "obj_id": obj.id, "changes": changes})
|
||||
|
||||
|
||||
class RelTarget(MixinBase, UUIDMixin):
|
||||
__tablename__ = "mixin_rel_targets"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(50))
|
||||
|
||||
|
||||
class RelOwner(MixinBase, UUIDMixin):
|
||||
"""Watched model with a relationship, to check eager loads survive commit."""
|
||||
|
||||
__tablename__ = "mixin_rel_owners"
|
||||
|
||||
title: Mapped[str] = mapped_column(String(50))
|
||||
target_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mixin_rel_targets.id"))
|
||||
target: Mapped[RelTarget] = relationship()
|
||||
|
||||
|
||||
@listens_for(RelOwner, [ModelEvent.CREATE, ModelEvent.UPDATE])
|
||||
async def _rel_owner_handler(obj, event_type, changes):
|
||||
_test_events.append({"event": event_type.value, "obj_id": obj.id})
|
||||
|
||||
|
||||
class WatchAllModel(MixinBase, UUIDMixin):
|
||||
"""Model without __watched_fields__ — watches all mapped fields by default."""
|
||||
|
||||
@@ -383,62 +355,6 @@ async def mixin_session_maker():
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
class TestEventSessionPreservesEagerLoads:
|
||||
"""EventSession.commit() must not discard relations an eager load populated."""
|
||||
|
||||
async def _seed_eager(self, session):
|
||||
target = RelTarget(name="t")
|
||||
session.add(target)
|
||||
await session.flush()
|
||||
owner = RelOwner(title="o", target_id=target.id)
|
||||
session.add(owner)
|
||||
await session.flush()
|
||||
loaded = (
|
||||
await session.execute(
|
||||
select(RelOwner)
|
||||
.where(RelOwner.id == owner.id)
|
||||
.options(selectinload(RelOwner.target))
|
||||
)
|
||||
).scalar_one()
|
||||
assert "target" not in sa_inspect(loaded).unloaded
|
||||
return loaded
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_eager_load_survives_commit(self, mixin_session):
|
||||
"""expire_on_commit=False: the reload must not expire the relation."""
|
||||
owner = await self._seed_eager(mixin_session)
|
||||
|
||||
await mixin_session.commit()
|
||||
|
||||
assert "target" not in sa_inspect(owner).unloaded
|
||||
assert owner.target.name == "t"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_eager_load_survives_commit_expire_on_commit(
|
||||
self, mixin_session_expire
|
||||
):
|
||||
"""expire_on_commit=True: what was loaded must be recorded before the commit."""
|
||||
owner = await self._seed_eager(mixin_session_expire)
|
||||
|
||||
await mixin_session_expire.commit()
|
||||
|
||||
assert "target" not in sa_inspect(owner).unloaded
|
||||
assert owner.target.name == "t"
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unloaded_relation_stays_unloaded(self, mixin_session):
|
||||
"""Only what was loaded is restored: the reload must not eager-load extra."""
|
||||
target = RelTarget(name="t")
|
||||
mixin_session.add(target)
|
||||
await mixin_session.flush()
|
||||
owner = RelOwner(title="o", target_id=target.id)
|
||||
mixin_session.add(owner)
|
||||
|
||||
await mixin_session.commit()
|
||||
|
||||
assert "target" in sa_inspect(owner).unloaded
|
||||
|
||||
|
||||
class TestUUIDMixin:
|
||||
@pytest.mark.anyio
|
||||
async def test_uuid_generated_by_db(self, mixin_session):
|
||||
@@ -1097,10 +1013,10 @@ class TestEventCallbacks:
|
||||
|
||||
real_batch_reload = _watched_module._batch_reload
|
||||
|
||||
async def racing_batch_reload(session, model, objs, preloaded):
|
||||
if any(getattr(o, "id", None) == doomed_id for o in objs):
|
||||
async def racing_batch_reload(session, model, pk_tuples):
|
||||
if any(pk[0] == doomed_id for pk in pk_tuples):
|
||||
await kill_doomed_row_once()
|
||||
return await real_batch_reload(session, model, objs, preloaded)
|
||||
return await real_batch_reload(session, model, pk_tuples)
|
||||
|
||||
# Patch the batched reload EventSession.commit() uses to pick up
|
||||
# server defaults, so this test still exercises the race.
|
||||
@@ -1123,7 +1039,7 @@ class TestEventCallbacks:
|
||||
obj = WatchedModel(status="active", other="x")
|
||||
mixin_session.add(obj)
|
||||
|
||||
async def failing_batch_reload(session, model, objs, preloaded):
|
||||
async def failing_batch_reload(session, model, pk_tuples):
|
||||
raise RuntimeError("reload failed")
|
||||
|
||||
with (
|
||||
|
||||
@@ -973,15 +973,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pymdown-extensions"
|
||||
version = "11.0.1"
|
||||
version = "11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/a9/5f0c535ba3b08fe09270c16808e053a968868242ecbd5676d4e3a488bf28/pymdown_extensions-11.0.1.tar.gz", hash = "sha256:dd2905ae6fc5b75582fafb139a1266ffc754705efa902aa50067fa7ff4f94ec0", size = 857113, upload-time = "2026-07-02T17:59:22.955Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/67/f1e79672a5f91985577c7984c9709ca110e4fd37fe7fd167b60422e6ccc2/pymdown_extensions-11.0.tar.gz", hash = "sha256:8269cef0247f9e2d0a62fcea10860aba05c1cbab5470fd4b63230b96434dc589", size = 857049, upload-time = "2026-06-23T02:27:45.146Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/54/da572c98c0b77626a91b5d3b89f0231d8bff5125c225420908632f8b342d/pymdown_extensions-11.0.1-py3-none-any.whl", hash = "sha256:db3943a62bab7e03af1364f0c4083e64b91fb097675a4b6cceccfbe9a77e5eb2", size = 269455, upload-time = "2026-07-02T17:59:21.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl", hash = "sha256:fbc4acb641814fa9d17521bbd21a5240ef739a662f11c06330c4b78c93e954d6", size = 269415, upload-time = "2026-06-23T02:27:43.826Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1307,27 +1307,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.64"
|
||||
version = "0.0.65"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/aa/14c9965d3b173105692473897cc89c34cd91241368b2044e43167e1c17ff/ty-0.0.64.tar.gz", hash = "sha256:d12ddbb05f15158bb518af619378b385486450def95fb06f8ab98037febe9f2c", size = 6350966, upload-time = "2026-07-27T18:32:45.403Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cf561927e8e9ab5c1892a833b664aa9cd6f051a75f6280c66d8047246bda/ty-0.0.65.tar.gz", hash = "sha256:b7134bffcc00b715fa8291e84d845782ced810a998dc1f7f11d71c85c4046325", size = 6460098, upload-time = "2026-07-29T18:31:03.27Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/4c/c54937e4ff3fa7b34a99ea3387ec766bf0ad98dc8df8d792e89b388e658e/ty-0.0.64-py3-none-linux_armv6l.whl", hash = "sha256:3830a6675ab43635ced1c4c557f380ac4a49e9414e03975e4e4e8db644c64944", size = 12118357, upload-time = "2026-07-27T18:32:08.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/aa/a839ee2bc78e943d079e6abe199a97b4eeffb7e5c9a57326d69de452186a/ty-0.0.64-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3ff07d7bc32a2135f58afe57393789a32ea2fed1a66216129a8e535e76043903", size = 11790882, upload-time = "2026-07-27T18:32:10.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/de/19f14357888a7198438926303753cf749428e3d62e8980ff1e9a72a78402/ty-0.0.64-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4f6d1c7f897cca05d12bacbf1435150d5ffa496099515aa6ed303c8b29e1d0bb", size = 11317394, upload-time = "2026-07-27T18:32:13.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/2f/f54462300535ab99b551eda733177be2eef5dbc2997d3fdb357c4ddd760a/ty-0.0.64-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:138d6c37ad4bf8583aa7a9b29d90954151d7856f9910a03eae3eb34b34c57215", size = 11863042, upload-time = "2026-07-27T18:32:15.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/95/dbecf745520ebe8bd7b02fc55eee6441c9be312ebf6addce605eb52740dd/ty-0.0.64-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ed9719d1b7b66fb8efe073d860208a44c40af1f6cd5c2364aa9b323a1e579b4", size = 11910730, upload-time = "2026-07-27T18:32:17.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/26/12cfd40028e51ceed7b3cb645281c61c02eb64ff9fb0c09231d65c30ff25/ty-0.0.64-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68b23e5169e2137b5f1de7169ab0cebecab6c8eda374c34c1f6394308f58242", size = 12631936, upload-time = "2026-07-27T18:32:19.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/d0/65ffc2b0a686347193c6f98e9421a7fc2a96fc3cd0b1001cf7cf284baff9/ty-0.0.64-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f41cb07d89d32626fcaf3ed4d262778fcb28b2628b6ed1e172cd7b18820668d8", size = 13171049, upload-time = "2026-07-27T18:32:22.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/a4/975a5961842dcd6fa60f0770a102c0bba7509da909ab652919bfcdcd4fc7/ty-0.0.64-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5f24e1504ab9e212f92356b82fe088fdeb3a39f9a2f4ff25e505d2e1d0db9056", size = 12826438, upload-time = "2026-07-27T18:32:24.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/ef/dfb9b7f9bcc032d3b540b0d1f55f532a336e2fb41b1bd539c05ae81a151a/ty-0.0.64-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86db830cb914bb33bb8247b66ccea58de4496c2391bd42658aba744b439f3290", size = 12440880, upload-time = "2026-07-27T18:32:26.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/d0/bedac20505e8a8f5501ad73d7d15d8e421a563fef59993909a23036929a4/ty-0.0.64-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:652ee3d6d03bea76cd2fe8949c78bb5970394ebd7c5fd270e9518c0dee1b931d", size = 12782439, upload-time = "2026-07-27T18:32:28.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/d5/795733f13ceff1378f08b3de0c49d0f518df220ed856b0dfac869f3b7c81/ty-0.0.64-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4838768295774a86e95f9ec5633e739d016adbbb69dcbdc62f3549578a11f624", size = 11814821, upload-time = "2026-07-27T18:32:30.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/3e/9d99cd1e1831003434f508ed9f258a56543194afc3bbe051eba2545fa676/ty-0.0.64-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5a3d700669868599edf39ce5125196682f15a8880cc8c669bc2a83b99984d99b", size = 11928678, upload-time = "2026-07-27T18:32:32.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/3d/448f49a3503fb119a34348a5714bb92001f252fadbbb12d645f2e744b557/ty-0.0.64-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b161f0a82a8e2f2432db3bf7702b4d3924fa9486ba0014f6710a160fc157df0d", size = 12202249, upload-time = "2026-07-27T18:32:34.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/9b/75768e562cec990d189dc05807ae72890b20ffbd1e1f43597bb98db63c60/ty-0.0.64-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:39b9dd42908df47c2dc57dda87e656fab97097ffd2618474bbdea986af0d6a9d", size = 12548817, upload-time = "2026-07-27T18:32:36.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/89/44cc276ea6ca0245495014758ffeadde1798fb0b5840c9cf36d8d2ed3250/ty-0.0.64-py3-none-win32.whl", hash = "sha256:d0676ab0e0935795e5843baa28dd5e366dc343d7c0945a996df9eab8e0644885", size = 11545474, upload-time = "2026-07-27T18:32:39.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/7e/d1c8a871a38d17c8f168b9a6975f6247f7660f8334e517656a5e4b4a4858/ty-0.0.64-py3-none-win_amd64.whl", hash = "sha256:dcb9bd31f54097e362b776c26ab4564d4564cdd1355cb883481167a26c03cc3f", size = 12542987, upload-time = "2026-07-27T18:32:41.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/4d/6d18640d0204cacd69abbaca95ad6a34c6d7e9169e9051d0117b17b827ec/ty-0.0.64-py3-none-win_arm64.whl", hash = "sha256:82cc34c1ad9a8feb6059aef193bebcecc656e548f6fab3d518bcd8b57d198d39", size = 11899263, upload-time = "2026-07-27T18:32:43.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/4e/71e2d325d2b53a1afad81624ad076b2ede413213fc4a18cb05b78c568571/ty-0.0.65-py3-none-linux_armv6l.whl", hash = "sha256:dc556c9f05408bef4c4ef02b2cc382e4e5f797b4b20d64410289848f0d76705f", size = 12298466, upload-time = "2026-07-29T18:30:12.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/77/fec8f29647c55794efa430a7f365e44f5ce7ffb6459d9445a87fac569bec/ty-0.0.65-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:29d2e0d34cc0a28a17ef0cf81135c5ebabc3562131f9079138ba5e7bae0f56bd", size = 11942421, upload-time = "2026-07-29T18:30:16.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/09/7f3766aef9dc627e2698cf4e3e59cf53389dcae3812040d33c1aa931230f/ty-0.0.65-py3-none-macosx_11_0_arm64.whl", hash = "sha256:685f49a9312bbf69d5b65bbb66384fed1f927403ea030c217b9289092d7e46c4", size = 11451922, upload-time = "2026-07-29T18:30:19.155Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/7b/1a77cd50e0befb50f55b8bf9bd3ed3eddf184bf28c61b56727039e0774fc/ty-0.0.65-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f564b5ebe78e2f3a8e7b8eacb1292eb88b7c0f3c8630671cfca31abc0709cd9", size = 11994999, upload-time = "2026-07-29T18:30:22.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/7b/feda16f3a4a0a99be27431e0c9598eeeec0db1eb2fec9a15976698209418/ty-0.0.65-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c983e156fe9e113fb56389e13d327b6b8549fe866de9b269684723a88e9b732d", size = 12090662, upload-time = "2026-07-29T18:30:24.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/3e/3f69bf9c9307dbdc0771719f65ce5b556e7bdeeaccbdd599d4f57866d801/ty-0.0.65-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3663b7396e8b1a9954e20e732de7ccb0192bf4118473069b4945920d6923921", size = 12822094, upload-time = "2026-07-29T18:30:28.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/38/8fa791b3bb503ee2b46ad81690cd1bdd54519582df6d805cee57fe143e85/ty-0.0.65-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:306ed01f29d6e108e98feb233dbbf5878a027603b71bd3743b343977933a9f16", size = 13357833, upload-time = "2026-07-29T18:30:31.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/73/4dda396a201e1dd0ed3594a9b48e559cb41c4bc048c6cd4c4d1b39eb4313/ty-0.0.65-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28bcfc8898c94f079a9100e684bcf312b6a64ad3a7d4ebb35a4591546030a2cd", size = 12977303, upload-time = "2026-07-29T18:30:33.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/26/c250c2c569adc53a8591716641388397bcb2a442e4a30b952ae81b50c0e0/ty-0.0.65-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a75bd0c245c38802a8f488378e74f92feb7dd33db7d63fbdd6fdf82791ba730", size = 12579338, upload-time = "2026-07-29T18:30:37.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/94/4a5647d44753ca218fc930d7e4d9bf468d0ed4a0ad4b3d57588bc1bbacf7/ty-0.0.65-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9e5e1bdea9662d2b5312b4e99f319f4e6e2ea427511b5fbc546141b79ec53f76", size = 12957731, upload-time = "2026-07-29T18:30:39.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/b6/1e22fa11a1e0dfb20b1c7f3cbfd8170273aada2a82f9ecd3055275370c44/ty-0.0.65-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:03a88493d4842889f65280ae241e06b399d57eb3c63571054cad21a4c33b3b69", size = 11938625, upload-time = "2026-07-29T18:30:42.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/0a/fe5f22ef62b193201bc5566762e22049762cd485bfafb5095a7050760054/ty-0.0.65-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:600b8bf6f4940cf7ffb2f43d3716faaf38dcb97cd8617c55771451bc0276408f", size = 12105592, upload-time = "2026-07-29T18:30:45.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/fd/922b3a6e9d697452cdbb4b7e3f636868add5ec652154518a736e4364f3b7/ty-0.0.65-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c28007bc79d648c1ddaf1e65885d07baec48eb87240da442f608e4107c1b7d8", size = 12387335, upload-time = "2026-07-29T18:30:48.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/22/a1a08ebc84c083db2fb55e3b5cd186db0c067692f4921146f601360231e2/ty-0.0.65-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c852da96091ad22361e6586b7c7ba98e1334dcd4d8ffb67e47f4fb673de33f77", size = 12682710, upload-time = "2026-07-29T18:30:51.364Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/14/eaaa410a25bbdea19722109b5422380a0e211b3afcf3071d15953ddbd5db/ty-0.0.65-py3-none-win32.whl", hash = "sha256:cf529d538f1403b14b0511e6ec3cdb95d3d974adabf24cc76cedc533368c3edc", size = 11692341, upload-time = "2026-07-29T18:30:54.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/0f/6d48f206dce9d7e53fe3b5ea0f0ab5800dd9d2365b2b48f736783436c43f/ty-0.0.65-py3-none-win_amd64.whl", hash = "sha256:234a321e33c7cbbfbd67bfa0b01b685dd9c21f1841781a21e5ca1fa0b25f1d5d", size = 12729355, upload-time = "2026-07-29T18:30:57.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/aa/7446f7725e303cf78e058c893af1f0552b9451895454908706f4c6c3494b/ty-0.0.65-py3-none-win_arm64.whl", hash = "sha256:b9424be1ec56d93ff18609fb1c0a0a2283fe1282cd6d1c7604f97d73b94d61f2", size = 12051375, upload-time = "2026-07-29T18:31:00.579Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user