From bc6559a9d524b672dfaadf18bcf3fff3d6e03d4b Mon Sep 17 00:00:00 2001 From: Rino Montiel <516416+RinoFM@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:24:44 +0000 Subject: [PATCH] sync: lbb 25610e1aef0b --- CHANGELOG.md | 6 ++++++ README.md | 3 ++- lbb/_async_client.py | 15 ++++++++++++++- lbb/_sync_client.py | 10 +++++++++- lbb/_version.py | 2 +- tests/test_client.py | 26 ++++++++++++++++++++++++++ tests/test_public_api.py | 2 +- 7 files changed, 59 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e5a502..6663902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to the `littlebigbrain` Python SDK are documented here. +## 0.9.1 + +- Sync and async durable import submissions now reject an empty iterable before + issuing the import POST. +- The one-record preflight preserves streaming and one-shot iterator semantics. + ## 0.9.0 Durable, asynchronous NDJSON imports. diff --git a/README.md b/README.md index 2139d16..0d1bd03 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,8 @@ print(completed.state, completed.committed_commit_seq) The async client accepts an async iterable as well. Success means all grouped commits are durable and final publication was enqueued; it does not mean -published indexes have already reached `committed_commit_seq`. +published indexes have already reached `committed_commit_seq`. Empty iterables +are rejected locally before an import POST is sent. **Time-travel read.** Pin a SPARQL query to a past instant — results reflect the graph as it was then: diff --git a/lbb/_async_client.py b/lbb/_async_client.py index d217d6f..7195fbd 100644 --- a/lbb/_async_client.py +++ b/lbb/_async_client.py @@ -488,12 +488,25 @@ async def submit_import_ndjson( "submit_import_ndjson requires a non-empty idempotency_key" ) await self._require_capability("durable_import_jobs_v1") + content = _aiter_import_ndjson(lines) + try: + first = await anext(content) + except StopAsyncIteration as error: + raise ValueError( + "submit_import_ndjson requires at least one NDJSON record or byte chunk" + ) from error + + async def nonempty_content() -> AsyncIterator[bytes]: + yield first + async for chunk in content: + yield chunk + return await self._model_request( models.GraphImportJobAccepted, "POST", "/v1/graph/import-jobs", params={"batch": batch, "strict": strict, "observed_at": observed_at}, - content=_aiter_import_ndjson(lines), + content=nonempty_content(), content_type="application/x-ndjson", idempotency_key=idempotency_key, options={"max_retries": 0, "retry": False}, diff --git a/lbb/_sync_client.py b/lbb/_sync_client.py index 32dc782..2754991 100644 --- a/lbb/_sync_client.py +++ b/lbb/_sync_client.py @@ -5,6 +5,7 @@ import json import time from collections.abc import Callable, Iterable, Iterator, Mapping +from itertools import chain from typing import Any, cast import httpx @@ -258,12 +259,19 @@ def submit_import_ndjson( "submit_import_ndjson requires a non-empty idempotency_key" ) self._require_capability("durable_import_jobs_v1") + content = _iter_import_ndjson(lines) + try: + first = next(content) + except StopIteration as error: + raise ValueError( + "submit_import_ndjson requires at least one NDJSON record or byte chunk" + ) from error return self._model_request( models.GraphImportJobAccepted, "POST", "/v1/graph/import-jobs", params={"batch": batch, "strict": strict, "observed_at": observed_at}, - content=_iter_import_ndjson(lines), + content=chain((first,), content), content_type="application/x-ndjson", idempotency_key=idempotency_key, options={"max_retries": 0, "retry": False}, diff --git a/lbb/_version.py b/lbb/_version.py index b4a88f5..135b023 100644 --- a/lbb/_version.py +++ b/lbb/_version.py @@ -1,3 +1,3 @@ """Package version shared by build metadata and runtime telemetry.""" -__version__ = "0.9.0" +__version__ = "0.9.1" diff --git a/tests/test_client.py b/tests/test_client.py index 25a8490..bd0ac27 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -228,6 +228,18 @@ def test_durable_import_does_not_fallback_without_capability(self) -> None: client.submit_import_ndjson([], idempotency_key="source:2") self.assertEqual([request.url.path for request in seen], ["/version"]) + def test_durable_import_rejects_empty_source_before_post(self) -> None: + seen: list[httpx.Request] = [] + with LbbClient( + "http://h", + transport=capturing_transport( + seen, {"json": {"capabilities": ["durable_import_jobs_v1"]}} + ), + ) as client: + with self.assertRaisesRegex(ValueError, "requires at least one NDJSON"): + client.submit_import_ndjson([], idempotency_key="source:empty") + self.assertEqual([request.url.path for request in seen], ["/version"]) + def test_metadata_exposes_only_bounded_index_detail_option(self) -> None: seen: list[httpx.Request] = [] with LbbClient( @@ -1976,6 +1988,20 @@ async def handler(request: httpx.Request) -> httpx.Response: self.assertEqual(produced, 2) self.assertEqual(len(seen[1].content.splitlines()), 2) + async def test_async_durable_import_rejects_empty_source_before_post(self) -> None: + seen: list[httpx.Request] = [] + async with AsyncLbbClient( + "http://h", + transport=capturing_transport( + seen, {"json": {"capabilities": ["durable_import_jobs_v1"]}} + ), + ) as client: + with self.assertRaisesRegex(ValueError, "requires at least one NDJSON"): + await client.submit_import_ndjson( + [], idempotency_key="source:async-empty" + ) + self.assertEqual([request.url.path for request in seen], ["/version"]) + async def test_async_create_graph_returns_typed_response(self) -> None: payload = {"commit_seq": 0, "graph": GRAPH, "ontology_version": 1} async with AsyncLbbClient( diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 151ddff..ff5129c 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -26,7 +26,7 @@ def test_public_exports_are_explicit_and_stable() -> None: def test_package_version_and_primary_clients_are_available() -> None: - assert lbb.__version__ == "0.9.0" + assert lbb.__version__ == "0.9.1" try: distribution_version = version("littlebigbrain") except PackageNotFoundError: