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.
Files changed (41) hide show
  1. {saia_python-0.4.1/saia_python.egg-info → saia_python-0.5.0}/PKG-INFO +2 -4
  2. {saia_python-0.4.1 → saia_python-0.5.0}/README.md +1 -3
  3. {saia_python-0.4.1 → saia_python-0.5.0}/pyproject.toml +1 -1
  4. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/_http.py +9 -0
  5. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/_streaming.py +4 -1
  6. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/arcana.py +155 -28
  7. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/client.py +22 -3
  8. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/models.py +24 -9
  9. {saia_python-0.4.1 → saia_python-0.5.0/saia_python.egg-info}/PKG-INFO +2 -4
  10. {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_arcana.py +137 -0
  11. {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_auth.py +1 -0
  12. {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_client.py +9 -0
  13. {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_models.py +16 -1
  14. {saia_python-0.4.1 → saia_python-0.5.0}/LICENSE +0 -0
  15. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/__init__.py +0 -0
  16. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/_util.py +0 -0
  17. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/arcana_references.py +0 -0
  18. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/auth.py +0 -0
  19. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/chat.py +0 -0
  20. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/documents.py +0 -0
  21. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/exceptions.py +0 -0
  22. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/openai_compat.py +0 -0
  23. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/py.typed +0 -0
  24. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/rate_limits.py +0 -0
  25. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/responses.py +0 -0
  26. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python/voice.py +0 -0
  27. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python.egg-info/SOURCES.txt +0 -0
  28. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python.egg-info/dependency_links.txt +0 -0
  29. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python.egg-info/requires.txt +0 -0
  30. {saia_python-0.4.1 → saia_python-0.5.0}/saia_python.egg-info/top_level.txt +0 -0
  31. {saia_python-0.4.1 → saia_python-0.5.0}/setup.cfg +0 -0
  32. {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_arcana_references.py +0 -0
  33. {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_chat.py +0 -0
  34. {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_exceptions.py +0 -0
  35. {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_health_check.py +0 -0
  36. {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_openai_compat.py +0 -0
  37. {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_rate_limits.py +0 -0
  38. {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_responses.py +0 -0
  39. {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_setup_from_directory.py +0 -0
  40. {saia_python-0.4.1 → saia_python-0.5.0}/tests/test_streaming.py +0 -0
  41. {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.4.1
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
  [![License: AGPL-3.0-only](https://img.shields.io/badge/license-AGPL--3.0--only-blue.svg)](https://github.com/fschwar4/saia_python/blob/main/LICENSE)
58
58
  [![Tests](https://github.com/fschwar4/saia_python/actions/workflows/tests.yml/badge.svg)](https://github.com/fschwar4/saia_python/actions/workflows/tests.yml)
59
59
  [![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://fschwar4.github.io/saia_python/)
60
- <!-- After enabling Zenodo (Settings → Integrations → GitHub) and cutting a release,
61
- paste the DOI badge Zenodo provides, e.g.:
62
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.XXXXXXX.svg)](https://doi.org/10.5281/zenodo.XXXXXXX) -->
60
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.20480724.svg)](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
  [![License: AGPL-3.0-only](https://img.shields.io/badge/license-AGPL--3.0--only-blue.svg)](https://github.com/fschwar4/saia_python/blob/main/LICENSE)
6
6
  [![Tests](https://github.com/fschwar4/saia_python/actions/workflows/tests.yml/badge.svg)](https://github.com/fschwar4/saia_python/actions/workflows/tests.yml)
7
7
  [![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://fschwar4.github.io/saia_python/)
8
- <!-- After enabling Zenodo (Settings → Integrations → GitHub) and cutting a release,
9
- paste the DOI badge Zenodo provides, e.g.:
10
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.XXXXXXX.svg)](https://doi.org/10.5281/zenodo.XXXXXXX) -->
8
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.20480724.svg)](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
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "saia-python"
7
- version = "0.4.1"
7
+ version = "0.5.0"
8
8
  description = "Python wrapper for the GWDG SAIA platform REST API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -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 line in response.iter_lines(decode_unicode=True):
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__(self, session: requests.Session, base_url: str, api_key: str):
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` and
120
- :meth:`delete_directory`: an optionally tqdm-wrapped loop that records
121
- a ``{"file", "status", ["error"]}`` dict per item and prints a tally
122
- when ``verbose``. Only the per-file ``action`` and the past-tense
123
- ``verb`` (``"uploaded"`` / ``"deleted"``) differ between callers.
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._session.get(
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._session.get(
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._session.get(
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._session.post(
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._session.delete(
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._session.get(
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._session.get(
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._session.put(
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._session.post(
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._session.get(
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._session.delete(
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._session.get(
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._session.post(url, headers=self._headers(), timeout=30)
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
- data = self.get(name)
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
- raise TimeoutError(
896
- f"Indexing did not complete within {timeout}s. "
897
- f"Last status: {status}. Check with client.arcana.info(...)."
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._session.delete(
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(self._session, self._base_url)
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(self._session, self._base_url, self._api_key)
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(f"{self._base_url}/chat/completions")
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__(self, session: requests.Session, base_url: str):
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.4.1
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
  [![License: AGPL-3.0-only](https://img.shields.io/badge/license-AGPL--3.0--only-blue.svg)](https://github.com/fschwar4/saia_python/blob/main/LICENSE)
58
58
  [![Tests](https://github.com/fschwar4/saia_python/actions/workflows/tests.yml/badge.svg)](https://github.com/fschwar4/saia_python/actions/workflows/tests.yml)
59
59
  [![Docs](https://img.shields.io/badge/docs-online-blue.svg)](https://fschwar4.github.io/saia_python/)
60
- <!-- After enabling Zenodo (Settings → Integrations → GitHub) and cutting a release,
61
- paste the DOI badge Zenodo provides, e.g.:
62
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.XXXXXXX.svg)](https://doi.org/10.5281/zenodo.XXXXXXX) -->
60
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.20480724.svg)](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
- session.get.assert_called_once_with("https://example.com/v1/models")
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