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
4 changes: 4 additions & 0 deletions .changeset/sdk-py-guid-keying-1012.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Python SDK only: `SkillSummary`/`SkillDetail` now key the skill identifier on the real wire field `guid` (with a legacy `id` fallback) instead of a nonexistent `id` field, which made `search()`/`get()`/`publish()`/`update()` raise `KeyError: 'id'` on every real API response. No npm package version bump — the Python SDK has a separate release cadence. (#1012)
4 changes: 2 additions & 2 deletions sdk/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ with OrnnClient(
result = ornn.search(q="pdf", scope="public")

# Read
skill = ornn.get(result.items[0].id)
skill = ornn.get(result.items[0].guid)

# Pull
pkg = ornn.download_package(skill.id, skill.latest_version)
pkg = ornn.download_package(skill.guid, skill.latest_version)
# pkg is raw bytes — write to disk, pass to zipfile, etc. Resolves the
# version's presigned package URL from the skill detail and fetches it
# directly; verifies the bytes against `skill_hash` (SRI) when present.
Expand Down
4 changes: 2 additions & 2 deletions sdk/python/src/ornn_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
)

result = ornn.search(q="pdf", scope="public")
detail = ornn.get(result.items[0].id)
pkg = ornn.download_package(detail.id, detail.latest_version)
detail = ornn.get(result.items[0].guid)
pkg = ornn.download_package(detail.guid, detail.latest_version)

ornn.close() # or use ``with OrnnClient(...) as ornn: ...``

Expand Down
2 changes: 1 addition & 1 deletion sdk/python/src/ornn_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class OrnnClient:
... )
>>> result = ornn.search(q="pdf", scope="public")
>>> for skill in result.items:
... print(skill.id, skill.name)
... print(skill.guid, skill.name)

For dynamic token refresh, pass a ``token_resolver`` callable instead
of a static ``token``::
Expand Down
10 changes: 8 additions & 2 deletions sdk/python/src/ornn_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

@dataclass
class SkillSummary:
id: str
guid: str
name: str
description: str
is_private: bool
Expand All @@ -37,7 +37,12 @@ class SkillSummary:

@classmethod
def from_dict(cls, raw: dict[str, Any]) -> SkillSummary:
# The wire identifier is `guid` — both search `enrichItem` and the
# detail `buildDetailResponse` emit it (never `id`). The legacy `id`
# fallback keeps old/hand-rolled payloads from KeyError-ing, and
# `id` stays in `known` so a stray legacy key never leaks into `_extra`.
known = {
"guid",
"id",
"name",
"description",
Expand All @@ -51,7 +56,7 @@ def from_dict(cls, raw: dict[str, Any]) -> SkillSummary:
}
extra = {k: v for k, v in raw.items() if k not in known}
return cls(
id=raw["id"],
guid=raw.get("guid") or raw.get("id", ""),
name=raw["name"],
description=raw.get("description", ""),
is_private=bool(raw.get("isPrivate", False)),
Expand Down Expand Up @@ -92,6 +97,7 @@ def from_dict(cls, raw: dict[str, Any]) -> SkillDetail:
"skillHash",
}
summary_known = {
"guid",
"id",
"name",
"description",
Expand Down
24 changes: 24 additions & 0 deletions sdk/python/tests/fixtures/skill_detail_real.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"guid": "01HZ8K9QABCDEF0123456789AB",
"name": "pdf-extract",
"description": "Extract text and tables from PDF documents",
"license": "MIT",
"compatibility": {},
"metadata": {
"tags": ["pdf", "extraction"]
},
"tags": ["pdf", "extraction"],
"skillHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"presignedPackageUrl": "https://obj.example.com/bucket/01HZ8K9Q-1.0.zip?sig=abc123",
"isPrivate": false,
"createdBy": "user-42",
"createdByEmail": "owner@example.com",
"createdByDisplayName": "Skill Owner",
"createdOn": "2026-01-01T00:00:00.000Z",
"updatedOn": "2026-01-02T00:00:00.000Z",
"sharedWithUsers": [],
"sharedWithOrgs": [],
"version": "1.0",
"isDeprecated": false,
"deprecationNote": null
}
84 changes: 73 additions & 11 deletions sdk/python/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

import pytest
import respx

Expand All @@ -15,6 +19,13 @@

BASE = "https://ornn.example.com"

FIXTURES_DIR = Path(__file__).parent / "fixtures"


def load_fixture(name: str) -> dict[str, Any]:
"""Load a JSON fixture captured from a real API response shape."""
return json.loads((FIXTURES_DIR / name).read_text())


