Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
15 changes: 14 additions & 1 deletion lbb/_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
10 changes: 9 additions & 1 deletion lbb/_sync_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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},
Expand Down
2 changes: 1 addition & 1 deletion lbb/_version.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Package version shared by build metadata and runtime telemetry."""

__version__ = "0.9.0"
__version__ = "0.9.1"
26 changes: 26 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion tests/test_public_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading