From 0c2e05a4b57e8ddf78d9751bc20a4fb369d4765c Mon Sep 17 00:00:00 2001 From: Shining <250120269+chronoai-shining@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:30:42 +0800 Subject: [PATCH 1/3] fix(sdk-py): key SkillSummary on guid, not the nonexistent id (#1012) --- sdk/python/src/ornn_sdk/__init__.py | 4 +- sdk/python/src/ornn_sdk/client.py | 2 +- sdk/python/src/ornn_sdk/types.py | 10 ++- .../tests/fixtures/skill_detail_real.json | 24 ++++++ sdk/python/tests/test_client.py | 84 ++++++++++++++++--- 5 files changed, 108 insertions(+), 16 deletions(-) create mode 100644 sdk/python/tests/fixtures/skill_detail_real.json diff --git a/sdk/python/src/ornn_sdk/__init__.py b/sdk/python/src/ornn_sdk/__init__.py index e1c99e49..f44da1d5 100644 --- a/sdk/python/src/ornn_sdk/__init__.py +++ b/sdk/python/src/ornn_sdk/__init__.py @@ -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: ...`` diff --git a/sdk/python/src/ornn_sdk/client.py b/sdk/python/src/ornn_sdk/client.py index 253aed80..a9199b4b 100644 --- a/sdk/python/src/ornn_sdk/client.py +++ b/sdk/python/src/ornn_sdk/client.py @@ -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``:: diff --git a/sdk/python/src/ornn_sdk/types.py b/sdk/python/src/ornn_sdk/types.py index be49bb55..0736495a 100644 --- a/sdk/python/src/ornn_sdk/types.py +++ b/sdk/python/src/ornn_sdk/types.py @@ -23,7 +23,7 @@ @dataclass class SkillSummary: - id: str + guid: str name: str description: str is_private: bool @@ -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", @@ -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)), @@ -92,6 +97,7 @@ def from_dict(cls, raw: dict[str, Any]) -> SkillDetail: "skillHash", } summary_known = { + "guid", "id", "name", "description", diff --git a/sdk/python/tests/fixtures/skill_detail_real.json b/sdk/python/tests/fixtures/skill_detail_real.json new file mode 100644 index 00000000..65652741 --- /dev/null +++ b/sdk/python/tests/fixtures/skill_detail_real.json @@ -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 +} diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 0c2c1590..6f9b8b19 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -2,6 +2,10 @@ from __future__ import annotations +import json +from pathlib import Path +from typing import Any + import pytest import respx @@ -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) @@ -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, @@ -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 @@ -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, @@ -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( @@ -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, @@ -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, }, ) @@ -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, @@ -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 @@ -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, @@ -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, @@ -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, From a9d80f0e87663ebb4647c803c2283c7c3630c433 Mon Sep 17 00:00:00 2001 From: Shining <250120269+chronoai-shining@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:30:44 +0800 Subject: [PATCH 2/3] docs: empty changeset for Python SDK guid-keying fix (#1012) --- .changeset/sdk-py-guid-keying-1012.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changeset/sdk-py-guid-keying-1012.md diff --git a/.changeset/sdk-py-guid-keying-1012.md b/.changeset/sdk-py-guid-keying-1012.md new file mode 100644 index 00000000..bd8b3738 --- /dev/null +++ b/.changeset/sdk-py-guid-keying-1012.md @@ -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) From 31329c48de25017ebfc262da5115d693d8ed9c0d Mon Sep 17 00:00:00 2001 From: Shining <250120269+chronoai-shining@users.noreply.github.com> Date: Mon, 15 Jun 2026 15:33:03 +0800 Subject: [PATCH 3/3] docs(sdk-py): fix README quickstart to use guid not id (#1012) --- sdk/python/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/python/README.md b/sdk/python/README.md index 8130023d..39dadd26 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -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.