Skip to content
Open
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
22 changes: 10 additions & 12 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
# Migrating to nebula-sdk 1.4.x
# Migration guide

When upgrading across multiple release lines, review every section newer than
your installed version and apply the applicable changes from oldest to newest.

## Migrating to nebula-sdk 1.4.x

The 1.4.x line replaces the Stainless-generated SDK with an in-house generator.
Public method names and call signatures are preserved; the **wire shapes**
for errors and list responses changed — see the breaking-change section
below.

## Breaking changes
### Breaking changes

### 1. Error envelope
#### Error envelope

Every 4xx / 5xx now returns the canonical envelope:

Expand Down Expand Up @@ -45,7 +50,7 @@ If you wrote error-class checks against the body's `error_type` field, those
no longer match — `error_type` was renamed to `type`. Switch the check to
`err.type` or the typed-class hierarchy.

### 2. Cursor pagination
#### Cursor pagination

List endpoints (`list_memories`, `list_collections`) moved from offset to
opaque cursors:
Expand All @@ -71,14 +76,7 @@ while page.has_more:
either drop it or query a dedicated count endpoint (none exists today —
file an issue if you need one).

### 3. `applied_wal_seq` on `list_memories`

For read-your-writes scenarios, `list_memories` now returns an
`applied_wal_seq` field. Pair it with `min_applied_wal_seq` on the request
to assert that a prior write is visible. Default behavior is unchanged for
callers that don't pass `min_applied_wal_seq`.

## Non-breaking improvements
### Non-breaking improvements

- `store_memories` accepts a `max_concurrency` argument (default 8) —
bounded worker pool, no more accidental unbounded fan-out under the hood.
Expand Down
24 changes: 15 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# nebula-sdk

Official Nebula API SDK for Python. Provides typed async access to the public
Nebula REST API: collections, memories, connectors, snapshots, and system
health.
Official Python SDK for Nebula by Zeroset. Provides typed async access to the
public Nebula REST API: collections, memories, connectors, snapshots, and
system health.

## Install

Expand All @@ -25,15 +25,20 @@ pip install --pre nebula-sdk

