"""Fixture FastAPI apps for the SDK contract tests. Mirrors the same fixture-construction pattern used by the existing `tests/contract/api/{search,context,answers}` contract tests (real `RetrievalAuthorizationService`, a fixture executor/generator standing in only for the retrieval backend/model provider) -- these tests exist to prove the SDK's HTTP layer (request shape, response typing, SSE parsing, Problem Details mapping, retries), not to re-prove authorization/retrieval business logic that is already covered there. """ from __future__ import annotations import asyncio import contextlib import json import re import socket import sys import threading import time from collections.abc import Iterator from dataclasses import dataclass, replace from datetime import UTC, datetime from typing import Any from uuid import UUID, uuid4 import uvicorn from fastapi import FastAPI from ragcore.api.v1.answers.dependencies import get_answer_application, get_answer_principal from ragcore.api.v1.answers.service import AnswerService from ragcore.api.v1.context.dependencies import get_context_application, get_context_principal from ragcore.api.v1.context.service import ( ContextExecutionRequest, ContextExecutionResult, ContextService, ) from ragcore.api.v1.search.dependencies import get_search_application, get_search_principal from ragcore.api.v1.search.service import SearchHit, SearchQuery, SearchService from ragcore.application.retrieval.authorization import RetrievalAuthorizationService from ragcore.domain.authorization import Principal, PrincipalType from ragcore.domain.citations import Citation from ragcore.domain.control import Application, Role, Workspace from ragcore.main import create_app from ragcore.retrieval.context import ContextCandidate from ragcore.retrieval.ranking import FusedCandidate from ragcore.retrieval.search.models import Candidate, CandidateBackendKind, CandidateProvenance EVIDENCE_EXCERPT = "The refund window is thirty days from the original purchase date." class _FixtureInputs: def __init__(self, workspace: Workspace, space_id: UUID, space_name: str) -> None: self.workspace = workspace self.space_id = space_id self.space_name = space_name async def load(self, principal: Principal) -> Any: space = self.workspace.create_knowledge_space( id=self.space_id, name=self.space_name, slug=self.space_name.lower().replace(" ", "-") ) return type( "Inputs", (), { "spaces": (space,), "grants": (), "user_grants": ( self.workspace.grant_user( id=uuid4(), user_id=principal.id, knowledge_space=space, role=Role.EDITOR ), ), "application": Application( id=uuid4(), workspace_id=self.workspace.id, name=f"{self.space_name} application", default_knowledge_space_ids=frozenset({self.space_id}), ), }, )() @dataclass class SearchFixture: app: FastAPI principal: Principal space_id: UUID calls: list[SearchQuery] def build_search_app() -> SearchFixture: workspace = Workspace(uuid4(), "SDK search workspace", "sdk-search-workspace") space_id = uuid4() principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.VIEWER})) document_id, version_id, chunk_id = uuid4(), uuid4(), uuid4() citation = Citation.create( workspace_id=workspace.id, space_id=space_id, document_id=document_id, document_version_id=version_id, chunk_id=chunk_id, parent_chunk_id=None, location={"page_start": 4, "page_end": 4, "section": "Refunds"}, excerpt=EVIDENCE_EXCERPT, source_uri="https://example.test/policy.md", acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC), retrieval_run_id=uuid4(), index_alias="rag_dense_hybrid_active", profile_version="hybrid-v1", ) candidate = Candidate( point_id=uuid4(), score=0.87, rank=1, backend=CandidateBackendKind.DENSE, provenance=CandidateProvenance( workspace_id=workspace.id, space_id=space_id, document_id=document_id, document_version_id=version_id, chunk_id=chunk_id, source_id=uuid4(), parent_chunk_id=None, status="published", content_type="text/markdown", language="en", trust_level="trusted_internal", ), ) hit = SearchHit( fused=FusedCandidate(candidate, 1 / 61, 1, None), text=EVIDENCE_EXCERPT, title="Refund policy", citation=citation, dense_score=0.87, ) calls: list[SearchQuery] = [] class _Executor: async def execute(self, request: Any) -> Any: refreshed = replace( hit, citation=Citation.create( workspace_id=hit.citation.workspace_id, space_id=hit.citation.space_id, document_id=hit.citation.document_id, document_version_id=hit.citation.document_version_id, chunk_id=hit.citation.chunk_id, parent_chunk_id=hit.citation.parent_chunk_id, location=hit.citation.location, excerpt=hit.citation.excerpt, source_uri=hit.citation.source_uri, acquired_at=hit.citation.acquired_at, retrieval_run_id=request.retrieval_run_id, index_alias=hit.citation.index_alias, profile_version=hit.citation.profile_version, ), ) return type( "Result", (), { "hits": (refreshed,), "degraded": False, "degradation_codes": (), "timings_ms": {}, }, )() application = Application( id=uuid4(), workspace_id=workspace.id, name="SDK search application", default_knowledge_space_ids=frozenset({space_id}), ) class _Inputs(_FixtureInputs): async def load(self, principal: Principal) -> Any: base = await super().load(principal) return type( "Inputs", (), { "spaces": base.spaces, "grants": base.grants, "user_grants": base.user_grants, "application": application, }, )() service = SearchService( authorization=RetrievalAuthorizationService(), inputs=_Inputs(workspace, space_id, "SDK search space"), executor=_Executor(), ) app = create_app() app.dependency_overrides[get_search_principal] = lambda: principal app.dependency_overrides[get_search_application] = lambda: service return SearchFixture(app=app, principal=principal, space_id=space_id, calls=calls) @dataclass class ContextFixture: app: FastAPI principal: Principal space_id: UUID def build_context_app() -> ContextFixture: workspace = Workspace(uuid4(), "SDK context workspace", "sdk-context-workspace") space_id = uuid4() principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.EDITOR})) citation = Citation.create( workspace_id=workspace.id, space_id=space_id, document_id=uuid4(), document_version_id=uuid4(), chunk_id=uuid4(), parent_chunk_id=None, location={"page_start": 1}, excerpt=EVIDENCE_EXCERPT, source_uri="https://example.test/context.md", acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC), retrieval_run_id=uuid4(), index_alias="rag_dense_hybrid_active", profile_version="hybrid-v1", ) candidate = ContextCandidate(citation, EVIDENCE_EXCERPT, 1, 1.0) class _Executor: async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult: refreshed = Citation.create( workspace_id=citation.workspace_id, space_id=citation.space_id, document_id=citation.document_id, document_version_id=citation.document_version_id, chunk_id=citation.chunk_id, parent_chunk_id=citation.parent_chunk_id, location=citation.location, excerpt=citation.excerpt, source_uri=citation.source_uri, acquired_at=citation.acquired_at, retrieval_run_id=request.retrieval_run_id, index_alias=citation.index_alias, profile_version=citation.profile_version, ) return ContextExecutionResult(candidates=(replace(candidate, citation=refreshed),)) service = ContextService( authorization=RetrievalAuthorizationService(), inputs=_FixtureInputs(workspace, space_id, "SDK context space"), executor=_Executor(), ) app = create_app() app.dependency_overrides[get_context_principal] = lambda: principal app.dependency_overrides[get_context_application] = lambda: service return ContextFixture(app=app, principal=principal, space_id=space_id) _CITATION_ID = re.compile(r"citation_ids=([0-9a-fA-F-]{36})") @dataclass class AnswerFixture: app: FastAPI principal: Principal space_id: UUID def build_answer_app(*, generator_responses: list[str | None] | None = None) -> AnswerFixture: workspace = Workspace(uuid4(), "SDK answers workspace", "sdk-answers-workspace") space_id = uuid4() principal = Principal(uuid4(), workspace.id, PrincipalType.HUMAN, frozenset({Role.EDITOR})) citation = Citation.create( workspace_id=workspace.id, space_id=space_id, document_id=uuid4(), document_version_id=uuid4(), chunk_id=uuid4(), parent_chunk_id=None, location={"page_start": 1}, excerpt=EVIDENCE_EXCERPT, source_uri="https://example.test/answers.md", acquired_at=datetime(2026, 8, 1, 12, tzinfo=UTC), retrieval_run_id=uuid4(), index_alias="rag_dense_hybrid_active", profile_version="hybrid-v1", ) candidate = ContextCandidate(citation, EVIDENCE_EXCERPT, 1, 1.0) class _ContextExecutor: async def execute(self, request: ContextExecutionRequest) -> ContextExecutionResult: refreshed = Citation.create( workspace_id=citation.workspace_id, space_id=citation.space_id, document_id=citation.document_id, document_version_id=citation.document_version_id, chunk_id=citation.chunk_id, parent_chunk_id=citation.parent_chunk_id, location=citation.location, excerpt=citation.excerpt, source_uri=citation.source_uri, acquired_at=citation.acquired_at, retrieval_run_id=request.retrieval_run_id, index_alias=citation.index_alias, profile_version=citation.profile_version, ) return ContextExecutionResult(candidates=(replace(candidate, citation=refreshed),)) responses = list(generator_responses if generator_responses is not None else [None]) class _Generator: async def generate(self, *, prompt: str, generation_profile: str, spaces: Any) -> str: response = responses.pop(0) if responses else None if response is not None: return response match = _CITATION_ID.search(prompt) assert match is not None return json.dumps( { "status": "answerable", "answer": "The refund window is thirty days from purchase.", "citations": [match.group(1)], } ) service = AnswerService( authorization=RetrievalAuthorizationService(), inputs=_FixtureInputs(workspace, space_id, "SDK answers space"), context_executor=_ContextExecutor(), generator=_Generator(), ) app = create_app() app.dependency_overrides[get_answer_principal] = lambda: principal app.dependency_overrides[get_answer_application] = lambda: service return AnswerFixture(app=app, principal=principal, space_id=space_id) def _serve_forever(server: uvicorn.Server, sock: socket.socket) -> None: # Deliberately not `server.run()`: that wraps `server.serve()` in # `asyncio.run(..., loop_factory=...)`, and uvicorn's own loop factory # (`uvicorn.loops.asyncio.asyncio_loop_factory`) hardcodes # `asyncio.ProactorEventLoop` on Windows -- which the real app's # lifespan (an async psycopg connection) refuses to run under, no # matter what global event-loop policy is set beforehand. Driving the # loop directly sidesteps uvicorn's own hardcoded choice. loop = asyncio.SelectorEventLoop() if sys.platform == "win32" else asyncio.new_event_loop() asyncio.set_event_loop(loop) try: loop.run_until_complete(server.serve(sockets=[sock])) finally: loop.close() @contextlib.contextmanager def run_app(app: FastAPI) -> Iterator[str]: """Serve `app` over a real local TCP socket for the duration of the block. `RAGCoreClient` (sync) needs this rather than `httpx.ASGITransport`: ASGITransport only implements httpx's *async* transport interface, so a sync `httpx.Client` can't use it at all (`AsyncRAGCoreClient` tests use ASGITransport directly instead, which is the supported combination). """ # Hand uvicorn the already-bound socket itself, rather than closing it # and passing the port number for uvicorn to rebind: closing and # rebinding is a real race (another process, or even this same # process's next bind, can win the port first), which is what # produced Windows `STARTUP_FAILURE`s here in practice. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) sock.bind(("127.0.0.1", 0)) sock.listen(128) port = sock.getsockname()[1] config = uvicorn.Config(app, log_level="warning") server = uvicorn.Server(config) thread = threading.Thread(target=_serve_forever, args=(server, sock), daemon=True) thread.start() deadline = time.monotonic() + 10 while not server.started and time.monotonic() < deadline: time.sleep(0.01) try: yield f"http://127.0.0.1:{port}" finally: server.should_exit = True thread.join(timeout=10) with contextlib.suppress(OSError): sock.close() __all__ = [ "AnswerFixture", "ContextFixture", "SearchFixture", "build_answer_app", "build_context_app", "build_search_app", "run_app", ]