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

1from __future__ import annotations 

2 

3import os 

4import weakref 

5from contextlib import asynccontextmanager, contextmanager 

6from pathlib import Path 

7from typing import TYPE_CHECKING, Any, AsyncIterator, Iterator 

8 

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 

17 

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 

40 

41if TYPE_CHECKING: 

42 from .testing._client import AsyncRestlyTestClient, RestlyTestClient 

43 

44try: 

45 import pytest_asyncio 

46except ModuleNotFoundError as exc: 

47 if exc.name != "pytest_asyncio": 

48 raise 

49 pytest_asyncio = None 

50 

51_TESTING_EXTRA_MESSAGE = ( 

52 "fastapi_restly.pytest_fixtures requires optional testing dependencies. " 

53 'Install them with: pip install "fastapi-restly[testing]"' 

54) 

55 

56 

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. 

61 

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() 

75 

76 

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 ) 

86 

87 

88@pytest.fixture 

89def restly_project_root(request: pytest.FixtureRequest) -> Path: 

90 """Return the root of the project that owns the requesting test. 

91 

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) 

100 

101 

102# Test engines whose pysqlite legacy-transaction shim has already been neutralised. 

103_sqlite_savepoint_fixed: weakref.WeakSet = weakref.WeakSet() 

104 

105 

106def _install_sqlite_savepoint_fix(engine: Engine | AsyncEngine) -> None: 

107 """Neutralise pysqlite's legacy transaction shim on a test engine. 

108 

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. 

115 

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) 

129 

130 @event.listens_for(sync_engine, "connect") 

131 def _disable_legacy_transaction_control(dbapi_connection, connection_record): 

132 dbapi_connection.isolation_level = None 

133 

134 @event.listens_for(sync_engine, "begin") 

135 def _emit_begin(conn): 

136 conn.exec_driver_sql("BEGIN") 

137 

138 

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 ) 

155 

156 

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 

161 

162 

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] = [] 

166 

167 

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 

182 

183 

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 

189 

190 

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" 

200 

201 

202def _reject_per_mapper_binds(factory: Any) -> None: 

203 """Refuse a session factory whose ``binds=`` sends some models elsewhere. 

204 

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 ) 

221 

222 

223def _cleanup_mode() -> str: 

224 """The cleanup mode in force, or ``rollback`` when the suite never opted in. 

225 

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) 

233 

234 

235def _application_context_copy() -> RestlyContext: 

236 """Copy application configuration without test-loop overrides. 

237 

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 

249 

250 

251class _BlockedAsyncSessionFactory: 

252 """Fail direct async access while TestClient owns the async execution loop.""" 

253 

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 ) 

260 

261 def __call__(self, *args: Any, **kwargs: Any) -> Any: 

262 raise RestlyConfigurationError(self._message) 

263 

264 @property 

265 def kw(self) -> Any: 

266 raise RestlyConfigurationError(self._message) 

267 

268 def begin(self) -> Any: 

269 raise RestlyConfigurationError(self._message) 

270 

271 

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 

281 

282 

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] 

292 

293 

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 

312 

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 

318 

319 make_session = _restly_sync_source 

320 if not make_session: 

321 yield None 

322 return 

323 

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() 

335 

336 

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 

358 

359 

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() 

366 

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 

372 

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 

380 

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 

386 

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() 

400 

401 

402if pytest_asyncio is None: 402 ↛ 404line 402 didn't jump to line 404 because the condition on line 402 was never true

403 

404 @pytest.fixture 

405 def _restly_async_preclean() -> None: # pyright: ignore[reportRedeclaration] 

406 raise ModuleNotFoundError(_TESTING_EXTRA_MESSAGE, name="pytest_asyncio") 

407 

408 @pytest.fixture 

409 def _restly_async_scope() -> None: # pyright: ignore[reportRedeclaration] 

410 raise ModuleNotFoundError(_TESTING_EXTRA_MESSAGE, name="pytest_asyncio") 

411 

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") 

418 

419 @pytest.fixture 

420 def restly_async_client() -> None: # pyright: ignore[reportRedeclaration] 

421 raise ModuleNotFoundError(_TESTING_EXTRA_MESSAGE, name="pytest_asyncio") 

422 

423else: 

424 

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 ) 

437 

438 _reject_per_mapper_binds(original) 

439 async_engine = _resolve_engine(original.kw["bind"]) 

440 

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 

446 

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) 

459 

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 

476 

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 

491 

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 

498 

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) 

507 

508 async_engine = _resolve_engine(original.kw["bind"]) 

509 try: 

510 yield original 

511 finally: 

512 await _dispose_async_test_engine(async_engine) 

513 

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 

525 

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 

548 

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. 

555 

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. 

562 

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. 

571 

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. 

580 

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") 

587 

588 session = make_session() 

589 try: 

590 yield session 

591 finally: 

592 await session.close() 

593 

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. 

599 

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 

613 

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 

622 

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") 

625 

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 

633 

634 

635class _AsyncEngineLifespanApp: 

636 """Own async database resources on TestClient's ASGI portal loop.""" 

637 

638 def __init__(self, app: FastAPI) -> None: 

639 self.app = app 

640 self.context = _application_context_copy() 

641 

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 

650 

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) 

655 

656 

657@pytest.fixture 

658def restly_session(_restly_sync_scope) -> Iterator[SA_Session]: 

659 """ 

660 Pytest fixture providing the suite's database session. 

661 

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. 

667 

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. 

674 

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. 

683 

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") 

690 

691 session = make_session() 

692 try: 

693 yield session 

694 finally: 

695 session.close() 

696 

697 

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 

705 

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 

715 

716 

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. 

720 

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 

728 

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) 

733 

734 

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") 

742 

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") 

752 

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 

761 

762 yield 

763 

764 

765@pytest.fixture 

766def restly_app() -> FastAPI: 

767 """Return the application under test. 

768 

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() 

776 

777 

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() 

788 

789 

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" 

800 

801 

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 ) 

816 

817 

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. 

823 

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 

838 

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") 

843 

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 

862 

863 

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]