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)
self.marketplace: MarketplaceResource = MarketplaceResource(self._core)
self.memory: MemoryResource = MemoryResource(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 or document the removed memory APIs

This replaces the existing client.memories resource with client.memory, while the same regeneration also deletes the handwritten store_memory and store_memories methods. There are no forwarding aliases and the migration guide does not mention this break. Existing code such as the previously documented client.store_memory(...) or client.memories.search(...) now raises AttributeError immediately after upgrading. A focused runtime probe at this head confirmed that all three attributes are absent. Please retain deprecated compatibility aliases/wrappers for the patch release, or explicitly treat and document this as a breaking release with a migration path.

Evidence

  • At the merge base, _client.py constructs self.memories and _dx.py implements store_memory/store_memories; the PR removes them.
  • Focused probe output: has memories: False, has store_memory: False, has store_memories: False, followed by AttributeError 'Nebula' object has no attribute 'store_memory'.

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