```python
import asyncio
import uuid
from nebula import Nebula, ClientOptions

async def main() -> None:
async with Nebula(ClientOptions(api_key="...")) as client:
memory_id = await client.store_memory(
collection_id="01234567-...",
raw_text="hello, world",
)
results = await client.memories.search(body={"query": "hello"})
stored = await client.memory.store(body={
"collection_id": "01234567-...",
"raw_text": "hello, world",
})
results = await client.memory.search(body={
"query": "hello",
"retrieval_operation_id": str(uuid.uuid4()),
})
print(stored)
print(results)

asyncio.run(main())
Expand Down Expand Up @@ -69,7 +74,8 @@ All HTTP errors map to a typed exception hierarchy:

## Docs

- API reference: https://docs.zeroset.com
- Product documentation: https://docs.zeroset.com
- OpenAPI contract: https://api.zeroset.com/openapi.json
- Migration notes: see `MIGRATION.md` in the source repo

## License
Expand Down
14 changes: 7 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
[project]
name = "nebula-sdk"
version = "1.6.2"
description = "Nebula Python SDK (autogenerated)"
version = "0.0.0+from-source"
description = "Official Python SDK for Nebula by Zeroset, the state and memory layer for AI agents."
readme = "README.md"
license = { text = "MIT" }
authors = [{ name = "Nebula", email = "support@zeroset.com" }]
keywords = ["nebula", "memory", "ai", "sdk", "openapi"]
authors = [{ name = "Zeroset", email = "support@zeroset.com" }]
keywords = ["nebula", "zeroset", "memory", "ai", "agents", "knowledge-graph", "retrieval", "sdk", "openapi"]
classifiers = [
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
Expand All @@ -16,11 +16,12 @@ classifiers = [
requires-python = ">=3.11"
dependencies = [
"httpx>=0.27",
"pydantic>=2.7",
"pydantic[email]>=2.12",
]

[project.urls]
Homepage = "https://github.com/zeroset-inc/nebula-python"
Homepage = "https://docs.zeroset.com/clients/python"
Documentation = "https://docs.zeroset.com"
Repository = "https://github.com/zeroset-inc/nebula-python"
Issues = "https://github.com/zeroset-inc/nebula-python/issues"

Expand All @@ -37,7 +38,6 @@ build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/nebula"]
include = ["src/nebula/py.typed"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
6 changes: 6 additions & 0 deletions src/nebula/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@

from __future__ import annotations
from . import _models as models
from . import _inputs as inputs
from ._client import NebulaClient
from ._runtime import (
ClientOptions,
RequestOptions,
RetryPolicy,
DEFAULT_RETRY,
NebulaError,
Expand All @@ -20,6 +22,7 @@
NebulaValidationError,
NebulaRateLimitError,
NebulaServerError,
PublicApiError,
)
from ._dx import Nebula

Expand All @@ -28,9 +31,11 @@
"Nebula",
"NebulaClient",
"ClientOptions",
"RequestOptions",
"RetryPolicy",
"DEFAULT_RETRY",
"models",
"inputs",
"NebulaError",
"NebulaAPIError",
"NebulaConnectionError",
Expand All @@ -43,4 +48,5 @@
"NebulaValidationError",
"NebulaRateLimitError",
"NebulaServerError",
"PublicApiError",
]
8 changes: 6 additions & 2 deletions src/nebula/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
from .resources.client import ClientResource
from .resources.collections import CollectionsResource
from .resources.connectors import ConnectorsResource
from .resources.memories import MemoriesResource
from .resources.enterprise import EnterpriseResource
from .resources.marketplace import MarketplaceResource
from .resources.memory import MemoryResource
from .resources.snapshots import SnapshotsResource
from .resources.workspaces import WorkspacesResource

Expand All @@ -19,7 +21,9 @@ def __init__(self, options: Optional[ClientOptions] = None) -> None:
self.client: ClientResource = ClientResource(self._core)
self.collections: CollectionsResource = CollectionsResource(self._core)
self.connectors: ConnectorsResource = ConnectorsResource(self._core)
self.memories: MemoriesResource = MemoriesResource(self._core)
self.enterprise: EnterpriseResource = EnterpriseResource(self._core)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Preserve the existing memory API aliases

This replacement removes NebulaClient.memories, while the same regeneration also deletes Nebula.store_memory() and store_memories() from the DX facade. Those are existing public entry points, and the migration guide still instructs users to call them and describes store_memories as available. A probe against this head confirms that Nebula.store_memory, Nebula.store_memories, and NebulaClient.memories are all absent. Existing applications will therefore fail immediately with AttributeError after what is labeled a fix release. Keep compatibility aliases/wrappers (even if deprecated) or explicitly treat and document this as a breaking release.

Evidence

  • At the merge base, NebulaClient initializes self.memories, and Nebula implements store_memory plus store_memories.
  • Head probe: Nebula store_memory False store_memories False; NebulaClient instance memories False memory True.
  • MIGRATION.md at head still calls client.store_memory and advertises store_memories.

self.marketplace: MarketplaceResource = MarketplaceResource(self._core)
self.memory: MemoryResource = MemoryResource(self._core)
self.snapshots: SnapshotsResource = SnapshotsResource(self._core)
self.workspaces: WorkspacesResource = WorkspacesResource(self._core)

Expand Down
152 changes: 13 additions & 139 deletions src/nebula/_dx.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
# Handwritten Nebula DX layer.
#
# Carries only the methods that need real dispatch logic: store_memory's
# create-vs-append branch, bulk store_memories with a concurrency cap,
# positional connector helpers (connect_provider, disconnect), and auth
# normalization.
# Carries only the methods that need real dispatch logic: positional
# connector helpers and auth normalization.
#
# For everything else, use the resource methods directly. Resource methods
# now return unwrapped values natively (the generator peels the
Expand All @@ -16,7 +14,6 @@

from __future__ import annotations

import asyncio
import json
from collections.abc import Mapping, Sequence
from typing import Any, Optional, cast
Expand Down Expand Up @@ -53,52 +50,6 @@ def __init__(self, options: Optional[ClientOptions] = None, **compat: Any) -> No

# ---- memories ----

async def store_memory(
self,
memory: Optional[Mapping[str, Any]] = None,
**params: Any,
) -> Any:
"""
Polymorphic memory creator: dispatches to memories.create or memories.append
based on whether `memory_id` is set on the input. Returns the new memory id
(string), or the updated snapshot envelope when `snapshot` is set.
"""
body = _memory_params(memory, params)
memory_id = _memory_id(body)

# `memories.create` now returns the unwrapped inner type directly
# (the generator peels the wire `{results: X}` envelope).
if body.get("snapshot") is not None:
result = await self.memories.create(body=cast(Any, _snapshot_create_params(body)))
return _snapshot_result(result)

if memory_id:
await self.memories.append(
id=str(memory_id),
body=cast(Any, _memory_append_params(body)),
)
return str(memory_id)

result = await self.memories.create(body=cast(Any, _memory_create_params(body)))
return _extract_id(result)

async def store_memories(
self,
memories: Sequence[Mapping[str, Any]],
*,
max_concurrency: int = 8,
**options: Any,
) -> list[Any]:
semaphore = asyncio.Semaphore(max_concurrency)

async def worker(memory: Mapping[str, Any]) -> Any:
async with semaphore:
return await self.store_memory(memory, **options)

return await asyncio.gather(*(worker(memory) for memory in memories))

# ---- memories ----

async def list_memories(
self,
collection_ids: Optional[str | Sequence[str]] = None,
Expand All @@ -112,7 +63,7 @@ async def list_memories(
params["collection_ids"] = _listify(collection_ids)
if isinstance(params.get("metadata_filters"), Mapping):
params["metadata_filters"] = json.dumps(params["metadata_filters"])
return await self.memories.list(**params)
return await self.memory.list(**params)

# ---- connectors ----

Expand All @@ -121,12 +72,21 @@ async def connect_provider(
provider: str,
collection_id: str,
config: Optional[Mapping[str, Any]] = None,
oauth_client_mode: Optional[str] = None,
oauth_client_id: Optional[str] = None,
oauth_client_secret: Optional[str] = None,
) -> Any:
"""Custom signature: positional provider + collection_id + optional
config dict, packed into the wire body shape."""
config/OAuth fields, packed into the wire body shape."""
body: dict[str, Any] = {"collection_id": collection_id}
if config is not None:
body["config"] = dict(config)
if oauth_client_mode is not None:
body["oauth_client_mode"] = oauth_client_mode
if oauth_client_id is not None:
body["oauth_client_id"] = oauth_client_id
if oauth_client_secret is not None:
body["oauth_client_secret"] = oauth_client_secret
return await self.connectors.connect(provider=provider, body=cast(Any, body))

async def disconnect(
Expand All @@ -149,91 +109,5 @@ def _first_defined(*values: Optional[Any]) -> Optional[Any]:
return None


def _memory_params(
memory: Optional[Mapping[str, Any]], params: Mapping[str, Any]
) -> dict[str, Any]:
body: dict[str, Any] = dict(memory or {})
body.update(params)
if "collectionId" in body and "collection_id" not in body:
body["collection_id"] = body.pop("collectionId")
else:
body.pop("collectionId", None)
if "memoryId" in body and "memory_id" not in body:
body["memory_id"] = body.pop("memoryId")
else:
body.pop("memoryId", None)
content = body.pop("content", None)
if content is not None:
if isinstance(content, str):
body.setdefault("raw_text", content)
else:
body.setdefault("content_parts", content)
if body.get("messages") and not body.get("kind"):
body["kind"] = "conversation"
return body


def _memory_id(memory: Mapping[str, Any]) -> Any:
return memory.get("memory_id") or memory.get("memoryId")


def _memory_create_params(body: Mapping[str, Any]) -> dict[str, Any]:
params = dict(body)
params.pop("memory_id", None)
return params


def _snapshot_create_params(body: Mapping[str, Any]) -> dict[str, Any]:
params: dict[str, Any] = {"snapshot": body["snapshot"]}
if isinstance(body.get("raw_text"), str):
params["raw_text"] = body["raw_text"]
if isinstance(body.get("contents"), list):
params["contents"] = body["contents"]
return params


def _memory_append_params(body: Mapping[str, Any]) -> dict[str, Any]:
collection_id = body.get("collection_id")
if not collection_id:
raise ValueError(
"collection_id is required when appending to an existing memory"
)
params: dict[str, Any] = {"collection_id": collection_id}
for key in (
"metadata",
"ingestion_config",
"ingestion_mode",
"raw_text",
"chunks",
"messages",
):
if body.get(key) is not None:
params[key] = body[key]
return params


def _extract_value(obj: Any, name: str, default: Any = None) -> Any:
if isinstance(obj, Mapping):
return obj.get(name, default)
return getattr(obj, name, default)


def _snapshot_result(value: Any) -> Any:
return _extract_value(value, "snapshot", value)


def _extract_id(value: Any) -> str:
mapping = value if isinstance(value, Mapping) else None
for key in ("id", "memory_id", "engram_id", "ephemeral_collection_id"):
attr = getattr(value, key, None)
if isinstance(attr, str):
return attr
if mapping is not None:
mapped = mapping.get(key)
if isinstance(mapped, str):
return mapped
raise RuntimeError("Nebula memory create response did not include an id")


def _listify(value: str | Sequence[str]) -> list[str]:
return [value] if isinstance(value, str) else list(value)
Loading
Loading