saia-python 0.4.1__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,253 @@
1
+ """SAIA Python — a wrapper for the GWDG SAIA platform REST API.
2
+
3
+ Provides both an object-oriented and a functional interface::
4
+
5
+ # OOP
6
+ from saia_python import SAIAClient
7
+ client = SAIAClient(api_key="...")
8
+ client.models.list_ids()
9
+
10
+ # Functional
11
+ from saia_python import list_model_ids, chat_completion
12
+ list_model_ids()
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import concurrent.futures
18
+ from importlib.metadata import PackageNotFoundError, version
19
+
20
+ from ._streaming import SSEStream
21
+ from .arcana_references import (
22
+ ArcanaReference,
23
+ ParsedReferences,
24
+ is_arcana_event,
25
+ parse_arcana_references,
26
+ parse_reference_entries,
27
+ )
28
+ from .auth import (
29
+ DEFAULT_BASE_URL,
30
+ add_arcana_to_config,
31
+ load_api_key,
32
+ load_arcana_ids,
33
+ load_config,
34
+ load_username,
35
+ remove_arcana_from_config,
36
+ resolve_base_url,
37
+ )
38
+ from .client import SAIAClient
39
+ from .documents import ConversionResult
40
+ from .exceptions import APIError, AuthenticationError, RateLimitError, SAIAError
41
+ from .openai_compat import create_openai_client
42
+ from .rate_limits import RateLimitInfo, parse_rate_limits
43
+ from .responses import text_of
44
+
45
+ try:
46
+ __version__ = version("saia-python")
47
+ except PackageNotFoundError:
48
+ __version__ = "0.0.0-dev"
49
+
50
+ __all__ = [
51
+ # Meta
52
+ "__version__",
53
+ # Client
54
+ "SAIAClient",
55
+ "resolve_base_url",
56
+ "DEFAULT_BASE_URL",
57
+ "create_openai_client",
58
+ # Auth
59
+ "load_api_key",
60
+ "load_arcana_ids",
61
+ "load_config",
62
+ "load_username",
63
+ "add_arcana_to_config",
64
+ "remove_arcana_from_config",
65
+ # Exceptions
66
+ "SAIAError",
67
+ "AuthenticationError",
68
+ "RateLimitError",
69
+ "APIError",
70
+ # Rate limits
71
+ "RateLimitInfo",
72
+ "parse_rate_limits",
73
+ # Response helpers
74
+ "text_of",
75
+ "SSEStream",
76
+ # ARCANA reference parsing
77
+ "ArcanaReference",
78
+ "ParsedReferences",
79
+ "parse_arcana_references",
80
+ "parse_reference_entries",
81
+ "is_arcana_event",
82
+ # Functional API
83
+ "list_models",
84
+ "list_model_ids",
85
+ "chat_completion",
86
+ "transcribe",
87
+ "translate",
88
+ "list_arcanas",
89
+ "get_arcana",
90
+ "upload_to_arcana",
91
+ "arcana_chat",
92
+ "get_rate_limits",
93
+ "convert_document",
94
+ "ConversionResult",
95
+ ]
96
+
97
+
98
+ def _make_client(api_key: str | None = None, base_url: str | None = None) -> SAIAClient:
99
+ kwargs: dict = {}
100
+ if api_key is not None:
101
+ kwargs["api_key"] = api_key
102
+ if base_url is not None:
103
+ kwargs["base_url"] = base_url
104
+ return SAIAClient(**kwargs)
105
+
106
+
107
+ # --- Models ---
108
+
109
+
110
+ def list_models(
111
+ *, api_key: str | None = None, base_url: str | None = None
112
+ ) -> list[dict]:
113
+ """List all available models (functional API)."""
114
+ return _make_client(api_key, base_url).models.list()
115
+
116
+
117
+ def list_model_ids(
118
+ *, api_key: str | None = None, base_url: str | None = None
119
+ ) -> list[str]:
120
+ """List model ID strings (functional API)."""
121
+ return _make_client(api_key, base_url).models.list_ids()
122
+
123
+
124
+ # --- Chat ---
125
+
126
+
127
+ def chat_completion(
128
+ model: str,
129
+ messages: list[dict],
130
+ *,
131
+ api_key: str | None = None,
132
+ base_url: str | None = None,
133
+ **kwargs,
134
+ ) -> dict | SSEStream:
135
+ """Send a chat completion request (functional API).
136
+
137
+ See :meth:`ChatService.completions` for full parameter docs. With
138
+ ``stream=True`` this returns an iterable ``SSEStream`` instead of a dict.
139
+ """
140
+ return _make_client(api_key, base_url).chat.completions(
141
+ model=model, messages=messages, **kwargs
142
+ )
143
+
144
+
145
+ # --- Voice ---
146
+
147
+
148
+ def transcribe(
149
+ file_path: str,
150
+ *,
151
+ api_key: str | None = None,
152
+ base_url: str | None = None,
153
+ **kwargs,
154
+ ) -> str | concurrent.futures.Future[str]:
155
+ """Transcribe an audio file (functional API).
156
+
157
+ See :meth:`VoiceService.transcribe` for full parameter docs. Passing
158
+ ``wait=False`` returns a :class:`concurrent.futures.Future` instead of
159
+ the transcription string.
160
+ """
161
+ return _make_client(api_key, base_url).voice.transcribe(file_path, **kwargs)
162
+
163
+
164
+ def translate(
165
+ file_path: str,
166
+ *,
167
+ api_key: str | None = None,
168
+ base_url: str | None = None,
169
+ **kwargs,
170
+ ) -> str | concurrent.futures.Future[str]:
171
+ """Translate an audio file to English (functional API).
172
+
173
+ See :meth:`VoiceService.translate` for full parameter docs. Passing
174
+ ``wait=False`` returns a :class:`concurrent.futures.Future` instead of
175
+ the translation string.
176
+ """
177
+ return _make_client(api_key, base_url).voice.translate(file_path, **kwargs)
178
+
179
+
180
+ # --- ARCANA ---
181
+
182
+
183
+ def list_arcanas(
184
+ *, api_key: str | None = None, base_url: str | None = None
185
+ ) -> list[dict]:
186
+ """List all arcanas (functional API)."""
187
+ return _make_client(api_key, base_url).arcana.list()
188
+
189
+
190
+ def get_arcana(
191
+ name: str, *, api_key: str | None = None, base_url: str | None = None
192
+ ) -> dict:
193
+ """Get a specific arcana by name (functional API)."""
194
+ return _make_client(api_key, base_url).arcana.get(name)
195
+
196
+
197
+ def upload_to_arcana(
198
+ name: str,
199
+ file_path: str,
200
+ *,
201
+ api_key: str | None = None,
202
+ base_url: str | None = None,
203
+ ) -> dict | None:
204
+ """Upload a file to an arcana (functional API)."""
205
+ return _make_client(api_key, base_url).arcana.upload(name, file_path)
206
+
207
+
208
+ def arcana_chat(
209
+ model: str,
210
+ messages: list[dict],
211
+ arcana_id: str,
212
+ *,
213
+ api_key: str | None = None,
214
+ base_url: str | None = None,
215
+ **kwargs,
216
+ ) -> dict | SSEStream:
217
+ """Chat with RAG context from an arcana (functional API).
218
+
219
+ With ``stream=True`` this returns an iterable ``SSEStream`` instead of a dict.
220
+ """
221
+ return _make_client(api_key, base_url).arcana.chat(
222
+ model=model, messages=messages, arcana_id=arcana_id, **kwargs
223
+ )
224
+
225
+
226
+ # --- Rate Limits ---
227
+
228
+
229
+ def get_rate_limits(
230
+ *, api_key: str | None = None, base_url: str | None = None
231
+ ) -> RateLimitInfo:
232
+ """Fetch current rate-limit status (functional API)."""
233
+ return _make_client(api_key, base_url).get_rate_limits()
234
+
235
+
236
+ # --- Documents ---
237
+
238
+
239
+ def convert_document(
240
+ file_path: str,
241
+ *,
242
+ response_type: str = "markdown",
243
+ api_key: str | None = None,
244
+ base_url: str | None = None,
245
+ **kwargs,
246
+ ) -> ConversionResult:
247
+ """Convert a document using the Docling service (functional API).
248
+
249
+ See :meth:`DocumentService.convert` for full parameter docs.
250
+ """
251
+ return _make_client(api_key, base_url).documents.convert(
252
+ file_path, response_type=response_type, **kwargs
253
+ )
saia_python/_http.py ADDED
@@ -0,0 +1,71 @@
1
+ """Shared HTTP plumbing used by more than one service.
2
+
3
+ Kept in one place so the chat-completion request shape and the
4
+ background-thread ``Session`` helper each have a single implementation,
5
+ rather than being copied across :mod:`saia_python.chat`,
6
+ :mod:`saia_python.arcana`, and :mod:`saia_python.voice`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import requests
12
+
13
+ from ._streaming import SSEStream
14
+ from .exceptions import raise_for_status
15
+ from .rate_limits import parse_rate_limits
16
+
17
+
18
+ def new_session_like(template: requests.Session) -> requests.Session:
19
+ """Return a fresh :class:`requests.Session` mirroring ``template``'s headers.
20
+
21
+ Background-thread work must not reuse the caller's ``Session`` —
22
+ ``requests.Session`` is not guaranteed thread-safe, and sharing its
23
+ connection pool across threads can corrupt in-flight requests. Both the
24
+ non-blocking Voice path and the fire-and-forget ARCANA index trigger spin
25
+ up their own ``Session`` through this helper so they never race the
26
+ client's.
27
+ """
28
+ session = requests.Session()
29
+ session.headers.update(template.headers)
30
+ return session
31
+
32
+
33
+ def post_chat_completion(
34
+ session: requests.Session,
35
+ url: str,
36
+ body: dict,
37
+ *,
38
+ headers: dict | None = None,
39
+ stream: bool = False,
40
+ ) -> dict | SSEStream:
41
+ """POST a chat-completion request and normalise the response.
42
+
43
+ Shared by :meth:`ChatService.completions` and :meth:`ArcanaService.chat`:
44
+ both hit the same ``/chat/completions`` endpoint with identical
45
+ stream/non-stream handling and rate-limit surfacing — only the request
46
+ ``body`` fields and auth ``headers`` differ, so those stay with the caller.
47
+
48
+ Args:
49
+ session: The authenticated :class:`requests.Session`.
50
+ url: The fully-qualified ``/chat/completions`` URL.
51
+ body: The request JSON body (already assembled by the caller).
52
+ headers: Per-request headers. ``None`` uses the session defaults
53
+ (the Bearer auth + ``Accept: application/json``).
54
+ stream: When ``True``, request SSE and return an :class:`SSEStream`.
55
+
56
+ Returns:
57
+ When ``stream=False``: the response dict with an extra
58
+ ``"_rate_limits"`` key (a JSON-serializable dict). When ``stream=True``:
59
+ an :class:`SSEStream` whose ``rate_limits`` attribute holds the same dict.
60
+ """
61
+ if stream:
62
+ stream_body = {**body, "stream": True}
63
+ stream_headers = {**(headers or {}), "Accept": "text/event-stream"}
64
+ resp = session.post(url, json=stream_body, headers=stream_headers, stream=True)
65
+ return SSEStream(resp)
66
+
67
+ resp = session.post(url, json=body, headers=headers)
68
+ raise_for_status(resp)
69
+ result = resp.json()
70
+ result["_rate_limits"] = parse_rate_limits(resp.headers).to_dict()
71
+ return result
@@ -0,0 +1,88 @@
1
+ """Shared SSE (Server-Sent Events) streaming iterator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Generator
7
+ from typing import TYPE_CHECKING
8
+
9
+ from .exceptions import raise_for_status
10
+ from .rate_limits import parse_rate_limits
11
+
12
+ if TYPE_CHECKING:
13
+ import requests
14
+
15
+
16
+ def iter_sse(response: requests.Response) -> Generator[dict, None, None]:
17
+ """Yield parsed JSON chunks from an SSE stream.
18
+
19
+ Handles the ``data: {...}`` / ``data: [DONE]`` protocol used by
20
+ the OpenAI-compatible SAIA API.
21
+
22
+ Args:
23
+ response: A streaming :class:`requests.Response` (``stream=True``).
24
+
25
+ Yields:
26
+ Parsed JSON dicts for each ``data:`` line.
27
+ """
28
+ raise_for_status(response)
29
+ try:
30
+ for line in response.iter_lines(decode_unicode=True):
31
+ if not line or not line.startswith("data:"):
32
+ continue
33
+ payload = line[len("data:") :].strip()
34
+ if payload == "[DONE]":
35
+ return
36
+ try:
37
+ yield json.loads(payload)
38
+ except json.JSONDecodeError:
39
+ continue
40
+ finally:
41
+ # Release the underlying connection back to the pool whether the
42
+ # stream finished, hit [DONE], errored, or the consumer broke early
43
+ # (GeneratorExit). Without this, an abandoned stream leaks the socket
44
+ # until garbage collection.
45
+ response.close()
46
+
47
+
48
+ class SSEStream:
49
+ """Iterable over SSE chunks that also exposes parsed rate-limit info.
50
+
51
+ Wraps a streaming :class:`requests.Response` so callers can both iterate
52
+ the decoded chunks (``for chunk in stream``) and read the rate-limit
53
+ headers via :attr:`rate_limits` — available immediately, since headers
54
+ arrive before the streamed body. Mirrors the ``_rate_limits`` key that
55
+ non-streaming responses carry, giving both paths rate-limit parity.
56
+
57
+ Attributes:
58
+ rate_limits: A JSON-serializable dict of this response's rate-limit
59
+ headers (the same shape as :class:`~saia_python.RateLimitInfo`
60
+ via ``to_dict()``).
61
+ """
62
+
63
+ def __init__(self, response: requests.Response):
64
+ self._response = response
65
+ self.rate_limits: dict = parse_rate_limits(response.headers).to_dict()
66
+ self._chunks = iter_sse(response)
67
+
68
+ def __iter__(self) -> SSEStream:
69
+ return self
70
+
71
+ def __next__(self) -> dict:
72
+ return next(self._chunks)
73
+
74
+ def close(self) -> None:
75
+ """Close the stream, releasing the underlying connection.
76
+
77
+ Closes both the chunk generator (running its cleanup) and the
78
+ response directly, so the connection is released even if iteration
79
+ never started.
80
+ """
81
+ self._chunks.close()
82
+ self._response.close()
83
+
84
+ def __enter__(self) -> SSEStream:
85
+ return self
86
+
87
+ def __exit__(self, *exc) -> None:
88
+ self.close()
saia_python/_util.py ADDED
@@ -0,0 +1,29 @@
1
+ """Small internal utilities shared across services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Iterator
6
+
7
+
8
+ def progress_iter(
9
+ items: Iterable,
10
+ *,
11
+ desc: str,
12
+ unit: str = "file",
13
+ enabled: bool = True,
14
+ ) -> Iterator:
15
+ """Wrap ``items`` in a ``tqdm`` progress bar when available and ``enabled``.
16
+
17
+ ``tqdm`` is an optional dependency, so this degrades to a plain iterator
18
+ when it is not installed (or when ``enabled`` is ``False`` — e.g. a caller
19
+ that prints its own per-item lines instead of a bar). Centralises the
20
+ ``try: from tqdm.auto import tqdm`` dance that several services otherwise
21
+ repeat.
22
+ """
23
+ if not enabled:
24
+ return iter(items)
25
+ try:
26
+ from tqdm.auto import tqdm
27
+ except ImportError:
28
+ return iter(items)
29
+ return tqdm(items, desc=desc, unit=unit)