podium-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: podium-sdk
3
+ Version: 0.1.0
4
+ Summary: Thin HTTP client for the Podium registry.
5
+ License: MIT
6
+ Classifier: License :: OSI Approved :: MIT License
7
+ Classifier: Programming Language :: Python :: 3
8
+ Requires-Python: >=3.10
@@ -0,0 +1,29 @@
1
+ # podium-py
2
+
3
+ Thin HTTP client for the Podium registry.
4
+
5
+ Distributed on PyPI as `podium-sdk`; the import name is `podium`:
6
+
7
+ ```sh
8
+ pip install podium-sdk
9
+ ```
10
+
11
+ ```python
12
+ from podium import Client
13
+
14
+ client = Client.from_env()
15
+ results = client.search_artifacts("variance", type="skill")
16
+ artifact = client.load_artifact(results.results[0].id)
17
+ print(artifact.manifest_body)
18
+ ```
19
+
20
+ The client covers the meta-tool surface. OAuth device code, streaming
21
+ subscriptions, and dependency walks remain on the roadmap.
22
+
23
+ ## Test
24
+
25
+ ```sh
26
+ cd sdks/podium-py
27
+ pip install -e .
28
+ pytest
29
+ ```
@@ -0,0 +1,13 @@
1
+ """Podium Python SDK — thin HTTP client over the registry API."""
2
+
3
+ from .client import Client, DeviceCodeRequired, RegistryError
4
+
5
+ try:
6
+ # Generated by setuptools-scm at build/install time. See pyproject.toml
7
+ # → [tool.setuptools_scm]. Gitignored in source; regenerated by any
8
+ # `pip install`.
9
+ from ._version import __version__
10
+ except ImportError:
11
+ __version__ = "0.0.0+unknown"
12
+
13
+ __all__ = ["Client", "DeviceCodeRequired", "RegistryError"]
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = 'g9ba62dc66'
@@ -0,0 +1,287 @@
1
+ """Podium HTTP client (spec §7.6 surface)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.parse
8
+ import urllib.request
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+
13
+ class RegistryError(Exception):
14
+ """Raised when the registry returns a structured error envelope (§6.10)."""
15
+
16
+ def __init__(self, code: str, message: str, retryable: bool = False) -> None:
17
+ self.code = code
18
+ self.message = message
19
+ self.retryable = retryable
20
+ super().__init__(f"{code}: {message}")
21
+
22
+
23
+ class DeviceCodeRequired(Exception):
24
+ """Raised when the configured identity provider needs a device-code flow.
25
+
26
+ Stage 3 does not implement OAuth; this exception is wired so callers
27
+ can catch it once oauth-device-code lands in Phase 11.
28
+ """
29
+
30
+
31
+ @dataclass
32
+ class ArtifactDescriptor:
33
+ """A single result returned by search_artifacts and load_domain."""
34
+
35
+ id: str
36
+ type: str
37
+ version: str = ""
38
+ description: str = ""
39
+ tags: list[str] = field(default_factory=list)
40
+ score: float = 0.0
41
+
42
+
43
+ @dataclass
44
+ class SearchResult:
45
+ """Envelope returned by search_artifacts and search_domains."""
46
+
47
+ query: str = ""
48
+ total_matched: int = 0
49
+ results: list[ArtifactDescriptor] = field(default_factory=list)
50
+ domains: list[dict[str, Any]] = field(default_factory=list)
51
+
52
+
53
+ @dataclass
54
+ class LoadedArtifact:
55
+ """Manifest body and bundled resources returned by load_artifact."""
56
+
57
+ id: str
58
+ type: str
59
+ version: str
60
+ manifest_body: str
61
+ frontmatter: str
62
+ resources: dict[str, str] = field(default_factory=dict)
63
+
64
+
65
+ class Client:
66
+ """Thin HTTP client over the registry's meta-tool API.
67
+
68
+ Construct with an explicit registry URL or call `Client.from_env()` to
69
+ pick up `PODIUM_REGISTRY`, `PODIUM_IDENTITY_PROVIDER`, and
70
+ `PODIUM_OVERLAY_PATH` per §6.2.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ registry: str,
76
+ *,
77
+ identity_provider: str = "oauth-device-code",
78
+ overlay_path: str | None = None,
79
+ ) -> None:
80
+ self.registry = registry.rstrip("/")
81
+ self.identity_provider = identity_provider
82
+ self.overlay_path = overlay_path
83
+
84
+ @classmethod
85
+ def from_env(cls) -> "Client":
86
+ registry = os.environ.get("PODIUM_REGISTRY")
87
+ if not registry:
88
+ raise RuntimeError("PODIUM_REGISTRY environment variable is required")
89
+ return cls(
90
+ registry=registry,
91
+ identity_provider=os.environ.get("PODIUM_IDENTITY_PROVIDER", "oauth-device-code"),
92
+ overlay_path=os.environ.get("PODIUM_OVERLAY_PATH"),
93
+ )
94
+
95
+ def load_domain(self, path: str = "", depth: int = 1) -> dict[str, Any]:
96
+ params = {}
97
+ if path:
98
+ params["path"] = path
99
+ if depth:
100
+ params["depth"] = depth
101
+ return self._get("/v1/load_domain", params)
102
+
103
+ def search_domains(
104
+ self, query: str = "", *, scope: str = "", top_k: int = 10
105
+ ) -> SearchResult:
106
+ params = {"top_k": top_k}
107
+ if query:
108
+ params["query"] = query
109
+ if scope:
110
+ params["scope"] = scope
111
+ body = self._get("/v1/search_domains", params)
112
+ return SearchResult(
113
+ query=body.get("query", ""),
114
+ total_matched=body.get("total_matched", 0),
115
+ domains=body.get("domains", []) or [],
116
+ )
117
+
118
+ def search_artifacts(
119
+ self,
120
+ query: str = "",
121
+ *,
122
+ type: str = "",
123
+ scope: str = "",
124
+ tags: list[str] | None = None,
125
+ top_k: int = 10,
126
+ ) -> SearchResult:
127
+ params: dict[str, Any] = {"top_k": top_k}
128
+ if query:
129
+ params["query"] = query
130
+ if type:
131
+ params["type"] = type
132
+ if scope:
133
+ params["scope"] = scope
134
+ if tags:
135
+ params["tags"] = ",".join(tags)
136
+ body = self._get("/v1/search_artifacts", params)
137
+ results = [
138
+ ArtifactDescriptor(
139
+ id=r.get("id", ""),
140
+ type=r.get("type", ""),
141
+ version=r.get("version", ""),
142
+ description=r.get("description", ""),
143
+ tags=r.get("tags") or [],
144
+ score=r.get("score", 0.0),
145
+ )
146
+ for r in body.get("results", []) or []
147
+ ]
148
+ return SearchResult(
149
+ query=body.get("query", ""),
150
+ total_matched=body.get("total_matched", 0),
151
+ results=results,
152
+ )
153
+
154
+ def load_artifact(self, artifact_id: str, *, version: str = "") -> LoadedArtifact:
155
+ params = {"id": artifact_id}
156
+ if version:
157
+ params["version"] = version
158
+ body = self._get("/v1/load_artifact", params)
159
+ return LoadedArtifact(
160
+ id=body.get("id", artifact_id),
161
+ type=body.get("type", ""),
162
+ version=body.get("version", ""),
163
+ manifest_body=body.get("manifest_body", ""),
164
+ frontmatter=body.get("frontmatter", ""),
165
+ resources=body.get("resources", {}) or {},
166
+ )
167
+
168
+ def load_artifacts(
169
+ self,
170
+ ids: list[str],
171
+ *,
172
+ session_id: str = "",
173
+ harness: str = "",
174
+ version_pins: dict[str, str] | None = None,
175
+ ) -> list[dict[str, Any]]:
176
+ """Bulk-fetch artifacts via §7.6.2 POST /v1/artifacts:batchLoad.
177
+
178
+ The §7.6.2 hard cap is 50 IDs per request; the SDK splits
179
+ larger sets transparently. Each returned envelope carries
180
+ ``status="ok"`` with the manifest body, or ``status="error"``
181
+ with a §6.10 envelope. Partial failure does not raise.
182
+ """
183
+ if not ids:
184
+ return []
185
+ out: list[dict[str, Any]] = []
186
+ chunk_size = 50
187
+ for chunk_start in range(0, len(ids), chunk_size):
188
+ chunk = ids[chunk_start : chunk_start + chunk_size]
189
+ body: dict[str, Any] = {"ids": chunk}
190
+ if session_id:
191
+ body["session_id"] = session_id
192
+ if harness:
193
+ body["harness"] = harness
194
+ if version_pins:
195
+ body["version_pins"] = {k: v for k, v in version_pins.items() if k in chunk}
196
+ data = json.dumps(body).encode()
197
+ req = urllib.request.Request(
198
+ self.registry + "/v1/artifacts:batchLoad",
199
+ data=data,
200
+ headers={"Content-Type": "application/json"},
201
+ method="POST",
202
+ )
203
+ try:
204
+ with urllib.request.urlopen(req) as resp:
205
+ raw = resp.read()
206
+ except urllib.error.HTTPError as exc:
207
+ self._raise_from_http_error(exc)
208
+ out.extend(json.loads(raw))
209
+ return out
210
+
211
+ def dependents_of(self, artifact_id: str) -> list[ArtifactDescriptor]:
212
+ """Return artifacts that depend on artifact_id (spec §4.7.6)."""
213
+ body = self._get("/v1/dependents", {"id": artifact_id})
214
+ return [
215
+ ArtifactDescriptor(
216
+ id=r.get("id", ""),
217
+ type=r.get("type", ""),
218
+ version=r.get("version", ""),
219
+ description=r.get("description", ""),
220
+ tags=r.get("tags") or [],
221
+ )
222
+ for r in body.get("dependents", []) or []
223
+ ]
224
+
225
+ def preview_scope(
226
+ self,
227
+ *,
228
+ scope: str = "",
229
+ type: str = "",
230
+ tags: list[str] | None = None,
231
+ ) -> dict[str, Any]:
232
+ """Preview a scope's effective artifact set (spec §6.4)."""
233
+ params: dict[str, Any] = {}
234
+ if scope:
235
+ params["scope"] = scope
236
+ if type:
237
+ params["type"] = type
238
+ if tags:
239
+ params["tags"] = ",".join(tags)
240
+ return self._get("/v1/scope/preview", params)
241
+
242
+ def subscribe(self, *, types: list[str] | None = None):
243
+ """Yield NDJSON events from /v1/events (spec §7.6).
244
+
245
+ Each yielded value is the parsed JSON body of one event. The
246
+ iterator runs until the underlying connection closes; callers
247
+ wrap it in a try/except to handle reconnects.
248
+ """
249
+ params: dict[str, Any] = {}
250
+ if types:
251
+ params["types"] = ",".join(types)
252
+ url = self.registry + "/v1/events"
253
+ if params:
254
+ url = url + "?" + urllib.parse.urlencode(params)
255
+ req = urllib.request.Request(url)
256
+ with urllib.request.urlopen(req) as resp:
257
+ for raw in resp:
258
+ line = raw.decode("utf-8").rstrip("\n")
259
+ if not line:
260
+ continue
261
+ try:
262
+ yield json.loads(line)
263
+ except json.JSONDecodeError:
264
+ continue
265
+
266
+ def _get(self, path: str, params: dict[str, Any]) -> dict[str, Any]:
267
+ url = self.registry + path
268
+ if params:
269
+ url = url + "?" + urllib.parse.urlencode(params)
270
+ req = urllib.request.Request(url)
271
+ try:
272
+ with urllib.request.urlopen(req) as resp:
273
+ body = resp.read()
274
+ except urllib.error.HTTPError as exc:
275
+ self._raise_from_http_error(exc)
276
+ return json.loads(body)
277
+
278
+ def _raise_from_http_error(self, exc: urllib.error.HTTPError) -> None:
279
+ try:
280
+ envelope = json.loads(exc.read())
281
+ except Exception:
282
+ raise RegistryError("registry.unknown", f"HTTP {exc.code}: {exc.reason}") from exc
283
+ raise RegistryError(
284
+ code=envelope.get("code", "registry.unknown"),
285
+ message=envelope.get("message", str(exc)),
286
+ retryable=envelope.get("retryable", False),
287
+ )
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: podium-sdk
3
+ Version: 0.1.0
4
+ Summary: Thin HTTP client for the Podium registry.
5
+ License: MIT
6
+ Classifier: License :: OSI Approved :: MIT License
7
+ Classifier: Programming Language :: Python :: 3
8
+ Requires-Python: >=3.10
@@ -0,0 +1,10 @@
1
+ README.md
2
+ pyproject.toml
3
+ podium/__init__.py
4
+ podium/_version.py
5
+ podium/client.py
6
+ podium_sdk.egg-info/PKG-INFO
7
+ podium_sdk.egg-info/SOURCES.txt
8
+ podium_sdk.egg-info/dependency_links.txt
9
+ podium_sdk.egg-info/top_level.txt
10
+ tests/test_client.py
@@ -0,0 +1 @@
1
+ podium
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "setuptools-scm>=8"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ # Distribution name on PyPI. The plain "podium" name was taken; the
7
+ # Python import name stays `podium` (PEP 8 prefers single-word
8
+ # package names) so `from podium import Client` still works after
9
+ # `pip install podium-sdk`. Same pattern as Pillow / BeautifulSoup4.
10
+ name = "podium-sdk"
11
+ dynamic = ["version"]
12
+ description = "Thin HTTP client for the Podium registry."
13
+ requires-python = ">=3.10"
14
+ license = { text = "MIT" }
15
+ classifiers = [
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ ]
19
+
20
+ [tool.setuptools.packages.find]
21
+ where = ["."]
22
+ include = ["podium*"]
23
+
24
+ # setuptools-scm derives the version from git tags at build time —
25
+ # `pip install -e .` from a clean checkout at tag v0.1.0 produces
26
+ # podium-sdk 0.1.0; an untagged commit produces 0.1.1.dev<N>+g<sha>.
27
+ # Mirrors the Go side's `internal/buildinfo` + ldflags pattern.
28
+ [tool.setuptools_scm]
29
+ # pyproject.toml sits in sdks/podium-py/; the git root is two levels up.
30
+ root = "../.."
31
+ # Generated at build time and imported by podium/__init__.py.
32
+ # Gitignored — never check it in.
33
+ version_file = "podium/_version.py"
34
+ # Local-version separator (PEP 440); keeps PyPI-uploaded versions
35
+ # clean while local dev builds carry the git sha.
36
+ local_scheme = "node-and-date"
37
+ # Fallback when building from a tarball that has no .git directory.
38
+ fallback_version = "0.0.0"
39
+
40
+ [tool.pytest.ini_options]
41
+ testpaths = ["tests"]
42
+ addopts = "-q"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,186 @@
1
+ """Tests for the Podium Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import http.server
6
+ import json
7
+ import socket
8
+ import threading
9
+
10
+ import pytest
11
+
12
+ from podium import Client, RegistryError
13
+
14
+
15
+ class _StubHandler(http.server.BaseHTTPRequestHandler):
16
+ """Records the last request and replies with whatever the test sets."""
17
+
18
+ def log_message(self, format, *args): # noqa: A002 - signature inherited
19
+ pass
20
+
21
+ def do_GET(self): # noqa: N802 - signature inherited
22
+ self.server.last_path = self.path # type: ignore[attr-defined]
23
+ body = json.dumps(self.server.next_response).encode() # type: ignore[attr-defined]
24
+ self.send_response(self.server.next_status) # type: ignore[attr-defined]
25
+ self.send_header("Content-Type", "application/json")
26
+ self.send_header("Content-Length", str(len(body)))
27
+ self.end_headers()
28
+ self.wfile.write(body)
29
+
30
+ def do_POST(self): # noqa: N802 - signature inherited
31
+ self.server.last_path = self.path # type: ignore[attr-defined]
32
+ length = int(self.headers.get("Content-Length", "0"))
33
+ self.server.last_body = self.rfile.read(length) # type: ignore[attr-defined]
34
+ body = json.dumps(self.server.next_response).encode() # type: ignore[attr-defined]
35
+ self.send_response(self.server.next_status) # type: ignore[attr-defined]
36
+ self.send_header("Content-Type", "application/json")
37
+ self.send_header("Content-Length", str(len(body)))
38
+ self.end_headers()
39
+ self.wfile.write(body)
40
+
41
+
42
+ @pytest.fixture()
43
+ def stub_server():
44
+ sock = socket.socket()
45
+ sock.bind(("127.0.0.1", 0))
46
+ port = sock.getsockname()[1]
47
+ sock.close()
48
+
49
+ server = http.server.HTTPServer(("127.0.0.1", port), _StubHandler)
50
+ server.next_status = 200
51
+ server.next_response = {}
52
+ server.last_path = ""
53
+
54
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
55
+ thread.start()
56
+ yield server
57
+ server.shutdown()
58
+ thread.join()
59
+
60
+
61
+ # Spec: §7.6 SDK surface — search_artifacts forwards to GET /v1/search_artifacts
62
+ # and decodes the SearchResult envelope.
63
+ def test_search_artifacts_forwards_query(stub_server):
64
+ stub_server.next_response = {
65
+ "query": "variance",
66
+ "total_matched": 1,
67
+ "results": [
68
+ {
69
+ "id": "finance/run-variance",
70
+ "type": "skill",
71
+ "version": "1.0.0",
72
+ "description": "Variance analysis",
73
+ },
74
+ ],
75
+ }
76
+ client = Client(registry=f"http://127.0.0.1:{stub_server.server_port}")
77
+ out = client.search_artifacts("variance", top_k=5)
78
+
79
+ assert "search_artifacts" in stub_server.last_path
80
+ assert "query=variance" in stub_server.last_path
81
+ assert out.total_matched == 1
82
+ assert out.results[0].id == "finance/run-variance"
83
+
84
+
85
+ # Spec: §7.6 SDK surface — load_artifact returns a LoadedArtifact with
86
+ # manifest body and bundled resources.
87
+ def test_load_artifact_returns_manifest_and_resources(stub_server):
88
+ stub_server.next_response = {
89
+ "id": "finance/run",
90
+ "type": "skill",
91
+ "version": "1.0.0",
92
+ "manifest_body": "Body.",
93
+ "frontmatter": "---\ntype: skill\n---\n",
94
+ "resources": {"scripts/run.py": "print('run')\n"},
95
+ }
96
+ client = Client(registry=f"http://127.0.0.1:{stub_server.server_port}")
97
+ art = client.load_artifact("finance/run")
98
+
99
+ assert art.id == "finance/run"
100
+ assert art.manifest_body == "Body."
101
+ assert art.resources == {"scripts/run.py": "print('run')\n"}
102
+
103
+
104
+ # Spec: §6.10 — error envelopes from the registry surface as RegistryError
105
+ # with the namespaced code preserved.
106
+ def test_registry_error_envelope_translates_to_exception(stub_server):
107
+ stub_server.next_status = 404
108
+ stub_server.next_response = {
109
+ "code": "registry.not_found",
110
+ "message": "artifact not found",
111
+ "retryable": False,
112
+ }
113
+ client = Client(registry=f"http://127.0.0.1:{stub_server.server_port}")
114
+ with pytest.raises(RegistryError) as exc:
115
+ client.load_artifact("does/not/exist")
116
+ assert exc.value.code == "registry.not_found"
117
+ assert "artifact not found" in exc.value.message
118
+
119
+
120
+ # Spec: §6.2 — Client.from_env reads PODIUM_REGISTRY and provider env vars.
121
+ def test_from_env_reads_registry(monkeypatch):
122
+ monkeypatch.setenv("PODIUM_REGISTRY", "http://127.0.0.1:9999")
123
+ monkeypatch.setenv("PODIUM_OVERLAY_PATH", "/tmp/overlay")
124
+ client = Client.from_env()
125
+ assert client.registry == "http://127.0.0.1:9999"
126
+ assert client.overlay_path == "/tmp/overlay"
127
+
128
+
129
+ # Spec: §4.7.6 — dependents_of returns artifacts that depend on the
130
+ # given id, surfaced as ArtifactDescriptor instances.
131
+ def test_dependents_of_decodes_envelope(stub_server):
132
+ stub_server.next_response = {
133
+ "dependents": [
134
+ {"id": "finance/run", "type": "skill", "version": "1.0.0",
135
+ "description": "Variance"},
136
+ ],
137
+ }
138
+ client = Client(registry=f"http://127.0.0.1:{stub_server.server_port}")
139
+ deps = client.dependents_of("finance/glossary")
140
+ assert "/v1/dependents" in stub_server.last_path
141
+ assert len(deps) == 1
142
+ assert deps[0].id == "finance/run"
143
+
144
+
145
+ # Spec: §6.4 — preview_scope hits /v1/scope/preview with the
146
+ # constraints; the SDK passes the response through unchanged so
147
+ # callers can inspect the full envelope.
148
+ def test_preview_scope_passes_constraints(stub_server):
149
+ stub_server.next_response = {
150
+ "scope": "finance/",
151
+ "matched": 12,
152
+ "results": [],
153
+ }
154
+ client = Client(registry=f"http://127.0.0.1:{stub_server.server_port}")
155
+ out = client.preview_scope(scope="finance/", type="skill", tags=["q4"])
156
+ assert "/v1/scope/preview" in stub_server.last_path
157
+ assert "scope=finance" in stub_server.last_path
158
+ assert "tags=q4" in stub_server.last_path
159
+ assert out["matched"] == 12
160
+
161
+
162
+ # Spec: §7.6.2 — load_artifacts POSTs to /v1/artifacts:batchLoad
163
+ # and returns per-item envelopes; partial failures do not raise.
164
+ def test_load_artifacts_returns_envelopes(stub_server):
165
+ stub_server.next_response = [
166
+ {"id": "a", "status": "ok", "version": "1.0.0", "content_hash": "sha256:a"},
167
+ {"id": "b", "status": "error", "error": {"code": "registry.not_found", "message": "missing"}},
168
+ ]
169
+ client = Client(registry=f"http://127.0.0.1:{stub_server.server_port}")
170
+ out = client.load_artifacts(["a", "b"])
171
+
172
+ assert "/v1/artifacts:batchLoad" in stub_server.last_path
173
+ body = json.loads(stub_server.last_body)
174
+ assert body["ids"] == ["a", "b"]
175
+ assert len(out) == 2
176
+ assert out[0]["status"] == "ok"
177
+ assert out[1]["status"] == "error"
178
+
179
+
180
+ # Spec: §7.6.2 — empty ids list short-circuits to an empty
181
+ # response without a network call.
182
+ def test_load_artifacts_empty_short_circuits(stub_server):
183
+ client = Client(registry=f"http://127.0.0.1:{stub_server.server_port}")
184
+ out = client.load_artifacts([])
185
+ assert out == []
186
+ assert stub_server.last_path == ""