def make_client(**kwargs) -> OrnnClient:
return OrnnClient(base_url=BASE, **kwargs)
Expand Down Expand Up @@ -162,7 +173,7 @@ def test_parses_items_as_skill_summaries(self) -> None:
"data": {
"items": [
{
"id": "abc",
"guid": "abc",
"name": "pdf-extract",
"description": "Extract pdf text",
"isPrivate": False,
Expand All @@ -184,10 +195,42 @@ def test_parses_items_as_skill_summaries(self) -> None:
result = ornn.search()
assert result.total == 1
assert result.mode == "keyword"
assert result.items[0].id == "abc"
assert result.items[0].guid == "abc"
assert result.items[0].latest_version == "1.2"
assert result.items[0].is_private is False

@respx.mock
def test_legacy_id_only_payload_populates_guid(self) -> None:
# Defensive: a hand-rolled / pre-#1012 payload that carries only the
# old `id` key (no `guid`) must still parse — the fallback maps `id`
# onto `guid` rather than KeyError-ing, and `id` is not leaked to _extra.
respx.get(f"{BASE}/api/v1/skill-search").respond(
200,
json={
"data": {
"items": [
{
"id": "legacy-id",
"name": "legacy-skill",
"description": "",
"isPrivate": False,
"createdBy": "u1",
"createdOn": "2026-01-01T00:00:00Z",
}
],
"total": 1,
"page": 1,
"pageSize": 20,
"totalPages": 1,
},
"error": None,
},
)
with make_client() as ornn:
result = ornn.search()
assert result.items[0].guid == "legacy-id"
assert "id" not in result.items[0]._extra


class TestGet:
@respx.mock
Expand All @@ -196,7 +239,7 @@ def test_url_encodes_path_segment(self) -> None:
200,
json={
"data": {
"id": "x",
"guid": "x",
"name": "my/weird name",
"description": "",
"isPrivate": False,
Expand All @@ -212,6 +255,25 @@ def test_url_encodes_path_segment(self) -> None:
assert detail.created_by == "u1"
assert route.called

@respx.mock
def test_parses_real_api_detail_shape(self) -> None:
# Fixture mirrors the genuine wire shape emitted by the API's
# `buildDetailResponse` — `guid`-keyed, NO `id` key. This is the
# exact payload that used to KeyError before #1012.
raw = load_fixture("skill_detail_real.json")
assert "id" not in raw # guard: the real shape never carries `id`
respx.get(f"{BASE}/api/v1/skills/pdf-extract").respond(
200, json={"data": raw, "error": None}
)
with make_client() as ornn:
detail = ornn.get("pdf-extract")
assert isinstance(detail, SkillDetail)
assert detail.guid == raw["guid"]
assert detail.name == "pdf-extract"
assert detail.presigned_package_url == raw["presignedPackageUrl"]
assert detail.skill_hash == raw["skillHash"]
assert detail.shared_with_users == []

@respx.mock
def test_raises_ornn_error_on_404(self) -> None:
respx.get(f"{BASE}/api/v1/skills/nope").respond(
Expand Down Expand Up @@ -255,7 +317,7 @@ def test_list_versions_unwraps_items(self) -> None:

def _detail_data(**overrides: object) -> dict[str, object]:
base: dict[str, object] = {
"id": "abc",
"guid": "abc",
"name": "abc",
"description": "",
"isPrivate": False,
Expand Down Expand Up @@ -477,14 +539,14 @@ def test_pull_closure_downloads_in_topo_order(self) -> None:
detail_c = respx.get(f"{BASE}/api/v1/skills/g-c").respond(
200,
json={
"data": _detail_data(id="g-c", name="g-c", presignedPackageUrl=url_c),
"data": _detail_data(guid="g-c", name="g-c", presignedPackageUrl=url_c),
"error": None,
},
)
detail_b = respx.get(f"{BASE}/api/v1/skills/g-b").respond(
200,
json={
"data": _detail_data(id="g-b", name="g-b", presignedPackageUrl=url_b),
"data": _detail_data(guid="g-b", name="g-b", presignedPackageUrl=url_b),
"error": None,
},
)
Expand All @@ -511,7 +573,7 @@ def test_publish_sends_zip_bytes(self) -> None:
200,
json={
"data": {
"id": "new_abc",
"guid": "new_abc",
"name": "my-skill",
"description": "",
"isPrivate": True,
Expand All @@ -524,7 +586,7 @@ def test_publish_sends_zip_bytes(self) -> None:
zip_bytes = b"PK\x03\x04fakezip"
with make_client() as ornn:
detail = ornn.publish(zip_bytes)
assert detail.id == "new_abc"
assert detail.guid == "new_abc"
req = route.calls.last.request
assert req.headers["content-type"] == "application/zip"
assert req.content == zip_bytes
Expand All @@ -535,7 +597,7 @@ def test_publish_adds_skip_validation_query(self) -> None:
200,
json={
"data": {
"id": "admin_x",
"guid": "admin_x",
"name": "admin-skill",
"description": "",
"isPrivate": False,
Expand All @@ -559,7 +621,7 @@ def test_update_metadata_sends_json(self) -> None:
200,
json={
"data": {
"id": "abc",
"guid": "abc",
"name": "abc",
"description": "updated",
"isPrivate": False,
Expand All @@ -581,7 +643,7 @@ def test_update_with_zip_sends_zip(self) -> None:
200,
json={
"data": {
"id": "abc",
"guid": "abc",
"name": "abc",
"description": "",
"isPrivate": False,
Expand Down