saia-python 0.4.1__tar.gz → 0.5.0__tar.gz
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.
- {saia_python-0.4.1/saia_python.egg-info → saia_python-0.5.0}/PKG-INFO +2 -4
- {saia_python-0.4.1 → saia_python-0.5.0}/README.md +1 -3
- {saia_python-0.4.1 → saia_python-0.5.0}/pyproject.toml +1 -1
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/_http.py +9 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/_streaming.py +4 -1
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/arcana.py +155 -28
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/client.py +22 -3
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/models.py +24 -9
- {saia_python-0.4.1 → saia_python-0.5.0/saia_python.egg-info}/PKG-INFO +2 -4
- {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_arcana.py +137 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_auth.py +1 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_client.py +9 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_models.py +16 -1
- {saia_python-0.4.1 → saia_python-0.5.0}/LICENSE +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/__init__.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/_util.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/arcana_references.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/auth.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/chat.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/documents.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/exceptions.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/openai_compat.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/py.typed +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/rate_limits.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/responses.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/voice.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python.egg-info/SOURCES.txt +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python.egg-info/dependency_links.txt +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python.egg-info/requires.txt +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/saia_python.egg-info/top_level.txt +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/setup.cfg +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_arcana_references.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_chat.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_exceptions.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_health_check.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_openai_compat.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_rate_limits.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_responses.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_setup_from_directory.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_streaming.py +0 -0
- {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_voice.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: saia-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Python wrapper for the GWDG SAIA platform REST API
|
|
5
5
|
Author: Friedrich Schwarz
|
|
6
6
|
License-Expression: AGPL-3.0-only
|
|
@@ -57,9 +57,7 @@ Dynamic: license-file
|
|
|
57
57
|
[](https://github.com/fschwar4/saia_python/blob/main/LICENSE)
|
|
58
58
|
[](https://github.com/fschwar4/saia_python/actions/workflows/tests.yml)
|
|
59
59
|
[](https://fschwar4.github.io/saia_python/)
|
|
60
|
-
|
|
61
|
-
paste the DOI badge Zenodo provides, e.g.:
|
|
62
|
-
[](https://doi.org/10.5281/zenodo.XXXXXXX) -->
|
|
60
|
+
[](https://doi.org/10.5281/zenodo.20480724)
|
|
63
61
|
|
|
64
62
|
A Python wrapper for the [GWDG SAIA (Scalable AI Accelerator) platform](https://docs.hpc.gwdg.de/services/ai-services/saia/index.html) REST API.
|
|
65
63
|
|
|
@@ -5,9 +5,7 @@
|
|
|
5
5
|
[](https://github.com/fschwar4/saia_python/blob/main/LICENSE)
|
|
6
6
|
[](https://github.com/fschwar4/saia_python/actions/workflows/tests.yml)
|
|
7
7
|
[](https://fschwar4.github.io/saia_python/)
|
|
8
|
-
|
|
9
|
-
paste the DOI badge Zenodo provides, e.g.:
|
|
10
|
-
[](https://doi.org/10.5281/zenodo.XXXXXXX) -->
|
|
8
|
+
[](https://doi.org/10.5281/zenodo.20480724)
|
|
11
9
|
|
|
12
10
|
A Python wrapper for the [GWDG SAIA (Scalable AI Accelerator) platform](https://docs.hpc.gwdg.de/services/ai-services/saia/index.html) REST API.
|
|
13
11
|
|
|
@@ -14,6 +14,15 @@ from ._streaming import SSEStream
|
|
|
14
14
|
from .exceptions import raise_for_status
|
|
15
15
|
from .rate_limits import parse_rate_limits
|
|
16
16
|
|
|
17
|
+
# Default ``(connect, read)`` timeout in seconds for ARCANA management
|
|
18
|
+
# ("control-plane") requests that do not pass their own. A plain
|
|
19
|
+
# :class:`requests.Session` has NO default timeout, so a request the server
|
|
20
|
+
# accepts but never answers — common while an arcana is locked mid-(re)index —
|
|
21
|
+
# blocks forever on the socket read. Long-running "data-plane" calls (chat
|
|
22
|
+
# completions, voice transcription, document conversion) deliberately do not
|
|
23
|
+
# inherit this cap, since they can legitimately run for minutes.
|
|
24
|
+
DEFAULT_TIMEOUT: tuple[float, float] = (10.0, 60.0)
|
|
25
|
+
|
|
17
26
|
|
|
18
27
|
def new_session_like(template: requests.Session) -> requests.Session:
|
|
19
28
|
"""Return a fresh :class:`requests.Session` mirroring ``template``'s headers.
|
|
@@ -27,7 +27,10 @@ def iter_sse(response: requests.Response) -> Generator[dict, None, None]:
|
|
|
27
27
|
"""
|
|
28
28
|
raise_for_status(response)
|
|
29
29
|
try:
|
|
30
|
-
for
|
|
30
|
+
for raw in response.iter_lines(decode_unicode=True):
|
|
31
|
+
# decode_unicode=True yields str at runtime, but the requests type
|
|
32
|
+
# stub still types iter_lines as bytes — normalize for both.
|
|
33
|
+
line = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
|
31
34
|
if not line or not line.startswith("data:"):
|
|
32
35
|
continue
|
|
33
36
|
payload = line[len("data:") :].strip()
|
|
@@ -11,7 +11,7 @@ from urllib.parse import quote
|
|
|
11
11
|
|
|
12
12
|
import requests
|
|
13
13
|
|
|
14
|
-
from ._http import new_session_like, post_chat_completion
|
|
14
|
+
from ._http import DEFAULT_TIMEOUT, new_session_like, post_chat_completion
|
|
15
15
|
from ._streaming import SSEStream
|
|
16
16
|
from ._util import progress_iter
|
|
17
17
|
from .exceptions import APIError, raise_for_status
|
|
@@ -61,17 +61,52 @@ class ArcanaService:
|
|
|
61
61
|
session: A :class:`requests.Session` (auth header will be overridden per-request).
|
|
62
62
|
base_url: The SAIA API base URL (e.g. ``https://chat-ai.academiccloud.de/v1``).
|
|
63
63
|
api_key: The raw API key (needed because ARCANA omits the ``Bearer`` prefix).
|
|
64
|
+
timeout: Default ``(connect, read)`` timeout in seconds applied to every
|
|
65
|
+
ARCANA management request that does not set its own. Guards against
|
|
66
|
+
the server accepting a request but never responding (e.g. while an
|
|
67
|
+
arcana is locked mid-(re)index), which would otherwise block forever
|
|
68
|
+
on the socket read. A single ``float`` applies to both phases; pass
|
|
69
|
+
``None`` to disable. Defaults to ``(10, 60)``. The long-running chat
|
|
70
|
+
path (:meth:`chat`) is exempt.
|
|
64
71
|
"""
|
|
65
72
|
|
|
66
|
-
def __init__(
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
session: requests.Session,
|
|
76
|
+
base_url: str,
|
|
77
|
+
api_key: str,
|
|
78
|
+
*,
|
|
79
|
+
timeout: float | tuple[float, float] | None = DEFAULT_TIMEOUT,
|
|
80
|
+
):
|
|
67
81
|
self._session = session
|
|
68
82
|
self._base_url = base_url
|
|
69
83
|
self._arcana_base = f"{self._base_url}{_ARCANA_PATH}"
|
|
70
84
|
self._api_key = api_key
|
|
85
|
+
self._timeout = timeout
|
|
71
86
|
|
|
72
87
|
def _headers(self, **extra) -> dict:
|
|
73
88
|
return {"Authorization": self._api_key, "Accept": "application/json", **extra}
|
|
74
89
|
|
|
90
|
+
def _request(self, method: str, url: str, **kwargs) -> requests.Response:
|
|
91
|
+
"""Issue a request on the shared session with a default timeout.
|
|
92
|
+
|
|
93
|
+
A plain :class:`requests.Session` has no default timeout, so any ARCANA
|
|
94
|
+
management call that forgets ``timeout=`` blocks forever on the socket
|
|
95
|
+
read when the server accepts the request but never responds — common
|
|
96
|
+
while an arcana is locked mid-(re)index. Routing every such call through
|
|
97
|
+
here injects :attr:`_timeout` unless an explicit ``timeout`` was passed
|
|
98
|
+
(so :meth:`heartbeat` and :meth:`generate_index` keep their own), so
|
|
99
|
+
calls fail fast with :class:`requests.Timeout` instead of hanging. That
|
|
100
|
+
error is *not* swallowed here — it propagates to the caller (the batch
|
|
101
|
+
helpers record it per file and carry on).
|
|
102
|
+
|
|
103
|
+
``method`` is the lowercase HTTP verb (``"get"``, ``"post"``, ``"put"``,
|
|
104
|
+
``"delete"``); dispatching through the verb attribute keeps the call
|
|
105
|
+
shape the rest of the code — and the tests — already rely on.
|
|
106
|
+
"""
|
|
107
|
+
kwargs.setdefault("timeout", self._timeout)
|
|
108
|
+
return getattr(self._session, method)(url, **kwargs)
|
|
109
|
+
|
|
75
110
|
@staticmethod
|
|
76
111
|
def _format_arcana_line(a: dict, *, with_owner: bool = False) -> str:
|
|
77
112
|
"""Format one arcana as a one-line summary (shared by the summary views)."""
|
|
@@ -113,14 +148,21 @@ class ArcanaService:
|
|
|
113
148
|
verb: str,
|
|
114
149
|
desc: str,
|
|
115
150
|
verbose: bool,
|
|
151
|
+
on_result: Callable[[Path, dict], None] | None = None,
|
|
116
152
|
) -> list[dict]:
|
|
117
153
|
"""Run ``action(path)`` over each file, tallying per-file status.
|
|
118
154
|
|
|
119
|
-
The shared skeleton behind :meth:`upload_directory
|
|
120
|
-
:meth:`delete_directory`: an optionally
|
|
121
|
-
a ``{"file", "status", ["error"]}`` dict
|
|
122
|
-
when ``verbose``. Only the per-file
|
|
123
|
-
``verb`` (``"uploaded"`` / ``"deleted"``)
|
|
155
|
+
The shared skeleton behind :meth:`upload_directory`,
|
|
156
|
+
:meth:`upload_files`, and :meth:`delete_directory`: an optionally
|
|
157
|
+
tqdm-wrapped loop that records a ``{"file", "status", ["error"]}`` dict
|
|
158
|
+
per item and prints a tally when ``verbose``. Only the per-file
|
|
159
|
+
``action`` and the past-tense ``verb`` (``"uploaded"`` / ``"deleted"``)
|
|
160
|
+
differ between callers.
|
|
161
|
+
|
|
162
|
+
If ``on_result`` is given it is called as ``on_result(path, entry)``
|
|
163
|
+
immediately after each file is processed (``entry`` is that file's
|
|
164
|
+
result dict), so callers can stream per-file provenance / transaction
|
|
165
|
+
logging without reimplementing the loop.
|
|
124
166
|
"""
|
|
125
167
|
results = []
|
|
126
168
|
for fp in progress_iter(files, desc=desc, unit="file"):
|
|
@@ -134,6 +176,8 @@ class ArcanaService:
|
|
|
134
176
|
if verbose:
|
|
135
177
|
label = verb.capitalize() if entry["status"] == verb else "FAILED"
|
|
136
178
|
print(f" {fp.name} {label}")
|
|
179
|
+
if on_result is not None:
|
|
180
|
+
on_result(fp, entry)
|
|
137
181
|
results.append(entry)
|
|
138
182
|
|
|
139
183
|
succeeded = sum(1 for r in results if r["status"] == verb)
|
|
@@ -153,8 +197,8 @@ class ArcanaService:
|
|
|
153
197
|
Returns:
|
|
154
198
|
The version string (e.g. ``"0.4.16"``).
|
|
155
199
|
"""
|
|
156
|
-
resp = self.
|
|
157
|
-
f"{self._arcana_base}/version", headers=self._headers()
|
|
200
|
+
resp = self._request(
|
|
201
|
+
"get", f"{self._arcana_base}/version", headers=self._headers()
|
|
158
202
|
)
|
|
159
203
|
raise_for_status(resp)
|
|
160
204
|
return resp.json().get("version", "")
|
|
@@ -167,7 +211,8 @@ class ArcanaService:
|
|
|
167
211
|
errors — it never raises).
|
|
168
212
|
"""
|
|
169
213
|
try:
|
|
170
|
-
resp = self.
|
|
214
|
+
resp = self._request(
|
|
215
|
+
"get",
|
|
171
216
|
f"{self._arcana_base}/heartbeat",
|
|
172
217
|
headers=self._headers(),
|
|
173
218
|
timeout=10,
|
|
@@ -185,7 +230,8 @@ class ArcanaService:
|
|
|
185
230
|
Returns:
|
|
186
231
|
A dict with user profile fields.
|
|
187
232
|
"""
|
|
188
|
-
resp = self.
|
|
233
|
+
resp = self._request(
|
|
234
|
+
"get",
|
|
189
235
|
f"{self._arcana_base}/user/me",
|
|
190
236
|
headers=self._headers(),
|
|
191
237
|
)
|
|
@@ -251,7 +297,8 @@ class ArcanaService:
|
|
|
251
297
|
if append_uuid:
|
|
252
298
|
name = f"{name}-{_uuid.uuid4()}"
|
|
253
299
|
|
|
254
|
-
resp = self.
|
|
300
|
+
resp = self._request(
|
|
301
|
+
"post",
|
|
255
302
|
f"{self._arcana_base}/arcana/",
|
|
256
303
|
headers={**self._headers(), "Content-Type": "application/json"},
|
|
257
304
|
json={"name": name},
|
|
@@ -295,7 +342,8 @@ class ArcanaService:
|
|
|
295
342
|
full_id = name
|
|
296
343
|
name = extract_arcana_name(name)
|
|
297
344
|
|
|
298
|
-
resp = self.
|
|
345
|
+
resp = self._request(
|
|
346
|
+
"delete",
|
|
299
347
|
f"{self._arcana_base}/arcana/{quote(name, safe='')}",
|
|
300
348
|
headers=self._headers(),
|
|
301
349
|
)
|
|
@@ -317,7 +365,8 @@ class ArcanaService:
|
|
|
317
365
|
Returns:
|
|
318
366
|
A list of arcana dicts.
|
|
319
367
|
"""
|
|
320
|
-
resp = self.
|
|
368
|
+
resp = self._request(
|
|
369
|
+
"get",
|
|
321
370
|
f"{self._arcana_base}/arcana/",
|
|
322
371
|
headers=self._headers(),
|
|
323
372
|
)
|
|
@@ -384,7 +433,8 @@ class ArcanaService:
|
|
|
384
433
|
Arcana details dict.
|
|
385
434
|
"""
|
|
386
435
|
name = extract_arcana_name(name)
|
|
387
|
-
resp = self.
|
|
436
|
+
resp = self._request(
|
|
437
|
+
"get",
|
|
388
438
|
f"{self._arcana_base}/arcana/{quote(name, safe='')}",
|
|
389
439
|
headers=self._headers(),
|
|
390
440
|
)
|
|
@@ -469,13 +519,15 @@ class ArcanaService:
|
|
|
469
519
|
base = f"{self._arcana_base}/arcana/{quote(name, safe='')}/files"
|
|
470
520
|
with open(file_path, "rb") as f:
|
|
471
521
|
if overwrite:
|
|
472
|
-
resp = self.
|
|
522
|
+
resp = self._request(
|
|
523
|
+
"put",
|
|
473
524
|
f"{base}/{quote(file_path.name, safe='')}",
|
|
474
525
|
headers=self._headers(),
|
|
475
526
|
files={"file": (file_path.name, f)},
|
|
476
527
|
)
|
|
477
528
|
else:
|
|
478
|
-
resp = self.
|
|
529
|
+
resp = self._request(
|
|
530
|
+
"post",
|
|
479
531
|
f"{base}/",
|
|
480
532
|
headers=self._headers(),
|
|
481
533
|
files={"file": (file_path.name, f)},
|
|
@@ -492,6 +544,7 @@ class ArcanaService:
|
|
|
492
544
|
recursive: bool = False,
|
|
493
545
|
overwrite: bool = False,
|
|
494
546
|
verbose: bool = False,
|
|
547
|
+
on_result: Callable[[Path, dict], None] | None = None,
|
|
495
548
|
) -> list[dict]:
|
|
496
549
|
"""Upload all files in a directory to an arcana.
|
|
497
550
|
|
|
@@ -504,6 +557,10 @@ class ArcanaService:
|
|
|
504
557
|
(uses ``**/<pattern>``).
|
|
505
558
|
overwrite: If ``True``, replace existing files with the same name.
|
|
506
559
|
verbose: If ``True``, print per-file upload status.
|
|
560
|
+
on_result: Optional callback invoked as ``on_result(local_path,
|
|
561
|
+
entry)`` after each file (``entry`` is that file's
|
|
562
|
+
``{"file", "status", ["error"]}`` dict), for inline per-file
|
|
563
|
+
provenance / transaction logging.
|
|
507
564
|
|
|
508
565
|
Returns:
|
|
509
566
|
A list of dicts with keys ``"file"`` (filename only),
|
|
@@ -517,6 +574,7 @@ class ArcanaService:
|
|
|
517
574
|
verb="uploaded",
|
|
518
575
|
desc="Uploading",
|
|
519
576
|
verbose=verbose,
|
|
577
|
+
on_result=on_result,
|
|
520
578
|
)
|
|
521
579
|
|
|
522
580
|
def upload_files(
|
|
@@ -526,6 +584,7 @@ class ArcanaService:
|
|
|
526
584
|
*,
|
|
527
585
|
overwrite: bool = True,
|
|
528
586
|
verbose: bool = False,
|
|
587
|
+
on_result: Callable[[Path, dict], None] | None = None,
|
|
529
588
|
) -> list[dict]:
|
|
530
589
|
"""Upload an explicit, caller-chosen list of files to an arcana.
|
|
531
590
|
|
|
@@ -544,6 +603,11 @@ class ArcanaService:
|
|
|
544
603
|
the typical caller has already decided these files are new or
|
|
545
604
|
changed.
|
|
546
605
|
verbose: If ``True``, print per-file upload status.
|
|
606
|
+
on_result: Optional callback invoked as ``on_result(local_path,
|
|
607
|
+
entry)`` after each file (``entry`` is that file's
|
|
608
|
+
``{"file", "status", ["error"]}`` dict). Lets a caller record
|
|
609
|
+
per-file provenance (e.g. a git SHA) or a transaction-log entry
|
|
610
|
+
as each upload completes, without reimplementing this loop.
|
|
547
611
|
|
|
548
612
|
Returns:
|
|
549
613
|
A list of ``{"file", "status", ["error"]}`` dicts — the same shape
|
|
@@ -556,6 +620,7 @@ class ArcanaService:
|
|
|
556
620
|
verb="uploaded",
|
|
557
621
|
desc="Uploading",
|
|
558
622
|
verbose=verbose,
|
|
623
|
+
on_result=on_result,
|
|
559
624
|
)
|
|
560
625
|
|
|
561
626
|
def list_files(self, name: str) -> list[dict]:
|
|
@@ -588,7 +653,8 @@ class ArcanaService:
|
|
|
588
653
|
has no per-file index call.
|
|
589
654
|
"""
|
|
590
655
|
name = extract_arcana_name(name)
|
|
591
|
-
resp = self.
|
|
656
|
+
resp = self._request(
|
|
657
|
+
"get",
|
|
592
658
|
f"{self._arcana_base}/arcana/{quote(name, safe='')}/files/",
|
|
593
659
|
headers=self._headers(),
|
|
594
660
|
)
|
|
@@ -609,7 +675,8 @@ class ArcanaService:
|
|
|
609
675
|
The API response, or ``None`` if the response has no body.
|
|
610
676
|
"""
|
|
611
677
|
name = extract_arcana_name(name)
|
|
612
|
-
resp = self.
|
|
678
|
+
resp = self._request(
|
|
679
|
+
"delete",
|
|
613
680
|
f"{self._arcana_base}/arcana/{quote(name, safe='')}/files/{quote(file_name, safe='')}",
|
|
614
681
|
headers=self._headers(),
|
|
615
682
|
)
|
|
@@ -631,7 +698,8 @@ class ArcanaService:
|
|
|
631
698
|
The path the file was written to.
|
|
632
699
|
"""
|
|
633
700
|
name = extract_arcana_name(name)
|
|
634
|
-
resp = self.
|
|
701
|
+
resp = self._request(
|
|
702
|
+
"get",
|
|
635
703
|
f"{self._arcana_base}/arcana/{quote(name, safe='')}/files/{quote(file_name, safe='')}/download",
|
|
636
704
|
headers=self._headers(),
|
|
637
705
|
stream=True,
|
|
@@ -651,6 +719,7 @@ class ArcanaService:
|
|
|
651
719
|
pattern: str = "*",
|
|
652
720
|
recursive: bool = False,
|
|
653
721
|
verbose: bool = False,
|
|
722
|
+
on_result: Callable[[Path, dict], None] | None = None,
|
|
654
723
|
) -> list[dict]:
|
|
655
724
|
"""Delete files from an arcana that match filenames in a local directory.
|
|
656
725
|
|
|
@@ -665,6 +734,10 @@ class ArcanaService:
|
|
|
665
734
|
pattern: Glob pattern to filter files (default ``"*"``).
|
|
666
735
|
recursive: If ``True``, search subdirectories recursively.
|
|
667
736
|
verbose: If ``True``, print per-file deletion status.
|
|
737
|
+
on_result: Optional callback invoked as ``on_result(local_path,
|
|
738
|
+
entry)`` after each file (``entry`` is that file's
|
|
739
|
+
``{"file", "status", ["error"]}`` dict), for inline per-file
|
|
740
|
+
logging.
|
|
668
741
|
|
|
669
742
|
Returns:
|
|
670
743
|
A list of dicts with keys ``"file"`` (filename only),
|
|
@@ -678,6 +751,7 @@ class ArcanaService:
|
|
|
678
751
|
verb="deleted",
|
|
679
752
|
desc="Deleting",
|
|
680
753
|
verbose=verbose,
|
|
754
|
+
on_result=on_result,
|
|
681
755
|
)
|
|
682
756
|
|
|
683
757
|
def sync_directory(
|
|
@@ -692,6 +766,7 @@ class ArcanaService:
|
|
|
692
766
|
index: bool = True,
|
|
693
767
|
index_wait: bool = True,
|
|
694
768
|
verbose: bool = False,
|
|
769
|
+
on_result: Callable[[Path, dict], None] | None = None,
|
|
695
770
|
) -> dict:
|
|
696
771
|
"""Sync a local directory into an arcana under caller-defined rules.
|
|
697
772
|
|
|
@@ -703,6 +778,9 @@ class ArcanaService:
|
|
|
703
778
|
detection (e.g. SHA-256 against your own manifest) belongs in
|
|
704
779
|
``select``.
|
|
705
780
|
|
|
781
|
+
The single index pass is efficient because the server only (re)embeds
|
|
782
|
+
files that are not already ``INDEXED`` — see :meth:`generate_index`.
|
|
783
|
+
|
|
706
784
|
Args:
|
|
707
785
|
name: The arcana name or full ``owner/name`` ID.
|
|
708
786
|
directory: Local directory to sync from.
|
|
@@ -721,6 +799,14 @@ class ArcanaService:
|
|
|
721
799
|
the sync — but only when something actually changed.
|
|
722
800
|
index_wait: Forwarded to :meth:`generate_index` as ``wait``.
|
|
723
801
|
verbose: If ``True``, print per-file actions and a summary.
|
|
802
|
+
on_result: Optional callback invoked as ``on_result(local_path,
|
|
803
|
+
entry)`` for each *local* file as it is uploaded, replaced, or
|
|
804
|
+
skipped (``entry`` mirrors the batch-helper shape:
|
|
805
|
+
``{"file", "status", ["error"]}``, where ``status`` is one of
|
|
806
|
+
``"uploaded"`` / ``"replaced"`` / ``"skipped"`` / ``"failed"``).
|
|
807
|
+
Lets callers record per-file provenance / transaction logs
|
|
808
|
+
inline. Not called for ``prune`` deletions (those are
|
|
809
|
+
remote-only).
|
|
724
810
|
|
|
725
811
|
Returns:
|
|
726
812
|
A report ``dict`` with keys ``"uploaded"``, ``"replaced"``,
|
|
@@ -764,18 +850,26 @@ class ArcanaService:
|
|
|
764
850
|
for path, action in plan:
|
|
765
851
|
if action == "skip":
|
|
766
852
|
report["skipped"].append(path.name)
|
|
853
|
+
if on_result is not None:
|
|
854
|
+
on_result(path, {"file": path.name, "status": "skipped"})
|
|
767
855
|
continue
|
|
768
856
|
overwrite = action == "replace"
|
|
769
857
|
bucket = "replaced" if overwrite else "uploaded"
|
|
858
|
+
entry: dict = {"file": path.name}
|
|
770
859
|
try:
|
|
771
860
|
self.upload(name, path, overwrite=overwrite)
|
|
772
861
|
report[bucket].append(path.name)
|
|
862
|
+
entry["status"] = bucket
|
|
773
863
|
if verbose:
|
|
774
864
|
print(f" {path.name} {bucket}")
|
|
775
865
|
except Exception as e:
|
|
866
|
+
entry["status"] = "failed"
|
|
867
|
+
entry["error"] = str(e)
|
|
776
868
|
report["failed"].append({"file": path.name, "error": str(e)})
|
|
777
869
|
if verbose:
|
|
778
870
|
print(f" {path.name} FAILED ({e})")
|
|
871
|
+
if on_result is not None:
|
|
872
|
+
on_result(path, entry)
|
|
779
873
|
|
|
780
874
|
if prune:
|
|
781
875
|
local_names = {p.name for p in local_files}
|
|
@@ -835,6 +929,16 @@ class ArcanaService:
|
|
|
835
929
|
Raises:
|
|
836
930
|
TimeoutError: If ``wait=True`` and indexing does not complete
|
|
837
931
|
within ``timeout`` seconds.
|
|
932
|
+
|
|
933
|
+
Note:
|
|
934
|
+
Indexing is incremental on the server: files already at
|
|
935
|
+
``index_status == "INDEXED"`` are skipped, so only files added or
|
|
936
|
+
replaced since the last index (an upload resets a file to
|
|
937
|
+
``NOT_INDEXED``) are (re)embedded. This is why the efficient pattern
|
|
938
|
+
is *upload only the changed files, then call this once* — the single
|
|
939
|
+
whole-arcana trigger re-embeds just those. The library relies on
|
|
940
|
+
this skip-``INDEXED`` behavior; if the server ever stops skipping,
|
|
941
|
+
the call stays correct but re-embeds the whole arcana.
|
|
838
942
|
"""
|
|
839
943
|
import threading
|
|
840
944
|
import time
|
|
@@ -861,7 +965,7 @@ class ArcanaService:
|
|
|
861
965
|
|
|
862
966
|
# Synchronous: fire the request, tolerate transport-level failures, then poll
|
|
863
967
|
try:
|
|
864
|
-
resp = self.
|
|
968
|
+
resp = self._request("post", url, headers=self._headers(), timeout=30)
|
|
865
969
|
raise_for_status(resp)
|
|
866
970
|
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError):
|
|
867
971
|
# The trigger almost certainly reached the server (the body
|
|
@@ -879,23 +983,45 @@ class ArcanaService:
|
|
|
879
983
|
if e.status_code != 504:
|
|
880
984
|
raise
|
|
881
985
|
|
|
882
|
-
# Poll until indexing finishes
|
|
986
|
+
# Poll until indexing finishes. Tolerate transient transport failures on
|
|
987
|
+
# an individual poll (the index keeps building server-side) so a slow or
|
|
988
|
+
# dropped poll GET — now that control-plane calls carry a default
|
|
989
|
+
# timeout — cannot abort a long, still-progressing reindex. Only the
|
|
990
|
+
# overall ``timeout`` deadline ends the wait; each retry is paced by the
|
|
991
|
+
# poll_interval sleep, so a genuinely-down server still gives up at the
|
|
992
|
+
# deadline.
|
|
883
993
|
deadline = time.monotonic() + timeout
|
|
884
994
|
terminal = {"INDEXED", "ERROR", "NOT_INDEXED"}
|
|
885
995
|
status = ""
|
|
996
|
+
last_error: Exception | None = None
|
|
886
997
|
|
|
887
998
|
while time.monotonic() < deadline:
|
|
888
999
|
time.sleep(poll_interval)
|
|
889
|
-
|
|
1000
|
+
try:
|
|
1001
|
+
data = self.get(name)
|
|
1002
|
+
except (
|
|
1003
|
+
requests.exceptions.Timeout,
|
|
1004
|
+
requests.exceptions.ConnectionError,
|
|
1005
|
+
) as e:
|
|
1006
|
+
last_error = e # transient — remember it, retry on the next poll
|
|
1007
|
+
continue
|
|
1008
|
+
last_error = None # a successful poll clears any prior transient error
|
|
890
1009
|
idx = data.get("index_info") or {}
|
|
891
1010
|
status = idx.get("index_status", "")
|
|
892
1011
|
if status in terminal:
|
|
893
1012
|
return data
|
|
894
1013
|
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1014
|
+
# Deadline exhausted. Surface whichever signal we actually have — the
|
|
1015
|
+
# last-seen status (slow indexing) and/or the last transport error
|
|
1016
|
+
# (polls that kept failing) — so the timeout is diagnosable rather than
|
|
1017
|
+
# reporting an empty status.
|
|
1018
|
+
msg = f"Indexing did not complete within {timeout}s."
|
|
1019
|
+
if status:
|
|
1020
|
+
msg += f" Last status: {status}."
|
|
1021
|
+
if last_error is not None:
|
|
1022
|
+
msg += f" Last poll error: {last_error!r}."
|
|
1023
|
+
msg += " Check with client.arcana.info(...)."
|
|
1024
|
+
raise TimeoutError(msg)
|
|
899
1025
|
|
|
900
1026
|
def delete_index(self, name: str) -> dict | None:
|
|
901
1027
|
"""Delete the index of an arcana.
|
|
@@ -909,7 +1035,8 @@ class ArcanaService:
|
|
|
909
1035
|
The API response, or ``None`` if the response has no body.
|
|
910
1036
|
"""
|
|
911
1037
|
name = extract_arcana_name(name)
|
|
912
|
-
resp = self.
|
|
1038
|
+
resp = self._request(
|
|
1039
|
+
"delete",
|
|
913
1040
|
f"{self._arcana_base}/arcana/{quote(name, safe='')}/delete-index",
|
|
914
1041
|
headers=self._headers(),
|
|
915
1042
|
)
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import requests as _requests
|
|
6
6
|
|
|
7
|
+
from ._http import DEFAULT_TIMEOUT
|
|
7
8
|
from .arcana import ArcanaService
|
|
8
9
|
from .auth import resolve_credentials
|
|
9
10
|
from .chat import ChatService
|
|
@@ -30,6 +31,12 @@ class SAIAClient:
|
|
|
30
31
|
hardcoded default (``https://chat-ai.academiccloud.de/v1``).
|
|
31
32
|
key_file: Explicit path to a ``.saia_api`` or ``.env`` file.
|
|
32
33
|
Ignored when ``api_key`` is provided.
|
|
34
|
+
timeout: Default ``(connect, read)`` timeout in seconds for ARCANA
|
|
35
|
+
management calls, forwarded to :class:`~saia_python.arcana.ArcanaService`.
|
|
36
|
+
Stops those calls from hanging forever when the server accepts a
|
|
37
|
+
request but never responds (e.g. while an arcana is locked
|
|
38
|
+
mid-(re)index). A single ``float`` applies to both phases; pass
|
|
39
|
+
``None`` to disable. Defaults to ``(10, 60)``.
|
|
33
40
|
|
|
34
41
|
Example::
|
|
35
42
|
|
|
@@ -48,8 +55,11 @@ class SAIAClient:
|
|
|
48
55
|
api_key: str | None = None,
|
|
49
56
|
base_url: str | None = None,
|
|
50
57
|
key_file: str | None = None,
|
|
58
|
+
*,
|
|
59
|
+
timeout: float | tuple[float, float] | None = DEFAULT_TIMEOUT,
|
|
51
60
|
):
|
|
52
61
|
self._api_key, self._base_url = resolve_credentials(api_key, base_url, key_file)
|
|
62
|
+
self._timeout = timeout
|
|
53
63
|
self._session = _requests.Session()
|
|
54
64
|
self._session.headers.update(
|
|
55
65
|
{
|
|
@@ -84,14 +94,21 @@ class SAIAClient:
|
|
|
84
94
|
def models(self) -> ModelsService:
|
|
85
95
|
"""Model listing service."""
|
|
86
96
|
if self._models is None:
|
|
87
|
-
self._models = ModelsService(
|
|
97
|
+
self._models = ModelsService(
|
|
98
|
+
self._session, self._base_url, timeout=self._timeout
|
|
99
|
+
)
|
|
88
100
|
return self._models
|
|
89
101
|
|
|
90
102
|
@property
|
|
91
103
|
def arcana(self) -> ArcanaService:
|
|
92
104
|
"""ARCANA/RAG service."""
|
|
93
105
|
if self._arcana is None:
|
|
94
|
-
self._arcana = ArcanaService(
|
|
106
|
+
self._arcana = ArcanaService(
|
|
107
|
+
self._session,
|
|
108
|
+
self._base_url,
|
|
109
|
+
self._api_key,
|
|
110
|
+
timeout=self._timeout,
|
|
111
|
+
)
|
|
95
112
|
return self._arcana
|
|
96
113
|
|
|
97
114
|
@property
|
|
@@ -156,7 +173,9 @@ class SAIAClient:
|
|
|
156
173
|
(401/403). Other non-2xx statuses (notably the expected
|
|
157
174
|
400) are tolerated since they still carry the headers.
|
|
158
175
|
"""
|
|
159
|
-
resp = self._session.get(
|
|
176
|
+
resp = self._session.get(
|
|
177
|
+
f"{self._base_url}/chat/completions", timeout=self._timeout
|
|
178
|
+
)
|
|
160
179
|
if resp.status_code in (401, 403):
|
|
161
180
|
# The probe is *expected* to 400 (missing request body) but still
|
|
162
181
|
# carries rate-limit headers. A 401/403 means the key is bad, so
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from typing import TYPE_CHECKING
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
6
|
|
|
7
|
+
from ._http import DEFAULT_TIMEOUT
|
|
7
8
|
from ._util import progress_iter
|
|
8
9
|
from .exceptions import raise_for_status
|
|
9
10
|
|
|
@@ -17,11 +18,24 @@ class ModelsService:
|
|
|
17
18
|
Args:
|
|
18
19
|
session: A :class:`requests.Session` with auth headers configured.
|
|
19
20
|
base_url: The SAIA API base URL (e.g. ``https://chat-ai.academiccloud.de/v1``).
|
|
21
|
+
timeout: Default ``(connect, read)`` timeout in seconds for the
|
|
22
|
+
``GET /models`` listing call, so it fails fast instead of hanging
|
|
23
|
+
forever when the server accepts the request but never responds. A
|
|
24
|
+
single ``float`` applies to both phases; pass ``None`` to disable.
|
|
25
|
+
Defaults to ``(10, 60)``. (The tool-capability probe keeps its own
|
|
26
|
+
per-request ``timeout``.)
|
|
20
27
|
"""
|
|
21
28
|
|
|
22
|
-
def __init__(
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
session: requests.Session,
|
|
32
|
+
base_url: str,
|
|
33
|
+
*,
|
|
34
|
+
timeout: float | tuple[float, float] | None = DEFAULT_TIMEOUT,
|
|
35
|
+
):
|
|
23
36
|
self._session = session
|
|
24
37
|
self._base_url = base_url
|
|
38
|
+
self._timeout = timeout
|
|
25
39
|
|
|
26
40
|
def list_raw(self) -> dict:
|
|
27
41
|
"""Return the raw ``/models`` response envelope, as the API sent it.
|
|
@@ -39,7 +53,7 @@ class ModelsService:
|
|
|
39
53
|
this is a dict of the form
|
|
40
54
|
``{"object": "list", "data": [...]}``.
|
|
41
55
|
"""
|
|
42
|
-
resp = self._session.get(f"{self._base_url}/models")
|
|
56
|
+
resp = self._session.get(f"{self._base_url}/models", timeout=self._timeout)
|
|
43
57
|
raise_for_status(resp)
|
|
44
58
|
return resp.json()
|
|
45
59
|
|
|
@@ -117,15 +131,16 @@ class ModelsService:
|
|
|
117
131
|
for mid in progress_iter(
|
|
118
132
|
model_ids, desc="Probing models", unit="model", enabled=not verbose
|
|
119
133
|
):
|
|
134
|
+
body: dict[str, Any] = {
|
|
135
|
+
"model": mid,
|
|
136
|
+
"messages": _PROBE_MESSAGES,
|
|
137
|
+
"tools": _PROBE_TOOLS,
|
|
138
|
+
"max_tokens": 50,
|
|
139
|
+
}
|
|
120
140
|
try:
|
|
121
141
|
resp = self._session.post(
|
|
122
142
|
f"{self._base_url}/chat/completions",
|
|
123
|
-
json=
|
|
124
|
-
"model": mid,
|
|
125
|
-
"messages": _PROBE_MESSAGES,
|
|
126
|
-
"tools": _PROBE_TOOLS,
|
|
127
|
-
"max_tokens": 50,
|
|
128
|
-
},
|
|
143
|
+
json=body,
|
|
129
144
|
timeout=30,
|
|
130
145
|
)
|
|
131
146
|
data = resp.json()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: saia-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Python wrapper for the GWDG SAIA platform REST API
|
|
5
5
|
Author: Friedrich Schwarz
|
|
6
6
|
License-Expression: AGPL-3.0-only
|
|
@@ -57,9 +57,7 @@ Dynamic: license-file
|
|
|
57
57
|
[](https://github.com/fschwar4/saia_python/blob/main/LICENSE)
|
|
58
58
|
[](https://github.com/fschwar4/saia_python/actions/workflows/tests.yml)
|
|
59
59
|
[](https://fschwar4.github.io/saia_python/)
|
|
60
|
-
|
|
61
|
-
paste the DOI badge Zenodo provides, e.g.:
|
|
62
|
-
[](https://doi.org/10.5281/zenodo.XXXXXXX) -->
|
|
60
|
+
[](https://doi.org/10.5281/zenodo.20480724)
|
|
63
61
|
|
|
64
62
|
A Python wrapper for the [GWDG SAIA (Scalable AI Accelerator) platform](https://docs.hpc.gwdg.de/services/ai-services/saia/index.html) REST API.
|
|
65
63
|
|
|
@@ -17,6 +17,7 @@ def _make_service() -> ArcanaService:
|
|
|
17
17
|
svc._base_url = "https://example.com/v1"
|
|
18
18
|
svc._arcana_base = "https://example.com/v1/arcanas/api/v1"
|
|
19
19
|
svc._api_key = "test"
|
|
20
|
+
svc._timeout = (10.0, 60.0)
|
|
20
21
|
return svc
|
|
21
22
|
|
|
22
23
|
|
|
@@ -154,6 +155,41 @@ class TestGenerateIndexTransportErrors:
|
|
|
154
155
|
with pytest.raises(TimeoutError, match="did not complete"):
|
|
155
156
|
svc.generate_index("my-arcana", poll_interval=0, timeout=0)
|
|
156
157
|
|
|
158
|
+
def test_transient_poll_timeout_is_tolerated(self):
|
|
159
|
+
"""A dropped/slow individual poll GET must not abort a still-building
|
|
160
|
+
reindex — retry on the next poll, bounded by the overall timeout."""
|
|
161
|
+
svc = _make_service()
|
|
162
|
+
post_resp = MagicMock()
|
|
163
|
+
post_resp.status_code = 200
|
|
164
|
+
post_resp.ok = True
|
|
165
|
+
svc._session.post.return_value = post_resp
|
|
166
|
+
svc._session.get.side_effect = [
|
|
167
|
+
requests.exceptions.ReadTimeout("transient poll blip"), # tolerated
|
|
168
|
+
_get_response("PENDING"),
|
|
169
|
+
_get_response("INDEXED"),
|
|
170
|
+
]
|
|
171
|
+
result = svc.generate_index("my-arcana", poll_interval=0, timeout=10)
|
|
172
|
+
assert result["index_info"]["index_status"] == "INDEXED"
|
|
173
|
+
assert svc._session.get.call_count == 3
|
|
174
|
+
|
|
175
|
+
def test_persistent_poll_timeout_reports_last_error(self):
|
|
176
|
+
"""If polls keep timing out until the deadline, the TimeoutError surfaces
|
|
177
|
+
the transport error instead of a misleading empty 'Last status'."""
|
|
178
|
+
svc = _make_service()
|
|
179
|
+
post_resp = MagicMock()
|
|
180
|
+
post_resp.status_code = 200
|
|
181
|
+
post_resp.ok = True
|
|
182
|
+
svc._session.post.return_value = post_resp
|
|
183
|
+
svc._session.get.side_effect = requests.exceptions.ReadTimeout(
|
|
184
|
+
"poll read timed out"
|
|
185
|
+
)
|
|
186
|
+
with pytest.raises(TimeoutError) as exc_info:
|
|
187
|
+
svc.generate_index("my-arcana", poll_interval=0.01, timeout=0.05)
|
|
188
|
+
msg = str(exc_info.value)
|
|
189
|
+
assert "did not complete" in msg
|
|
190
|
+
assert "poll read timed out" in msg # the transport error is surfaced
|
|
191
|
+
assert "Last status" not in msg # never got a successful status read
|
|
192
|
+
|
|
157
193
|
|
|
158
194
|
def test_generate_index_wait_false_uses_dedicated_session(monkeypatch):
|
|
159
195
|
"""wait=False fires the trigger on its OWN Session (not the shared one)
|
|
@@ -326,3 +362,104 @@ class TestSyncDirectory:
|
|
|
326
362
|
svc.list_files = MagicMock(return_value=[])
|
|
327
363
|
with pytest.raises(ValueError, match="select"):
|
|
328
364
|
svc.sync_directory("my-arcana", tmp_path, select=lambda p, r: "bogus")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class TestDefaultTimeout:
|
|
368
|
+
"""Every ARCANA control-plane call must carry a timeout so a server that
|
|
369
|
+
accepts a request but never responds raises instead of hanging forever
|
|
370
|
+
(``requests.Session`` has no native default timeout)."""
|
|
371
|
+
|
|
372
|
+
def test_default_timeout_injected_when_unset(self):
|
|
373
|
+
svc = _make_service()
|
|
374
|
+
svc._session.delete.return_value = _ok_response()
|
|
375
|
+
svc.delete_file("my-arcana", "f.txt")
|
|
376
|
+
assert svc._session.delete.call_args.kwargs["timeout"] == (10.0, 60.0)
|
|
377
|
+
|
|
378
|
+
def test_explicit_call_timeout_is_preserved(self):
|
|
379
|
+
# heartbeat passes its own timeout=10; the default must not clobber it.
|
|
380
|
+
svc = _make_service()
|
|
381
|
+
resp = MagicMock()
|
|
382
|
+
resp.status_code = 204
|
|
383
|
+
svc._session.get.return_value = resp
|
|
384
|
+
assert svc.heartbeat() is True
|
|
385
|
+
assert svc._session.get.call_args.kwargs["timeout"] == 10
|
|
386
|
+
|
|
387
|
+
def test_configured_timeout_is_used(self):
|
|
388
|
+
svc = _make_service()
|
|
389
|
+
svc._timeout = (3.0, 12.0)
|
|
390
|
+
svc._session.get.return_value = _ok_response()
|
|
391
|
+
svc.list_files("my-arcana")
|
|
392
|
+
assert svc._session.get.call_args.kwargs["timeout"] == (3.0, 12.0)
|
|
393
|
+
|
|
394
|
+
def test_timeout_propagates_from_single_call(self):
|
|
395
|
+
"""A single-file method does not swallow the timeout — it propagates."""
|
|
396
|
+
svc = _make_service()
|
|
397
|
+
svc._session.delete.side_effect = requests.exceptions.ReadTimeout(
|
|
398
|
+
"read timed out"
|
|
399
|
+
)
|
|
400
|
+
with pytest.raises(requests.exceptions.ReadTimeout):
|
|
401
|
+
svc.delete_file("my-arcana", "f.txt")
|
|
402
|
+
|
|
403
|
+
def test_batch_records_timeout_per_file_and_continues(self, tmp_path):
|
|
404
|
+
"""In a batch, a per-file timeout is recorded and the loop carries on —
|
|
405
|
+
it neither hangs nor aborts the whole batch (the reported repro)."""
|
|
406
|
+
svc = _make_service()
|
|
407
|
+
(tmp_path / "a.txt").write_text("a")
|
|
408
|
+
(tmp_path / "b.txt").write_text("b")
|
|
409
|
+
svc._session.delete.side_effect = [
|
|
410
|
+
requests.exceptions.ReadTimeout("read timed out"),
|
|
411
|
+
_ok_response(),
|
|
412
|
+
]
|
|
413
|
+
results = svc.delete_directory("my-arcana", tmp_path)
|
|
414
|
+
by_file = {r["file"]: r["status"] for r in results}
|
|
415
|
+
assert by_file == {"a.txt": "failed", "b.txt": "deleted"}
|
|
416
|
+
failed = next(r for r in results if r["status"] == "failed")
|
|
417
|
+
assert "read timed out" in failed["error"]
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class TestOnResultHook:
|
|
421
|
+
"""The per-file callback lets callers record provenance / transaction logs
|
|
422
|
+
without reimplementing the upload loop (downstream consumer feedback)."""
|
|
423
|
+
|
|
424
|
+
def test_upload_files_invokes_on_result_per_file_in_order(self, tmp_path):
|
|
425
|
+
svc = _make_service()
|
|
426
|
+
svc._session.put.return_value = _ok_response()
|
|
427
|
+
f1 = tmp_path / "a.txt"
|
|
428
|
+
f1.write_text("a")
|
|
429
|
+
f2 = tmp_path / "b.txt"
|
|
430
|
+
f2.write_text("b")
|
|
431
|
+
seen = []
|
|
432
|
+
svc.upload_files(
|
|
433
|
+
"kb", [f1, f2], on_result=lambda p, e: seen.append((p, e["status"]))
|
|
434
|
+
)
|
|
435
|
+
assert seen == [(f1, "uploaded"), (f2, "uploaded")]
|
|
436
|
+
|
|
437
|
+
def test_on_result_reports_failure_with_error(self, tmp_path):
|
|
438
|
+
svc = _make_service()
|
|
439
|
+
svc._session.put.return_value = _ok_response()
|
|
440
|
+
seen = []
|
|
441
|
+
svc.upload_files(
|
|
442
|
+
"kb", [tmp_path / "missing.txt"], on_result=lambda p, e: seen.append(e)
|
|
443
|
+
)
|
|
444
|
+
assert seen[0]["status"] == "failed"
|
|
445
|
+
assert "error" in seen[0]
|
|
446
|
+
|
|
447
|
+
def test_sync_directory_invokes_on_result_for_local_files(self, tmp_path):
|
|
448
|
+
svc = _make_service()
|
|
449
|
+
(tmp_path / "new.txt").write_text("n")
|
|
450
|
+
(tmp_path / "keep.txt").write_text("k")
|
|
451
|
+
svc.list_files = MagicMock(return_value=[{"name": "keep.txt"}])
|
|
452
|
+
svc.upload = MagicMock(return_value={"status": "ok"})
|
|
453
|
+
svc.generate_index = MagicMock(return_value=None)
|
|
454
|
+
seen = {}
|
|
455
|
+
|
|
456
|
+
def select(path, remote):
|
|
457
|
+
return "upload" if remote is None else "skip"
|
|
458
|
+
|
|
459
|
+
svc.sync_directory(
|
|
460
|
+
"kb",
|
|
461
|
+
tmp_path,
|
|
462
|
+
select=select,
|
|
463
|
+
on_result=lambda p, e: seen.__setitem__(p.name, e["status"]),
|
|
464
|
+
)
|
|
465
|
+
assert seen == {"new.txt": "uploaded", "keep.txt": "skipped"}
|
|
@@ -330,6 +330,7 @@ class TestArcanaSummary:
|
|
|
330
330
|
svc._base_url = "https://example.com/v1"
|
|
331
331
|
svc._arcana_base = "https://example.com/v1/arcanas/api/v1"
|
|
332
332
|
svc._api_key = "test"
|
|
333
|
+
svc._timeout = (10.0, 60.0)
|
|
333
334
|
|
|
334
335
|
mock_resp = MagicMock()
|
|
335
336
|
mock_resp.status_code = 200
|
|
@@ -17,6 +17,7 @@ def _make_client() -> SAIAClient:
|
|
|
17
17
|
client = SAIAClient.__new__(SAIAClient)
|
|
18
18
|
client._base_url = "https://example.com/v1"
|
|
19
19
|
client._session = MagicMock()
|
|
20
|
+
client._timeout = (10.0, 60.0)
|
|
20
21
|
return client
|
|
21
22
|
|
|
22
23
|
|
|
@@ -57,3 +58,11 @@ def test_get_rate_limits_raises_on_403():
|
|
|
57
58
|
client._session.get.return_value = _resp(403, text="Forbidden")
|
|
58
59
|
with pytest.raises(AuthenticationError):
|
|
59
60
|
client.get_rate_limits()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_get_rate_limits_probe_carries_timeout():
|
|
64
|
+
"""The probe GET must carry a timeout so a stalled server can't hang it."""
|
|
65
|
+
client = _make_client()
|
|
66
|
+
client._session.get.return_value = _resp(400)
|
|
67
|
+
client.get_rate_limits()
|
|
68
|
+
assert client._session.get.call_args.kwargs["timeout"] == (10.0, 60.0)
|
|
@@ -30,7 +30,10 @@ def test_list_raw_returns_envelope_not_unwrapped():
|
|
|
30
30
|
result = svc.list_raw()
|
|
31
31
|
assert result["object"] == "list" # envelope preserved, not unwrapped
|
|
32
32
|
assert result["data"] == _ENVELOPE["data"]
|
|
33
|
-
|
|
33
|
+
# GET /models (not POST), and carries the default timeout so it can't hang.
|
|
34
|
+
session.get.assert_called_once_with(
|
|
35
|
+
"https://example.com/v1/models", timeout=(10.0, 60.0)
|
|
36
|
+
)
|
|
34
37
|
|
|
35
38
|
|
|
36
39
|
def test_list_unwraps_data_from_envelope():
|
|
@@ -48,3 +51,15 @@ def test_list_handles_bare_list_response():
|
|
|
48
51
|
svc, _ = _service(bare)
|
|
49
52
|
assert svc.list() == bare
|
|
50
53
|
assert svc.list_raw() == bare
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_configured_timeout_is_forwarded():
|
|
57
|
+
"""A custom timeout passed to the constructor reaches the GET /models call."""
|
|
58
|
+
session = MagicMock()
|
|
59
|
+
resp = MagicMock()
|
|
60
|
+
resp.ok = True
|
|
61
|
+
resp.json.return_value = _ENVELOPE
|
|
62
|
+
session.get.return_value = resp
|
|
63
|
+
svc = ModelsService(session, "https://example.com/v1", timeout=(3.0, 9.0))
|
|
64
|
+
svc.list_raw()
|
|
65
|
+
assert session.get.call_args.kwargs["timeout"] == (3.0, 9.0)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|