saia-python 0.7.0__tar.gz → 0.8.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 (45) hide show
  1. {saia_python-0.7.0/saia_python.egg-info → saia_python-0.8.0}/PKG-INFO +1 -1
  2. {saia_python-0.7.0 → saia_python-0.8.0}/pyproject.toml +1 -1
  3. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/arcana.py +107 -0
  4. {saia_python-0.7.0 → saia_python-0.8.0/saia_python.egg-info}/PKG-INFO +1 -1
  5. {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_arcana.py +85 -0
  6. {saia_python-0.7.0 → saia_python-0.8.0}/LICENSE +0 -0
  7. {saia_python-0.7.0 → saia_python-0.8.0}/README.md +0 -0
  8. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/__init__.py +0 -0
  9. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/_http.py +0 -0
  10. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/_streaming.py +0 -0
  11. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/_util.py +0 -0
  12. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/arcana_references.py +0 -0
  13. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/auth.py +0 -0
  14. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/chat.py +0 -0
  15. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/client.py +0 -0
  16. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/documents.py +0 -0
  17. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/exceptions.py +0 -0
  18. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/models.py +0 -0
  19. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/openai_compat.py +0 -0
  20. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/py.typed +0 -0
  21. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/rate_limits.py +0 -0
  22. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/responses.py +0 -0
  23. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/tokenizer.py +0 -0
  24. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/voice.py +0 -0
  25. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python.egg-info/SOURCES.txt +0 -0
  26. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python.egg-info/dependency_links.txt +0 -0
  27. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python.egg-info/requires.txt +0 -0
  28. {saia_python-0.7.0 → saia_python-0.8.0}/saia_python.egg-info/top_level.txt +0 -0
  29. {saia_python-0.7.0 → saia_python-0.8.0}/setup.cfg +0 -0
  30. {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_arcana_references.py +0 -0
  31. {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_auth.py +0 -0
  32. {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_chat.py +0 -0
  33. {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_client.py +0 -0
  34. {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_documents.py +0 -0
  35. {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_exceptions.py +0 -0
  36. {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_health_check.py +0 -0
  37. {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_models.py +0 -0
  38. {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_openai_compat.py +0 -0
  39. {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_rate_limits.py +0 -0
  40. {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_responses.py +0 -0
  41. {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_setup_from_directory.py +0 -0
  42. {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_streaming.py +0 -0
  43. {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_tokenizer.py +0 -0
  44. {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_transport_policy.py +0 -0
  45. {saia_python-0.7.0 → saia_python-0.8.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.7.0
3
+ Version: 0.8.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "saia-python"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "Python wrapper for the GWDG SAIA platform REST API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -415,6 +415,49 @@ class ArcanaService:
415
415
 
416
416
  return _json_or_none(resp)
417
417
 
418
+ def recreate(self, name: str, *, update_toml: bool = False) -> dict:
419
+ """Delete an arcana and recreate it **empty with the same ID**.
420
+
421
+ The minimal-call way to wipe an entire arcana: two requests
422
+ (:meth:`delete` + :meth:`create`) regardless of how many files it
423
+ holds, versus one :meth:`delete_file` per file (thousands of calls,
424
+ each its own read-timeout risk while the arcana is busy). The name —
425
+ including any UUID suffix — is preserved verbatim via
426
+ ``create(..., append_uuid=False)``, so a downstream pin on the full
427
+ ``owner/name-uuid`` ID stays valid.
428
+
429
+ Trade-offs versus emptying file-by-file (:meth:`delete_file` in a
430
+ loop, which keeps the container): there is a brief window between the
431
+ two calls where the arcana does **not exist**, and the recreated
432
+ arcana is brand-new — ``created_at``, sharing/permissions and any
433
+ other container settings reset to defaults. Only the name/ID carries
434
+ across.
435
+
436
+ Args:
437
+ name: The arcana name or full ``owner/name`` ID to recreate.
438
+ update_toml: Forwarded to :meth:`create`.
439
+
440
+ Returns:
441
+ The :meth:`create` result (``{"name", "id", "message"}``).
442
+
443
+ Raises:
444
+ APIError: If recreation fails *after* the delete already
445
+ succeeded — the arcana is then gone. The message says so
446
+ explicitly so the operator recreates it before any consumer
447
+ (adapter / manifest pin) points at the ID.
448
+ """
449
+ short = extract_arcana_name(name)
450
+ self.delete(name)
451
+ try:
452
+ return self.create(short, append_uuid=False, update_toml=update_toml)
453
+ except Exception as e: # noqa: BLE001 — re-raise with a louder message
454
+ raise APIError(
455
+ f"arcana {short!r} was DELETED but could not be recreated: {e}. "
456
+ f"The arcana no longer exists — recreate it (e.g. "
457
+ f"create({short!r}, append_uuid=False)) before any consumer "
458
+ f"(adapter / manifest pin) points at its ID."
459
+ ) from e
460
+
418
461
  def list(self) -> list[dict]:
419
462
  """List all available arcanas.
420
463
 
@@ -742,6 +785,70 @@ class ArcanaService:
742
785
  raise_for_status(resp)
743
786
  return _json_or_none(resp)
744
787
 
788
+ def delete_files(
789
+ self,
790
+ name: str,
791
+ file_names: Iterable[str],
792
+ *,
793
+ verbose: bool = False,
794
+ on_result: Callable[[str, dict], None] | None = None,
795
+ ) -> list[dict]:
796
+ """Delete an explicit list of files from an arcana, by name.
797
+
798
+ The batch counterpart to :meth:`delete_file`: hand it the file names
799
+ (as returned by :meth:`list_files`) and it deletes each one,
800
+ capturing a per-file outcome instead of aborting on the first
801
+ failure. Unlike :meth:`delete_directory` no local directory is
802
+ consulted — the names come straight from the caller — so it can
803
+ target files that no longer exist on disk (e.g. a name list from a
804
+ CSV). Pairs with a thin CLI front-end that resolves *which* names to
805
+ delete; this method only does the deleting.
806
+
807
+ Args:
808
+ name: The arcana name or full ``owner/name`` ID.
809
+ file_names: The file names to delete (flat names, as listed by
810
+ :meth:`list_files`). Order is preserved; a repeated name is
811
+ attempted each time (a second delete just reports the
812
+ server's response / 404).
813
+ verbose: If ``True``, print per-file deletion status.
814
+ on_result: Optional callback invoked as ``on_result(file_name,
815
+ entry)`` after each file (``entry`` is that file's
816
+ ``{"file", "status", ["error"]}`` dict), for inline
817
+ per-file logging.
818
+
819
+ Returns:
820
+ A list of dicts with keys ``"file"`` (the name), ``"status"``
821
+ (``"deleted"`` or ``"failed"``), and ``"error"`` (only on
822
+ failure) — the same shape every batch op returns.
823
+ """
824
+ names = [str(n) for n in file_names]
825
+ # Arcana file names are flat (as listed by :meth:`list_files`). The
826
+ # batch executor below is Path-centric and targets ``Path(n).name``;
827
+ # a name containing ``/`` would silently collapse to its basename and
828
+ # delete the WRONG file. Refuse the whole batch up front — atomic, so
829
+ # nothing is deleted if any name is malformed (e.g. from a bad CSV).
830
+ bad = [n for n in names if "/" in n]
831
+ if bad:
832
+ raise ValueError(
833
+ f"ARCANA file names are flat (no '/'); refusing to delete "
834
+ f"{len(bad)} name(s) containing a path separator: {bad}"
835
+ )
836
+ # Reuse the shared batch executor (iteration, per-file error capture,
837
+ # progress bar, on_result, tally). With flat names ``Path(n).name ==
838
+ # n``, so the label/delete-target round-trips exactly.
839
+ return self._run_file_batch(
840
+ [Path(n) for n in names],
841
+ lambda fp: self.delete_file(name, fp.name),
842
+ default_status="deleted",
843
+ desc="Deleting",
844
+ verbose=verbose,
845
+ on_result=(
846
+ None
847
+ if on_result is None
848
+ else lambda fp, entry: on_result(fp.name, entry)
849
+ ),
850
+ )
851
+
745
852
  def download_file(self, name: str, file_name: str, output_path: str | Path) -> Path:
746
853
  """Download a file from an arcana to a local path.
747
854
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: saia-python
3
- Version: 0.7.0
3
+ Version: 0.8.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
@@ -513,3 +513,88 @@ class TestOnResultHook:
513
513
  on_result=lambda p, e: seen.__setitem__(p.name, e["status"]),
514
514
  )
515
515
  assert seen == {"new.txt": "uploaded", "keep.txt": "skipped"}
516
+
517
+
518
+ class TestDeleteFiles:
519
+ """ArcanaService.delete_files — batch delete by an explicit name list."""
520
+
521
+ def test_deletes_each_name_and_returns_entries(self):
522
+ svc = _make_service()
523
+ svc.delete_file = MagicMock(return_value=None)
524
+ results = svc.delete_files("my-arcana", ["a.md", "b.md", "c.md"])
525
+ assert [r["file"] for r in results] == ["a.md", "b.md", "c.md"]
526
+ assert {r["status"] for r in results} == {"deleted"}
527
+ assert [c.args for c in svc.delete_file.call_args_list] == [
528
+ ("my-arcana", "a.md"),
529
+ ("my-arcana", "b.md"),
530
+ ("my-arcana", "c.md"),
531
+ ]
532
+
533
+ def test_failure_recorded_per_file_and_continues(self):
534
+ """A read-timeout on one name must not abort the rest — the whole
535
+ reason this is a batch op and not a bare loop that raises."""
536
+ svc = _make_service()
537
+
538
+ def fake_delete(arcana, fn):
539
+ if fn == "b.md":
540
+ raise requests.exceptions.ReadTimeout("read timed out")
541
+ return None
542
+
543
+ svc.delete_file = MagicMock(side_effect=fake_delete)
544
+ results = svc.delete_files("my-arcana", ["a.md", "b.md", "c.md"])
545
+ by = {r["file"]: r for r in results}
546
+ assert by["a.md"]["status"] == "deleted"
547
+ assert by["b.md"]["status"] == "failed" and "error" in by["b.md"]
548
+ assert by["c.md"]["status"] == "deleted" # continued past the failure
549
+
550
+ def test_on_result_called_with_name_and_entry(self):
551
+ svc = _make_service()
552
+ svc.delete_file = MagicMock(return_value=None)
553
+ seen = []
554
+ svc.delete_files(
555
+ "my-arcana",
556
+ ["a.md", "b.md"],
557
+ on_result=lambda n, e: seen.append((n, e["status"])),
558
+ )
559
+ assert seen == [("a.md", "deleted"), ("b.md", "deleted")]
560
+
561
+ def test_empty_list_makes_no_calls(self):
562
+ svc = _make_service()
563
+ svc.delete_file = MagicMock()
564
+ assert svc.delete_files("my-arcana", []) == []
565
+ svc.delete_file.assert_not_called()
566
+
567
+ def test_name_with_path_separator_rejected_atomically(self):
568
+ """A '/' would make the Path-based executor delete only the basename
569
+ — refuse the whole batch up front, deleting nothing."""
570
+ svc = _make_service()
571
+ svc.delete_file = MagicMock()
572
+ with pytest.raises(ValueError, match="flat"):
573
+ svc.delete_files("my-arcana", ["ok.md", "sub/dir/bad.md"])
574
+ svc.delete_file.assert_not_called() # atomic — nothing deleted
575
+
576
+
577
+ class TestRecreate:
578
+ """ArcanaService.recreate — delete + recreate-empty-with-the-same-ID."""
579
+
580
+ def test_deletes_then_creates_same_name_without_uuid(self):
581
+ svc = _make_service()
582
+ svc.delete = MagicMock(return_value=None)
583
+ svc.create = MagicMock(return_value={"name": "kb-uuid", "id": "owner/kb-uuid"})
584
+ out = svc.recreate("owner/kb-uuid")
585
+ svc.delete.assert_called_once_with("owner/kb-uuid")
586
+ # name stripped of owner, UUID preserved, append_uuid OFF → same ID
587
+ svc.create.assert_called_once_with(
588
+ "kb-uuid", append_uuid=False, update_toml=False
589
+ )
590
+ assert out["id"] == "owner/kb-uuid"
591
+
592
+ def test_create_failure_after_delete_raises_loud_apierror(self):
593
+ """If create dies after delete already landed, the arcana is gone —
594
+ that must surface as a loud, unambiguous error, not the raw 500."""
595
+ svc = _make_service()
596
+ svc.delete = MagicMock(return_value=None)
597
+ svc.create = MagicMock(side_effect=RuntimeError("gateway down"))
598
+ with pytest.raises(APIError, match="was DELETED but could not be recreated"):
599
+ svc.recreate("owner/kb-uuid")
600
+ svc.delete.assert_called_once() # the dangerous half already happened
File without changes
File without changes
File without changes