ragnarok-api 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,223 @@
1
+ """ragnarok-api: the Python SDK of the RAGnarok HTTP API.
2
+
3
+ Quick start::
4
+
5
+ from ragnarok_api import Client, Token
6
+
7
+ with Client("https://ragnarok.example", Token("rk_...")) as client:
8
+ for event in client.query.stream("What is the indication?", workspace="epar"):
9
+ print(event.event, event)
10
+
11
+ See the package README for the four usage scenarios (streaming query, ingest a
12
+ folder, read the graph, manage access).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from ._core import SUPPORTED_INGEST_SUFFIXES, FolderSelection, collect_folder
18
+ from ._upload import (
19
+ FilesTooLarge,
20
+ ServerTooOldForUpload,
21
+ UploadInterrupted,
22
+ UploadItem,
23
+ UploadJobFinished,
24
+ UploadJobResult,
25
+ UploadProgress,
26
+ new_client_ref,
27
+ plan_batches,
28
+ prepare_uploads,
29
+ sha256_of,
30
+ )
31
+ from ._version import SUPPORTED_API_MAJOR, __version__
32
+ from .accounting import (
33
+ ChunkCounts,
34
+ DocumentAccounting,
35
+ FigureCounts,
36
+ InsertedModified,
37
+ JobAccounting,
38
+ JobDocumentCounts,
39
+ ModelCallCounts,
40
+ TableCounts,
41
+ )
42
+ from .async_client import AsyncClient
43
+ from .client import Client
44
+ from .credentials import Session, Token
45
+ from .errors import (
46
+ ApiVersionMismatch,
47
+ BadRequest,
48
+ ChecksumMismatch,
49
+ ClientRefConflict,
50
+ Conflict,
51
+ EmbeddingMismatch,
52
+ Forbidden,
53
+ ManifestMismatch,
54
+ NotFound,
55
+ PayloadTooLarge,
56
+ QueryFailed,
57
+ RagnarokError,
58
+ RateLimited,
59
+ ServerError,
60
+ StagedNameConflict,
61
+ StagingQuotaExceeded,
62
+ TermsRequired,
63
+ TransportError,
64
+ Unauthorized,
65
+ UploadIncomplete,
66
+ )
67
+ from .events import (
68
+ INGEST_UPLOAD_STEPS,
69
+ AnswerDeltaEvent,
70
+ AnswerEvent,
71
+ CitationMapEvent,
72
+ DoneEvent,
73
+ EntitiesEvent,
74
+ ErrorEvent,
75
+ GraphEvent,
76
+ HistoryError,
77
+ IngestDoneEvent,
78
+ IngestErrorEvent,
79
+ IngestEvent,
80
+ IngestJobSummary,
81
+ NoticesEvent,
82
+ QueryEvent,
83
+ ReferenceEntry,
84
+ ReferenceGroup,
85
+ ReferencesEvent,
86
+ SourcesEvent,
87
+ StatsEvent,
88
+ StatusEvent,
89
+ UnknownEvent,
90
+ )
91
+ from .models import (
92
+ INGEST_JOB_STATUSES,
93
+ WORKSPACE_ROLES,
94
+ EmbeddingChange,
95
+ EmbeddingRecord,
96
+ Grant,
97
+ GrantChange,
98
+ IngestDocumentResult,
99
+ IngestEmbedder,
100
+ IngestJob,
101
+ IngestLimits,
102
+ IngestNoticeDetail,
103
+ IngestPlan,
104
+ IngestRoleModel,
105
+ IngestSubmitted,
106
+ QueryResult,
107
+ RemovedFiles,
108
+ RepairFinding,
109
+ RepairReport,
110
+ StagedFile,
111
+ StagedFiles,
112
+ UserRecord,
113
+ WhoAmI,
114
+ WorkspaceGrantRow,
115
+ WorkspaceGrants,
116
+ WorkspaceMember,
117
+ WorkspaceMeta,
118
+ WorkspaceSummary,
119
+ )
120
+ from .paging import aiterate, iterate
121
+
122
+ __all__ = [
123
+ "INGEST_JOB_STATUSES",
124
+ "INGEST_UPLOAD_STEPS",
125
+ "SUPPORTED_API_MAJOR",
126
+ "SUPPORTED_INGEST_SUFFIXES",
127
+ "WORKSPACE_ROLES",
128
+ "AnswerDeltaEvent",
129
+ "AnswerEvent",
130
+ "ApiVersionMismatch",
131
+ "AsyncClient",
132
+ "BadRequest",
133
+ "ChecksumMismatch",
134
+ "ChunkCounts",
135
+ "CitationMapEvent",
136
+ "Client",
137
+ "ClientRefConflict",
138
+ "Conflict",
139
+ "DocumentAccounting",
140
+ "DoneEvent",
141
+ "EmbeddingChange",
142
+ "EmbeddingMismatch",
143
+ "EmbeddingRecord",
144
+ "EntitiesEvent",
145
+ "ErrorEvent",
146
+ "FigureCounts",
147
+ "FilesTooLarge",
148
+ "FolderSelection",
149
+ "Forbidden",
150
+ "Grant",
151
+ "GrantChange",
152
+ "GraphEvent",
153
+ "HistoryError",
154
+ "IngestDocumentResult",
155
+ "IngestDoneEvent",
156
+ "IngestEmbedder",
157
+ "IngestErrorEvent",
158
+ "IngestEvent",
159
+ "IngestJob",
160
+ "IngestJobSummary",
161
+ "IngestLimits",
162
+ "IngestNoticeDetail",
163
+ "IngestPlan",
164
+ "IngestRoleModel",
165
+ "IngestSubmitted",
166
+ "InsertedModified",
167
+ "JobAccounting",
168
+ "JobDocumentCounts",
169
+ "ManifestMismatch",
170
+ "ModelCallCounts",
171
+ "NotFound",
172
+ "NoticesEvent",
173
+ "PayloadTooLarge",
174
+ "PostprocessRef",
175
+ "QueryEvent",
176
+ "QueryFailed",
177
+ "QueryResult",
178
+ "RagnarokError",
179
+ "RateLimited",
180
+ "ReferenceEntry",
181
+ "ReferenceGroup",
182
+ "ReferencesEvent",
183
+ "RemovedFiles",
184
+ "RepairFinding",
185
+ "RepairReport",
186
+ "ServerError",
187
+ "ServerTooOldForUpload",
188
+ "Session",
189
+ "SourcesEvent",
190
+ "StagedFile",
191
+ "StagedFiles",
192
+ "StagedNameConflict",
193
+ "StagingQuotaExceeded",
194
+ "StatsEvent",
195
+ "StatusEvent",
196
+ "TableCounts",
197
+ "TermsRequired",
198
+ "Token",
199
+ "TransportError",
200
+ "Unauthorized",
201
+ "UnknownEvent",
202
+ "UploadIncomplete",
203
+ "UploadInterrupted",
204
+ "UploadItem",
205
+ "UploadJobFinished",
206
+ "UploadJobResult",
207
+ "UploadProgress",
208
+ "UserRecord",
209
+ "WhoAmI",
210
+ "WorkspaceGrantRow",
211
+ "WorkspaceGrants",
212
+ "WorkspaceMember",
213
+ "WorkspaceMeta",
214
+ "WorkspaceSummary",
215
+ "__version__",
216
+ "aiterate",
217
+ "collect_folder",
218
+ "iterate",
219
+ "new_client_ref",
220
+ "plan_batches",
221
+ "prepare_uploads",
222
+ "sha256_of",
223
+ ]
ragnarok_api/_base.py ADDED
@@ -0,0 +1,178 @@
1
+ """State and pure helpers shared by :class:`Client` and :class:`AsyncClient`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import warnings
7
+ from collections.abc import Mapping
8
+ from typing import Any
9
+
10
+ import httpx
11
+
12
+ from ._core import (
13
+ DEFAULT_STREAM_READ_TIMEOUT_SECONDS,
14
+ DEFAULT_TIMEOUT_SECONDS,
15
+ Call,
16
+ normalize_prefix,
17
+ )
18
+ from ._version import SUPPORTED_API_MAJOR
19
+ from .errors import ApiVersionMismatch, TermsRequired, Unauthorized
20
+ from .events import (
21
+ AnswerDeltaEvent,
22
+ AnswerEvent,
23
+ CitationMapEvent,
24
+ DoneEvent,
25
+ EntitiesEvent,
26
+ ErrorEvent,
27
+ GraphEvent,
28
+ NoticesEvent,
29
+ QueryEvent,
30
+ ReferencesEvent,
31
+ SourcesEvent,
32
+ StatsEvent,
33
+ StatusEvent,
34
+ UnknownEvent,
35
+ )
36
+ from .models import AuthCheck, Health, QueryResult, WhoAmI
37
+
38
+ STREAM_ENDED_WITHOUT_DONE = (
39
+ "The query stream ended before RAGnarok sent a completion event. Wait a moment and try again."
40
+ )
41
+
42
+
43
+ class ClientBase:
44
+ """Configuration shared by both clients (no I/O here)."""
45
+
46
+ _is_async = False
47
+
48
+ def __init__(
49
+ self,
50
+ base_url: str,
51
+ credential: Any = None,
52
+ *,
53
+ api_prefix: str = "/api/v1",
54
+ timeout: float | httpx.Timeout = DEFAULT_TIMEOUT_SECONDS, # #TIMEOUT : ADR-006 240 s default
55
+ stream_read_timeout: float = DEFAULT_STREAM_READ_TIMEOUT_SECONDS, # #TIMEOUT : SSE liveness backstop
56
+ headers: Mapping[str, str] | None = None,
57
+ ) -> None:
58
+ if not str(base_url or "").strip():
59
+ raise ValueError("base_url is required (e.g. 'https://ragnarok.example').")
60
+ self.base_url = str(base_url).rstrip("/")
61
+ self.credential = credential
62
+ self.api_prefix = normalize_prefix(api_prefix)
63
+ self._timeout = timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(float(timeout))
64
+ self._stream_timeout = httpx.Timeout(self._timeout.connect, read=float(stream_read_timeout),
65
+ write=self._timeout.write, pool=self._timeout.pool)
66
+ self._extra_headers = dict(headers or {})
67
+
68
+ def _url(self, call: Call[Any]) -> str:
69
+ path = call.path if call.path.startswith("/") else "/" + call.path
70
+ return (self.api_prefix + path) if call.versioned else path
71
+
72
+ def __repr__(self) -> str:
73
+ return (
74
+ f"{type(self).__name__}(base_url={self.base_url!r}, api_prefix={self.api_prefix!r}, "
75
+ f"credential={self.credential!r})"
76
+ )
77
+
78
+
79
+ def whoami_from_check(check: AuthCheck, credential: Any) -> WhoAmI:
80
+ """Map ``/auth/check`` (servers before item 0166) onto :class:`WhoAmI`."""
81
+ if check.terms_required:
82
+ raise TermsRequired(
83
+ "The account must accept the confidentiality terms.",
84
+ session=credential if getattr(credential, "kind", None) == "session" else None,
85
+ )
86
+ if not check.authenticated or check.user is None:
87
+ raise Unauthorized("Not authenticated (auth/check reported authenticated=false).", code="not_authenticated")
88
+ return WhoAmI(
89
+ email=check.user.email,
90
+ account_type=check.user.account_type,
91
+ allowed_workspaces=list(check.user.allowed_workspaces),
92
+ terms_required=False,
93
+ terms_signed_at=check.terms_signed_at,
94
+ auth_kind=None,
95
+ source="check",
96
+ )
97
+
98
+
99
+ _MAJOR_RE = re.compile(r"^\D*(\d+)")
100
+
101
+
102
+ def api_major(api_version: str | None) -> int | None:
103
+ """``"1"``, ``"v1"``, ``"1.2.0"`` -> ``1``; ``None``/unparseable -> ``None``."""
104
+ if api_version is None:
105
+ return None
106
+ match = _MAJOR_RE.match(str(api_version).strip())
107
+ return int(match.group(1)) if match else None
108
+
109
+
110
+ def check_version(health: Health, *, strict: bool) -> Health:
111
+ """Raise :class:`ApiVersionMismatch` when the server's API major is not the SDK's."""
112
+ major = api_major(health.api_version)
113
+ if major is None:
114
+ message = (
115
+ "The server did not report a parseable api_version on /api/health "
116
+ f"(got {health.api_version!r}); compatibility with API v{SUPPORTED_API_MAJOR} is unverified."
117
+ )
118
+ if strict:
119
+ raise ApiVersionMismatch(message, server_api_version=health.api_version,
120
+ supported_major=SUPPORTED_API_MAJOR)
121
+ warnings.warn(message, RuntimeWarning, stacklevel=3)
122
+ return health
123
+ if major != SUPPORTED_API_MAJOR:
124
+ raise ApiVersionMismatch(
125
+ f"Server speaks API v{major}; this SDK supports v{SUPPORTED_API_MAJOR}.",
126
+ server_api_version=health.api_version,
127
+ supported_major=SUPPORTED_API_MAJOR,
128
+ )
129
+ return health
130
+
131
+
132
+ class QueryCollector:
133
+ """Fold a ``/query`` event stream into a :class:`QueryResult`."""
134
+
135
+ def __init__(self) -> None:
136
+ self.result = QueryResult()
137
+ self._draft: list[str] = []
138
+
139
+ def add(self, event: QueryEvent) -> None:
140
+ r = self.result
141
+ if isinstance(event, StatusEvent):
142
+ r.statuses.append(event)
143
+ elif isinstance(event, AnswerDeltaEvent):
144
+ self._draft.append(event.text)
145
+ r.draft = "".join(self._draft)
146
+ elif isinstance(event, AnswerEvent):
147
+ r.answer = event.text
148
+ elif isinstance(event, CitationMapEvent):
149
+ r.citation_map = dict(event.map)
150
+ elif isinstance(event, ReferencesEvent):
151
+ r.references = list(event.items)
152
+ r.reference_groups = list(event.groups)
153
+ elif isinstance(event, NoticesEvent):
154
+ r.notices = list(event.items)
155
+ elif isinstance(event, SourcesEvent):
156
+ r.sources = event
157
+ elif isinstance(event, EntitiesEvent):
158
+ r.entities = event
159
+ elif isinstance(event, GraphEvent):
160
+ r.graph = event
161
+ elif isinstance(event, StatsEvent):
162
+ r.stats = event
163
+ elif isinstance(event, ErrorEvent):
164
+ r.errors.append(event)
165
+ elif isinstance(event, DoneEvent):
166
+ r.success = bool(event.success) and not r.errors
167
+ r.history_id = event.history_id
168
+ r.history_error = event.history_error
169
+ elif isinstance(event, UnknownEvent):
170
+ r.unknown.append(event)
171
+
172
+
173
+ def synthetic_end() -> list[QueryEvent]:
174
+ """What the web client reports when the stream ends without ``done``."""
175
+ return [
176
+ ErrorEvent(message=STREAM_ENDED_WITHOUT_DONE, code="query_network_error", client_generated=True),
177
+ DoneEvent(success=False, client_generated=True),
178
+ ]
@@ -0,0 +1,63 @@
1
+ """Run an async httpx transport (e.g. ``httpx.ASGITransport(app=...)``) under the sync client.
2
+
3
+ ``httpx.Client`` only accepts sync transports, but the in-process ASGI
4
+ transport the backend tests and ``ragnarok --local`` use is async. This
5
+ bridge runs it on a private event loop thread (anyio blocking portal; anyio
6
+ is already an httpx dependency). The response body is fully buffered, which
7
+ is also what ``httpx.ASGITransport`` itself does, so SSE events arrive when
8
+ the app finishes the stream.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from contextlib import AbstractContextManager
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+
19
+ class AsyncTransportBridge(httpx.BaseTransport):
20
+ """A sync transport that forwards every request to an async transport."""
21
+
22
+ def __init__(self, transport: httpx.AsyncBaseTransport) -> None:
23
+ from anyio.from_thread import start_blocking_portal
24
+
25
+ self._transport = transport
26
+ self._portal_cm: AbstractContextManager[Any] = start_blocking_portal()
27
+ self._portal = self._portal_cm.__enter__()
28
+ self._closed = False
29
+
30
+ def handle_request(self, request: httpx.Request) -> httpx.Response:
31
+ body = request.read()
32
+ forwarded = httpx.Request(
33
+ request.method,
34
+ request.url,
35
+ headers=request.headers,
36
+ content=body,
37
+ extensions=request.extensions,
38
+ )
39
+
40
+ async def _send() -> httpx.Response:
41
+ response = await self._transport.handle_async_request(forwarded)
42
+ try:
43
+ raw = b"".join([chunk async for chunk in response.aiter_raw()])
44
+ finally:
45
+ await response.aclose()
46
+ return httpx.Response(
47
+ status_code=response.status_code,
48
+ headers=response.headers,
49
+ stream=httpx.ByteStream(raw),
50
+ extensions=response.extensions,
51
+ request=request,
52
+ )
53
+
54
+ return self._portal.call(_send)
55
+
56
+ def close(self) -> None:
57
+ if self._closed:
58
+ return
59
+ self._closed = True
60
+ try:
61
+ self._portal.call(self._transport.aclose)
62
+ finally:
63
+ self._portal_cm.__exit__(None, None, None)