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.
- {saia_python-0.7.0/saia_python.egg-info → saia_python-0.8.0}/PKG-INFO +1 -1
- {saia_python-0.7.0 → saia_python-0.8.0}/pyproject.toml +1 -1
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/arcana.py +107 -0
- {saia_python-0.7.0 → saia_python-0.8.0/saia_python.egg-info}/PKG-INFO +1 -1
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_arcana.py +85 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/LICENSE +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/README.md +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/__init__.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/_http.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/_streaming.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/_util.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/arcana_references.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/auth.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/chat.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/client.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/documents.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/exceptions.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/models.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/openai_compat.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/py.typed +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/rate_limits.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/responses.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/tokenizer.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python/voice.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python.egg-info/SOURCES.txt +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python.egg-info/dependency_links.txt +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python.egg-info/requires.txt +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/saia_python.egg-info/top_level.txt +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/setup.cfg +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_arcana_references.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_auth.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_chat.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_client.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_documents.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_exceptions.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_health_check.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_models.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_openai_compat.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_rate_limits.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_responses.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_setup_from_directory.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_streaming.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_tokenizer.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_transport_policy.py +0 -0
- {saia_python-0.7.0 → saia_python-0.8.0}/tests/test_voice.py +0 -0
|
@@ -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
|
|
|
@@ -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
|
|
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
|
|
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
|