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.
- podium_sdk-0.1.0/PKG-INFO +8 -0
- podium_sdk-0.1.0/README.md +29 -0
- podium_sdk-0.1.0/podium/__init__.py +13 -0
- podium_sdk-0.1.0/podium/_version.py +24 -0
- podium_sdk-0.1.0/podium/client.py +287 -0
- podium_sdk-0.1.0/podium_sdk.egg-info/PKG-INFO +8 -0
- podium_sdk-0.1.0/podium_sdk.egg-info/SOURCES.txt +10 -0
- podium_sdk-0.1.0/podium_sdk.egg-info/dependency_links.txt +1 -0
- podium_sdk-0.1.0/podium_sdk.egg-info/top_level.txt +1 -0
- podium_sdk-0.1.0/pyproject.toml +42 -0
- podium_sdk-0.1.0/setup.cfg +4 -0
- podium_sdk-0.1.0/tests/test_client.py +186 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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,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 == ""
|