Coverage for fastapi_restly / _pytest_fixtures.py: 60%
403 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-09 18:41 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-10-09 18:41 +0000
1from __future__ import annotations
3import os
4import weakref
5from contextlib import asynccontextmanager, contextmanager
6from pathlib import Path
7from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator
9import pytest
10from fastapi import FastAPI
11from sqlalchemy import Engine, event
12from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, async_sessionmaker
13from sqlalchemy.ext.asyncio import AsyncSession as SA_AsyncSession
14from sqlalchemy.orm import Session as SA_Session
15from sqlalchemy.orm import sessionmaker
16from sqlalchemy.util.concurrency import greenlet_spawn
18from ._test_setup import (
19 DB_CLEANUP_ENV_VAR,
20 DB_CLEANUP_MODES,
21 DELETE,
22 NONE,
23 ROLLBACK,
24 _async_source_factory,
25 _clean_database_async,
26 _clean_database_sync,
27 _create_schema,
28 _current_setup,
29 _is_memory_sqlite,
30 _prepare_database_sources,
31 _resolve_db_cleanup,
32 _resolve_engine,
33 _source_factories,
34 _sync_source_factory,
35 _validate_database_sources,
36)
37from .db._globals import RestlyContext, _fr_globals, _get_restly_context
38from .db._session import _async_generate_session, _generate_session
39from .exc import RestlyConfigurationError
41if TYPE_CHECKING:
42 from .testing._client import AsyncRestlyTestClient, RestlyTestClient
44try:
45 import pytest_asyncio
46except ModuleNotFoundError as exc:
47 if exc.name != "pytest_asyncio":
48 raise
49 pytest_asyncio = None
51_TESTING_EXTRA_MESSAGE = (
52 "fastapi_restly.pytest_fixtures requires optional testing dependencies. "
53 'Install them with: pip install "fastapi-restly[testing]"'
54)
57async def _dispose_async_test_engine(
58 engine: AsyncEngine, *, former_pool: Any | None = None
59) -> None:
60 """Empty a test engine's pool before its current event loop goes away.
62 SQLAlchemy requires a pooled ``AsyncEngine`` to be disposed before it is
63 reused on another loop. Restly creates those loop boundaries, so it also
64 closes the checked-in connections it used. In-memory SQLite is the one
65 exception: its single connection is the database itself.
66 """
67 if not _is_memory_sqlite(engine.url):
68 current_pool = engine.sync_engine.pool
69 if former_pool is not None and former_pool is not current_pool: 69 ↛ 74line 69 didn't jump to line 74 because the condition on line 69 was always true
70 # Application shutdown may have called engine.dispose() while the
71 # test's connection was checked out. SQLAlchemy replaces the pool;
72 # dispose the former pool after our connection has returned to it.
73 await greenlet_spawn(former_pool.dispose)
74 await engine.dispose()
77def _find_project_root(start: Path) -> Path:
78 """Walk up from ``start`` to the nearest ancestor holding a ``pyproject.toml``."""
79 for candidate in (start, *start.parents):
80 if (candidate / "pyproject.toml").exists():
81 return candidate
82 raise Exception(
83 f"Could not find a pyproject.toml at or above {start} to establish "
84 "the project root"
85 )
88@pytest.fixture
89def restly_project_root(request: pytest.FixtureRequest) -> Path:
90 """Return the root of the project that owns the requesting test.
92 Walks up from the requesting test file to the nearest ancestor directory
93 that holds a ``pyproject.toml``. Discovery is anchored to the test file, not
94 the working directory, so it returns the same root no matter where pytest
95 was invoked, and in a monorepo each test resolves to its own sub-project's
96 root. Use it to locate project files (migration configs, test data) without
97 hardcoding absolute paths.
98 """
99 return _find_project_root(request.path.parent)
102# Test engines whose pysqlite legacy-transaction shim has already been neutralised.
103_sqlite_savepoint_fixed: weakref.WeakSet = weakref.WeakSet()
106def _install_sqlite_savepoint_fix(engine: Engine | AsyncEngine) -> None:
107 """Neutralise pysqlite's legacy transaction shim on a test engine.
109 stdlib ``sqlite3`` emulates PEP 249 by sniffing SQL keywords and issuing an
110 implicit ``BEGIN``, which turns ``RELEASE`` of the outermost ``SAVEPOINT``
111 into a real commit. Under ``create_savepoint`` isolation that would leak
112 committed test data past the outer-transaction rollback (measured on
113 aiosqlite). Hand transaction control to SQLAlchemy: disable the shim and emit
114 ``BEGIN`` explicitly, so every ``SAVEPOINT`` nests inside a real transaction.
116 Fixtures-only, sqlite-only, idempotent per engine. Deliberately NOT applied to
117 production engines (fznb.12): an adopter may pass their own engine, so an
118 engine-wide fix could never be complete, and it would change production
119 locking and DDL semantics. On Python 3.12+ the connect handler is a one-liner
120 (``dbapi_connection.autocommit = False``); the ``isolation_level = None`` form
121 stays while ``requires-python`` still includes 3.10/3.11.
122 """
123 sync_engine = engine.sync_engine if isinstance(engine, AsyncEngine) else engine
124 if sync_engine.dialect.name != "sqlite": 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true
125 return
126 if sync_engine in _sqlite_savepoint_fixed: 126 ↛ 127line 126 didn't jump to line 127 because the condition on line 126 was never true
127 return
128 _sqlite_savepoint_fixed.add(sync_engine)
130 @event.listens_for(sync_engine, "connect")
131 def _disable_legacy_transaction_control(dbapi_connection, connection_record):
132 dbapi_connection.isolation_level = None
134 @event.listens_for(sync_engine, "begin")
135 def _emit_begin(conn):
136 conn.exec_driver_sql("BEGIN")
139def pytest_addoption(parser: pytest.Parser) -> None:
140 """Register ``--restly-db-cleanup``, which outranks the argument and the
141 environment so a debugging run can switch mode without editing the suite."""
142 parser.addoption(
143 "--restly-db-cleanup",
144 dest="restly_db_cleanup",
145 choices=list(DB_CLEANUP_MODES),
146 default=None,
147 help=(
148 "How Restly gives each test a clean database. 'rollback' (the "
149 "default) rolls every test back and persists nothing; 'delete' "
150 "empties the tables before each test and lets writes commit, so the "
151 "last test's rows survive the run and can be inspected; 'none' leaves "
152 "cleaning to the suite."
153 ),
154 )
157# The flag or environment override, resolved once. A property of the run, not of
158# a test: reading the environment again per test would let the mode announced in
159# the header and the mode enforced during the run disagree.
160_db_cleanup_override: str | None = None
163#: Overrides of runs that started before this one, innermost last. A nested
164#: in-process run must give the outer run its mode back rather than clear it.
165_override_stack: list[str | None] = []
168def pytest_configure(config: pytest.Config) -> None:
169 global _db_cleanup_override
170 _override_stack.append(_db_cleanup_override)
171 chosen = config.getoption("restly_db_cleanup")
172 if not isinstance(chosen, str):
173 chosen = os.environ.get(DB_CLEANUP_ENV_VAR) or None
174 if chosen is not None and chosen not in DB_CLEANUP_MODES:
175 # Raised here rather than from a later hook, where pytest would
176 # report it as an INTERNALERROR with a pluggy traceback.
177 raise pytest.UsageError(
178 f"{DB_CLEANUP_ENV_VAR} is {chosen!r}; expected one of "
179 f"{', '.join(repr(mode) for mode in DB_CLEANUP_MODES)}."
180 )
181 _db_cleanup_override = chosen
184def pytest_unconfigure(config: pytest.Config) -> None:
185 # Restore rather than clear: a nested in-process run must neither leak its
186 # mode outward nor take the outer run's away.
187 global _db_cleanup_override
188 _db_cleanup_override = _override_stack.pop() if _override_stack else None
191def pytest_report_header(config: pytest.Config) -> str | None:
192 """Announce a non-default cleanup mode, so a stale flag or environment
193 variable cannot quietly change what the suite does."""
194 mode = _cleanup_mode()
195 if mode == ROLLBACK: 195 ↛ 197line 195 didn't jump to line 197 because the condition on line 195 was always true
196 return None
197 if mode == NONE:
198 return "restly: db cleanup mode 'none', nothing is cleaned between tests"
199 return f"restly: db cleanup mode {mode!r}, test writes are committed and persist"
202def _reject_per_mapper_binds(factory: Any) -> None:
203 """Refuse a session factory whose ``binds=`` sends some models elsewhere.
205 The isolated factory copies the original's keyword arguments, so a per-mapper
206 ``binds`` would ride along and route those models to their own engine, outside
207 the pinned connection. Their writes commit for real and survive the rollback,
208 which is silent: the rest of the test looks isolated.
209 """
210 binds = factory.kw.get("binds")
211 if not binds:
212 return
213 mapped = ", ".join(sorted(getattr(key, "__name__", str(key)) for key in binds))
214 raise RestlyConfigurationError(
215 "The session factory passed to fr.configure() has per-mapper binds "
216 f"({mapped}), which the test fixtures cannot isolate: those models would "
217 "be routed to their own engine, outside the connection this test pins, "
218 "and their writes would be committed rather than rolled back. Configure "
219 "the tests with a single-bind sessionmaker."
220 )
223def _cleanup_mode() -> str:
224 """The cleanup mode in force, or ``rollback`` when the suite never opted in.
226 A suite that does not call ``configure_tests()`` still gets the rollback
227 behavior the session fixtures have always had when it requests one.
228 """
229 setup = _current_setup()
230 if setup is None: 230 ↛ 232line 230 didn't jump to line 232 because the condition on line 230 was always true
231 return ROLLBACK
232 return _resolve_db_cleanup(setup, _db_cleanup_override)
235def _application_context_copy() -> RestlyContext:
236 """Copy application configuration without test-loop overrides.
238 A synchronous TestClient runs ASGI work in a portal thread. Giving that
239 execution domain its own context lets it install a portal-owned async
240 transaction without exposing that loop-bound factory to the test thread.
241 """
242 source = _get_restly_context()
243 copied = RestlyContext()
244 for name in RestlyContext.__slots__:
245 setattr(copied, name, getattr(source, name))
246 copied.test_make_session = None
247 copied.test_async_make_session = None
248 return copied
251class _BlockedAsyncSessionFactory:
252 """Fail direct async access while TestClient owns the async execution loop."""
254 _message = (
255 "This test cannot open an async database session outside restly_client: "
256 "the synchronous client runs the application and its transaction on a "
257 "separate event loop. Use restly_async_client when the test or one of "
258 "its fixtures needs direct async database access."
259 )
261 def __call__(self, *args: Any, **kwargs: Any) -> Any:
262 raise RestlyConfigurationError(self._message)
264 @property
265 def kw(self) -> Any:
266 raise RestlyConfigurationError(self._message)
268 def begin(self) -> Any:
269 raise RestlyConfigurationError(self._message)
272@contextmanager
273def _blocked_async_test_access() -> Iterator[None]:
274 context = _get_restly_context()
275 previous = context.test_async_make_session
276 context.test_async_make_session = _BlockedAsyncSessionFactory() # type: ignore[assignment]
277 try:
278 yield
279 finally:
280 context.test_async_make_session = previous
283@pytest.fixture
284def _restly_sync_source(request: pytest.FixtureRequest):
285 """Discover a sync factory once per test, before pinning its connection."""
286 if _cleanup_mode() == ROLLBACK and (
287 _current_setup() is not None
288 or {"restly_session", "restly_async_session"} & set(request.fixturenames)
289 ):
290 return _sync_source_factory()
291 return _source_factories()[0]
294@pytest.fixture
295def _shared_connection(request: pytest.FixtureRequest, _restly_sync_source):
296 # One pinned connection shared by the sync and async fixtures, so a test that
297 # uses both sees a single database. Each request during the test joins this
298 # connection's outer transaction through a SAVEPOINT (create_savepoint mode);
299 # the outer transaction is never committed and rolls back at teardown, so no
300 # test data is ever persisted. Async-only projects have no sync sessionmaker;
301 # the async test scope pins its own connection in that case.
302 if _current_setup() is None and not {
303 "restly_session",
304 "restly_async_session",
305 } & set(request.fixturenames):
306 # In an unmanaged suite only an explicit session fixture opts the test
307 # in. A client fixture alone must leave the application's live engine
308 # untouched: no pinned connection, no event listeners. This runs before
309 # the scope fixtures that depend on it, so it guards itself.
310 yield None
311 return
313 if _cleanup_mode() != ROLLBACK: 313 ↛ 316line 313 didn't jump to line 316 because the condition on line 313 was never true
314 # Only rollback mode pins a connection. The other modes let writes commit,
315 # so there is no outer transaction to hold open.
316 yield None
317 return
319 make_session = _restly_sync_source
320 if not make_session:
321 yield None
322 return
324 # The bind may be a Connection, as fr.configure(make_session=...) allows.
325 # Pin a connection of our own from the engine behind it; events and connect()
326 # are engine-level, and a caller's Connection carries its own transaction.
327 engine = _resolve_engine(make_session.kw["bind"])
328 _install_sqlite_savepoint_fix(engine)
329 with engine.connect() as conn:
330 trans = conn.begin()
331 try:
332 yield conn
333 finally:
334 trans.rollback()
337@contextmanager
338def _isolated_sync_factory(
339 original: sessionmaker[SA_Session], connection: Any
340) -> Iterator[sessionmaker[SA_Session]]:
341 """Install a factory whose sessions join ``connection``'s transaction
342 through a SAVEPOINT, and restore the original on exit."""
343 isolated_make_session = sessionmaker(
344 class_=original.class_,
345 **{
346 **original.kw,
347 "bind": connection,
348 "join_transaction_mode": "create_savepoint",
349 },
350 )
351 context = _get_restly_context()
352 previous = context.test_make_session
353 context.test_make_session = isolated_make_session
354 try:
355 yield isolated_make_session
356 finally:
357 context.test_make_session = previous
360@pytest.fixture
361def _restly_sync_scope(_shared_connection, _restly_sync_source):
362 """Own sync cleanup and the per-test factory; create no public session."""
363 original = _restly_sync_source
364 mode = _cleanup_mode()
365 setup = _current_setup()
367 if mode != ROLLBACK: 367 ↛ 368line 367 didn't jump to line 368 because the condition on line 367 was never true
368 if mode == DELETE and setup is not None and original is not None:
369 _clean_database_sync(setup)
370 yield original
371 return
373 if original is None: 373 ↛ 376line 373 didn't jump to line 376 because the condition on line 373 was never true
374 # A dynamically requested session fixture may not have appeared in the
375 # fixture closure when the source was first resolved.
376 original = _sync_source_factory()
377 if original is None: 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true
378 yield None
379 return
381 _reject_per_mapper_binds(original)
382 if _shared_connection is not None: 382 ↛ 391line 382 didn't jump to line 391 because the condition on line 382 was always true
383 with _isolated_sync_factory(original, _shared_connection) as factory:
384 yield factory
385 return
387 # request.getfixturevalue("restly_session") leaves the fixture out of
388 # request.fixturenames, so in an unmanaged suite _shared_connection has
389 # punted. This scope only runs because a session fixture asked for it,
390 # which is the opt-in; pin the connection here instead.
391 engine = _resolve_engine(original.kw["bind"])
392 _install_sqlite_savepoint_fix(engine)
393 with engine.connect() as connection:
394 transaction = connection.begin()
395 try:
396 with _isolated_sync_factory(original, connection) as factory:
397 yield factory
398 finally:
399 transaction.rollback()
402if pytest_asyncio is None: 402 ↛ 404line 402 didn't jump to line 404 because the condition on line 402 was never true
404 @pytest.fixture
405 def _restly_async_preclean() -> None: # pyright: ignore[reportRedeclaration]
406 raise ModuleNotFoundError(_TESTING_EXTRA_MESSAGE, name="pytest_asyncio")
408 @pytest.fixture
409 def _restly_async_scope() -> None: # pyright: ignore[reportRedeclaration]
410 raise ModuleNotFoundError(_TESTING_EXTRA_MESSAGE, name="pytest_asyncio")
412 @pytest.fixture
413 def restly_async_session(_restly_async_scope) -> None: # pyright: ignore[reportRedeclaration]
414 # The else-branch defines the real async fixture; this stub only
415 # runs when the optional ``pytest_asyncio`` extra isn't installed.
416 # Pyright cannot model mutually exclusive module-level branches.
417 raise ModuleNotFoundError(_TESTING_EXTRA_MESSAGE, name="pytest_asyncio")
419 @pytest.fixture
420 def restly_async_client() -> None: # pyright: ignore[reportRedeclaration]
421 raise ModuleNotFoundError(_TESTING_EXTRA_MESSAGE, name="pytest_asyncio")
423else:
425 @asynccontextmanager
426 async def _async_rollback_factory(
427 shared_connection=None, *, original=None
428 ) -> AsyncIterator[async_sessionmaker[SA_AsyncSession]]:
429 """Install one savepoint factory on the caller's event loop."""
430 if original is None:
431 original = await _async_source_factory()
432 if original is None: 432 ↛ 433line 432 didn't jump to line 433 because the condition on line 432 was never true
433 raise RestlyConfigurationError(
434 "Async rollback isolation needs an async sessionmaker configured "
435 "through fr.configure()."
436 )
438 _reject_per_mapper_binds(original)
439 async_engine = _resolve_engine(original.kw["bind"])
441 @asynccontextmanager
442 async def pinned_connection() -> AsyncIterator[AsyncConnection]:
443 if shared_connection is not None:
444 yield AsyncConnection(async_engine, sync_connection=shared_connection)
445 return
447 _install_sqlite_savepoint_fix(async_engine)
448 owned_pool = async_engine.sync_engine.pool
449 try:
450 async with async_engine.connect() as connection:
451 transaction = await connection.begin()
452 try:
453 yield connection
454 finally:
455 if transaction.is_active: 455 ↛ 458line 455 didn't jump to line 458
456 await transaction.rollback()
457 finally:
458 await _dispose_async_test_engine(async_engine, former_pool=owned_pool)
460 async with pinned_connection() as async_connection:
461 isolated_make_session = async_sessionmaker(
462 class_=original.class_,
463 **{
464 **original.kw,
465 "bind": async_connection,
466 "join_transaction_mode": "create_savepoint",
467 },
468 )
469 globals_obj = _get_restly_context()
470 previous = globals_obj.test_async_make_session
471 globals_obj.test_async_make_session = isolated_make_session
472 try:
473 yield isolated_make_session
474 finally:
475 globals_obj.test_async_make_session = previous
477 @asynccontextmanager
478 async def _async_test_scope(
479 shared_connection=None, *, clean_database: bool = True
480 ) -> AsyncIterator[async_sessionmaker[SA_AsyncSession] | None]:
481 """Own one async execution domain's cleanup, override, and disposal."""
482 mode = _cleanup_mode()
483 original = (
484 await _async_source_factory()
485 if mode == ROLLBACK
486 else _source_factories()[1]
487 )
488 if original is None: 488 ↛ 489line 488 didn't jump to line 489 because the condition on line 488 was never true
489 yield None
490 return
492 if mode == ROLLBACK: 492 ↛ 499line 492 didn't jump to line 499 because the condition on line 492 was always true
493 async with _async_rollback_factory(
494 shared_connection, original=original
495 ) as isolated:
496 yield isolated
497 return
499 setup = _current_setup()
500 if (
501 clean_database
502 and mode == DELETE
503 and setup is not None
504 and setup.make_session is None
505 ):
506 await _clean_database_async(setup)
508 async_engine = _resolve_engine(original.kw["bind"])
509 try:
510 yield original
511 finally:
512 await _dispose_async_test_engine(async_engine)
514 @pytest_asyncio.fixture
515 async def _restly_async_preclean() -> AsyncIterator[None]:
516 """Clean an async-only database before any user fixture can seed it."""
517 setup = _current_setup()
518 original = _source_factories()[1]
519 if setup is not None and original is not None:
520 try:
521 await _clean_database_async(setup)
522 finally:
523 await _dispose_async_test_engine(_resolve_engine(original.kw["bind"]))
524 yield
526 @pytest_asyncio.fixture
527 async def _restly_async_scope(
528 _shared_connection, request: pytest.FixtureRequest
529 ) -> AsyncIterator[async_sessionmaker[SA_AsyncSession] | None]:
530 """Own async database state on pytest's event loop."""
531 if (
532 _current_setup() is None
533 and "restly_async_session" not in request.fixturenames
534 ):
535 # Merely requesting the HTTP client must not dispose or replace an
536 # application's live engine before the suite opts into management.
537 yield None
538 return
539 # The managed autouse fixture precleans async-only delete suites before
540 # user fixtures whenever the sync client owns the application's loop.
541 # A public async consumer may still request this scope later; it must not
542 # erase rows those user fixtures seeded in the meantime.
543 clean_database = "restly_client" not in request.fixturenames
544 async with _async_test_scope(
545 _shared_connection, clean_database=clean_database
546 ) as make_session:
547 yield make_session
549 @pytest_asyncio.fixture
550 async def restly_async_session(
551 _restly_async_scope,
552 ) -> AsyncIterator[SA_AsyncSession]:
553 """
554 Pytest fixture providing the suite's async database session.
556 The async equivalent of :func:`restly_session`, and like it, what it
557 yields follows the suite's ``db_cleanup`` mode: under the default
558 ``"rollback"`` it is the savepoint-isolated session described below;
559 under ``"delete"`` and ``"none"`` it is a plain session on the
560 configured database, since rolling it back would undo writes those
561 modes mean to commit.
563 Under rollback, each request during the test builds its own real
564 ``AsyncSession`` that joins a never-committed outer transaction through
565 a SAVEPOINT (SQLAlchemy's ``create_savepoint`` mode), so a request's
566 ``commit()`` and ``rollback()`` behave as in production. The outer
567 transaction rolls back at teardown, leaving the database clean for the
568 next test -- nothing is ever persisted. When a sync sessionmaker is
569 also configured, this fixture shares the sync fixture's pinned
570 connection, so a test that uses both sees one database.
572 As with the sync fixture there is no shared identity map: this fixture and
573 the request are separate sessions on one connection, so a write made
574 directly on this session becomes visible to a request only after a flush or
575 commit. A configured ``session_generator`` is overridden on the test app,
576 so native ``Depends(get_db)`` and Restly share the isolated request session.
577 Without a configured factory, rollback setup opens the generator to
578 discover its session's bind. Generators requiring request arguments need
579 an explicit test engine or sessionmaker.
581 ``fr.open_async_session()`` resolves the same factory, so it also yields an
582 isolated session during a test.
583 """
584 make_session = _restly_async_scope
585 if make_session is None:
586 pytest.skip("Database connection not set up")
588 session = make_session()
589 try:
590 yield session
591 finally:
592 await session.close()
594 @pytest_asyncio.fixture
595 async def restly_async_client(
596 restly_app, _restly_async_scope, request: pytest.FixtureRequest
597 ) -> AsyncIterator[AsyncRestlyTestClient]:
598 """An async HTTP client that runs the application's lifespan.
600 It uses pytest's event loop, so when ``restly_async_session`` is also
601 requested, direct database access and HTTP share one loop and, under
602 rollback, one transaction. It remains useful without a database
603 configuration.
604 """
605 try:
606 from asgi_lifespan import LifespanManager
607 except ModuleNotFoundError as exc:
608 if exc.name == "asgi_lifespan":
609 raise ModuleNotFoundError(
610 _TESTING_EXTRA_MESSAGE, name=exc.name
611 ) from exc
612 raise
614 try:
615 from .testing._client import AsyncRestlyTestClient
616 except ModuleNotFoundError as exc:
617 if exc.name in {"httpx", "httpx2"}:
618 raise ModuleNotFoundError(
619 _TESTING_EXTRA_MESSAGE, name=exc.name
620 ) from exc
621 raise
623 if "restly_session" in getattr(request, "fixturenames", ()): 623 ↛ 624line 623 didn't jump to line 624 because the condition on line 623 was never true
624 request.getfixturevalue("_restly_sync_scope")
626 with _override_session_generators(restly_app):
627 async with LifespanManager(
628 restly_app, startup_timeout=None, shutdown_timeout=None
629 ) as manager:
630 client = AsyncRestlyTestClient(restly_app, _transport_app=manager.app)
631 async with client:
632 yield client
635class _AsyncEngineLifespanApp:
636 """Own async database resources on TestClient's ASGI portal loop."""
638 def __init__(self, app: FastAPI) -> None:
639 self.app = app
640 self.context = _application_context_copy()
642 async def __call__(self, scope, receive, send) -> None:
643 # Lifespan and request tasks each receive this same portal-owned context.
644 # The lifespan installs the factory on the context object; requests then
645 # resolve it while entering their own ContextVar scope below.
646 with self.context:
647 if scope["type"] != "lifespan":
648 await self.app(scope, receive, send)
649 return
651 if pytest_asyncio is None: # pragma: no cover - testing extra guard
652 raise ModuleNotFoundError(_TESTING_EXTRA_MESSAGE, name="pytest_asyncio")
653 async with _async_test_scope(clean_database=False):
654 await self.app(scope, receive, send)
657@pytest.fixture
658def restly_session(_restly_sync_scope) -> Iterator[SA_Session]:
659 """
660 Pytest fixture providing the suite's database session.
662 What it yields follows the suite's ``db_cleanup`` mode: under the default
663 ``"rollback"`` it is the savepoint-isolated session described below; under
664 ``"delete"`` and ``"none"`` it is a plain session on the configured
665 database, since rolling it back would undo writes those modes mean to
666 commit.
668 Under rollback, the session joins a never-committed outer transaction
669 through a SAVEPOINT (SQLAlchemy's ``create_savepoint`` mode). Every request
670 during the test builds its own real session on the same pinned connection,
671 so a request's ``commit()`` and ``rollback()`` behave as in production, and
672 the outer transaction rolls back at teardown, leaving the database clean
673 for the next test -- nothing is ever persisted.
675 Unlike production, this fixture and the request are separate sessions on one
676 connection, so a write made directly on this session becomes visible to a
677 request only after a flush or commit (there is no shared identity map).
678 A configured ``sync_session_generator`` is overridden on the test app, so native
679 ``Depends(get_db)`` and Restly share the isolated request session. Without a
680 configured factory, rollback setup opens the generator to discover its
681 session's bind. Generators requiring request arguments need an explicit test
682 engine or sessionmaker.
684 ``fr.open_session()`` resolves the same factory, so it also yields an isolated
685 session during a test.
686 """
687 make_session = _restly_sync_scope
688 if make_session is None:
689 pytest.skip("Database connection not set up")
691 session = make_session()
692 try:
693 yield session
694 finally:
695 session.close()
698def pytest_collection_finish(session: pytest.Session) -> None:
699 """Verify the recorded application configuration and build its test schema."""
700 if getattr(session.config.option, "collectonly", False): 700 ↛ 701line 700 didn't jump to line 701 because the condition on line 700 was never true
701 return
702 setup = _current_setup()
703 if setup is None: 703 ↛ 706line 703 didn't jump to line 706 because the condition on line 703 was always true
704 return
706 try:
707 setup = _prepare_database_sources(_cleanup_mode())
708 _validate_database_sources(setup, _cleanup_mode())
709 _create_schema(setup, root=Path(session.config.rootpath))
710 except RestlyConfigurationError as error:
711 # As a UsageError pytest prints the message and stops; anything else
712 # raised from a hook is reported as an INTERNALERROR with a pluggy
713 # traceback.
714 raise pytest.UsageError(str(error)) from error
717@pytest.fixture(autouse=True)
718def _restly_managed_isolation(request: pytest.FixtureRequest) -> Iterator[None]:
719 """Give every test a clean database when ``configure_tests()`` was called.
721 Inert (rather than skipping) when nothing is configured, so tests that never
722 touch the database still run.
723 """
724 setup = _current_setup()
725 if setup is None: 725 ↛ 729line 725 didn't jump to line 729 because the condition on line 725 was always true
726 yield
727 return
729 mode = _cleanup_mode()
730 _validate_database_sources(setup, mode)
731 with _override_session_generators(setup.app):
732 yield from _managed_isolation(request, setup, mode)
735def _managed_isolation(
736 request: pytest.FixtureRequest, setup: Any, mode: str
737) -> Iterator[None]:
738 """Select isolation scopes by execution domain, independent of consumers."""
739 recorded_sync, recorded_async = _source_factories()
740 if recorded_sync is not None:
741 request.getfixturevalue("_restly_sync_scope")
743 portal_owns_async = (
744 recorded_async is not None
745 and "restly_client" in request.fixturenames
746 and (recorded_sync is None or mode != ROLLBACK)
747 )
748 if portal_owns_async and mode == DELETE and recorded_sync is None:
749 request.getfixturevalue("_restly_async_preclean")
750 if recorded_async is not None and not portal_owns_async:
751 request.getfixturevalue("_restly_async_scope")
753 block_split_access = portal_owns_async and (
754 mode == ROLLBACK or _async_source_is_loop_bound(setup)
755 )
756 if block_split_access:
757 _reject_split_async_usage(request)
758 with _blocked_async_test_access():
759 yield
760 return
762 yield
765@pytest.fixture
766def restly_app() -> FastAPI:
767 """Return the application under test.
769 ``fr.testing.configure_tests(app=...)`` sets this. Without it you get a bare
770 ``FastAPI()``; override the fixture in your ``conftest.py`` to supply your own.
771 """
772 setup = _current_setup()
773 if setup is not None and setup.app is not None:
774 return setup.app
775 return FastAPI()
778def _client_async_engine_policy() -> str | None:
779 """Return the async-engine lifecycle the synchronous client must own."""
780 setup = _current_setup()
781 if setup is None or setup.async_make_session is None: 781 ↛ 783line 781 didn't jump to line 783 because the condition on line 781 was always true
782 return None
783 if _cleanup_mode() == ROLLBACK:
784 if setup.make_session is None:
785 return ROLLBACK
786 return None
787 return _cleanup_mode()
790def _async_source_is_loop_bound(setup: Any) -> bool:
791 """Return whether a configured driver rejects pooled cross-loop reuse."""
792 factory = setup.async_make_session
793 if factory is None:
794 return False
795 engine = _resolve_engine(factory.kw["bind"])
796 # aiosqlite can hand sequential operations on one connection to different
797 # loops. Network async drivers generally cannot; SQLAlchemy likewise
798 # requires disposing pooled AsyncEngines before moving them across loops.
799 return engine.dialect.name != "sqlite"
802def _reject_split_async_usage(request: pytest.FixtureRequest) -> None:
803 """Keep direct async work and application work on one event loop."""
804 conflicts = {"restly_async_client", "restly_async_session"}.intersection(
805 request.fixturenames
806 )
807 if not conflicts:
808 return
809 raise RestlyConfigurationError(
810 "An async database test cannot combine restly_client with "
811 f"{', '.join(sorted(conflicts))}: the synchronous client runs the "
812 "application on its own event loop, while the async fixtures run on "
813 "pytest's event loop. Use restly_async_client for HTTP when a test needs "
814 "async fixtures."
815 )
818@pytest.fixture
819def restly_client(
820 restly_app, request: pytest.FixtureRequest
821) -> Iterator[RestlyTestClient]:
822 """A test client for ``restly_app``, entered so the app's lifespan runs.
824 Starlette's client only runs startup and shutdown inside its context manager.
825 Returning an unentered one meant a ``lifespan=`` that opens a pool, warms a
826 cache or registers a dependency never ran, and the failure surfaced far from
827 here as a missing resource.
828 """
829 try:
830 from .testing._client import RestlyTestClient
831 except ModuleNotFoundError as exc:
832 # Newer Starlette's testclient requires httpx2 (name="httpx2"); our own
833 # _client.py import raises name="httpx". Both mean the test client is
834 # missing.
835 if exc.name in {"httpx", "httpx2"}:
836 raise ModuleNotFoundError(_TESTING_EXTRA_MESSAGE, name=exc.name) from exc
837 raise
839 # Resolve isolation before entering the application's lifespan even when
840 # the test lists its client before its session fixture.
841 if "restly_session" in getattr(request, "fixturenames", ()): 841 ↛ 842line 841 didn't jump to line 842 because the condition on line 841 was never true
842 request.getfixturevalue("_restly_sync_scope")
844 # Bound before the block: __enter__ returns self at runtime, but Starlette
845 # annotates it as the base TestClient, so `with ... as client` would type
846 # the fixture without the subclass's status-code assertions.
847 transport_app = None
848 async_engine_policy = _client_async_engine_policy()
849 setup = _current_setup()
850 if async_engine_policy is not None and ( 850 ↛ 854line 850 didn't jump to line 854 because the condition on line 850 was never true
851 async_engine_policy == ROLLBACK
852 or (setup is not None and _async_source_is_loop_bound(setup))
853 ):
854 _reject_split_async_usage(request)
855 if setup is None and "restly_async_session" in getattr(request, "fixturenames", ()): 855 ↛ 856line 855 didn't jump to line 856 because the condition on line 855 was never true
856 request.getfixturevalue("_restly_async_scope")
857 if async_engine_policy is not None: 857 ↛ 858line 857 didn't jump to line 858 because the condition on line 857 was never true
858 transport_app = _AsyncEngineLifespanApp(restly_app)
859 client = RestlyTestClient(restly_app, _transport_app=transport_app)
860 with _override_session_generators(restly_app), client:
861 yield client
864@contextmanager
865def _override_session_generators(app: FastAPI | None) -> Iterator[None]:
866 """Make native Depends(get_db) and Restly use the test's pinned connection."""
867 if app is None or _cleanup_mode() != ROLLBACK: 867 ↛ 868line 867 didn't jump to line 868 because the condition on line 867 was never true
868 yield
869 return
870 managed = _current_setup() is not None
871 installed = {}
872 for generator, factory, replacement in (
873 (
874 _fr_globals.sync_session_generator,
875 _fr_globals.test_make_session,
876 _generate_session,
877 ),
878 (
879 _fr_globals.session_generator,
880 _fr_globals.test_async_make_session,
881 _async_generate_session,
882 ),
883 ):
884 if generator is not None and (managed or factory is not None): 884 ↛ 887line 884 didn't jump to line 887 because the condition on line 884 was never true
885 # An explicit application override owns its own isolation. Preserve
886 # it, including an override a user fixture installs during the test.
887 if generator not in app.dependency_overrides:
888 app.dependency_overrides[generator] = replacement
889 installed[generator] = replacement
890 try:
891 yield
892 finally:
893 for generator, replacement in installed.items(): 893 ↛ 894line 893 didn't jump to line 894 because the loop on line 893 never started
894 if app.dependency_overrides.get(generator) is replacement:
895 del app.dependency_overrides[generator]