helm-runtime-sdk 1.0.0rc1__py3-none-any.whl

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,35 @@
1
+ """helmstudio's runtime SDK for Python: a client for the platform API.
2
+
3
+ from helm_runtime_sdk import from_env
4
+ helm = from_env()
5
+ asset = helm.assets.adopt({"path": out, "kind": "video"})
6
+ helm.gallery.add({"kind": "video", "asset_id": asset["id"], "params": {"seed": 42}})
7
+
8
+ Standard library only. Method names match the Go and Node clients
9
+ (docs/design/04-packages.md §4). _generated.py is generated by api/gen; the rest
10
+ is written by hand. Sync only for now; the embedded provider for Python is
11
+ decided with the Python studios (docs/decisions.md M4 Q3, Q25).
12
+ """
13
+
14
+ import os
15
+
16
+ from ._generated import API_VERSION, Client
17
+ from ._transport import Event, HelmError, RawResponse, Transport
18
+
19
+ __all__ = ["API_VERSION", "Client", "Event", "HelmError", "RawResponse", "Transport", "from_env", "remote"]
20
+
21
+
22
+ def remote(base: str, token: str) -> Client:
23
+ """A client for the API at base, e.g. http://127.0.0.1:8700/api/v1."""
24
+ return Client(Transport(base, token))
25
+
26
+
27
+ def from_env() -> Client:
28
+ """The remote client when HELM_API is set, as helmstudio and helm dev do."""
29
+ api = os.environ.get("HELM_API")
30
+ if not api:
31
+ raise HelmError(0, "no_provider", "HELM_API is not set; run under helmstudio or helm dev")
32
+ token = os.environ.get("HELM_TOKEN")
33
+ if not token:
34
+ raise HelmError(0, "no_token", "HELM_API is set but HELM_TOKEN is not; a studio with no capabilities gets no token")
35
+ return remote(api, token)
@@ -0,0 +1,308 @@
1
+ # Code generated by api/gen from api/openapi.yaml. DO NOT EDIT.
2
+ """Generated groups of the helmstudio runtime SDK. Import from helm_runtime_sdk."""
3
+
4
+ from typing import Any, Dict, Optional, Sequence
5
+
6
+ from ._transport import Transport, quote
7
+
8
+ API_VERSION = "1.0.0"
9
+
10
+
11
+ class MeGroup:
12
+ """The me group."""
13
+
14
+ def __init__(self, transport: Transport) -> None:
15
+ self._t = transport
16
+
17
+ def get(self) -> Any:
18
+ """Who the token belongs to, what it may do, and how much quota is left. (GET /me)"""
19
+ return self._t.request("GET", "/me", query={}, headers={}, expect="json")
20
+
21
+ class EventsGroup:
22
+ """The events group."""
23
+
24
+ def __init__(self, transport: Transport) -> None:
25
+ self._t = transport
26
+
27
+ def subscribe(self, *, last_event_id: Optional[Any] = None) -> Any:
28
+ """Server-sent events for the calling studio. (GET /events)"""
29
+ return self._t.request("GET", "/events", query={}, headers={"Last-Event-ID": last_event_id}, expect="sse")
30
+
31
+ class KVGroup:
32
+ """The kv group."""
33
+
34
+ def __init__(self, transport: Transport) -> None:
35
+ self._t = transport
36
+
37
+ def list(self, ns: str, *, prefix: Optional[Any] = None, limit: Optional[Any] = None, cursor: Optional[Any] = None) -> Any:
38
+ """List keys in a namespace, with sizes and modified times. No values. (GET /kv/{ns})"""
39
+ return self._t.request("GET", "/kv/" + quote(ns), query={"prefix": prefix, "limit": limit, "cursor": cursor}, headers={}, expect="json")
40
+
41
+ def get(self, ns: str, key: str) -> Any:
42
+ """Read one document. (GET /kv/{ns}/{key})"""
43
+ return self._t.request("GET", "/kv/" + quote(ns) + "/" + quote(key), query={}, headers={}, expect="json")
44
+
45
+ def put(self, ns: str, key: str, body: Dict[str, Any], *, if_match: Optional[Any] = None) -> Any:
46
+ """Create or replace a document. (PUT /kv/{ns}/{key})"""
47
+ return self._t.request("PUT", "/kv/" + quote(ns) + "/" + quote(key), query={}, headers={"If-Match": if_match}, expect="json", json_body=body, content_type="application/json")
48
+
49
+ def patch(self, ns: str, key: str, body: Dict[str, Any], *, if_match: Optional[Any] = None) -> Any:
50
+ """Apply a JSON merge patch (RFC 7396) to an existing document. (PATCH /kv/{ns}/{key})"""
51
+ return self._t.request("PATCH", "/kv/" + quote(ns) + "/" + quote(key), query={}, headers={"If-Match": if_match}, expect="json", json_body=body, content_type="application/merge-patch+json")
52
+
53
+ def delete(self, ns: str, key: str, *, if_match: Optional[Any] = None) -> Any:
54
+ """Remove a document. (DELETE /kv/{ns}/{key})"""
55
+ return self._t.request("DELETE", "/kv/" + quote(ns) + "/" + quote(key), query={}, headers={"If-Match": if_match}, expect="empty")
56
+
57
+ class SessionsGroup:
58
+ """The sessions group."""
59
+
60
+ def __init__(self, transport: Transport) -> None:
61
+ self._t = transport
62
+
63
+ def list(self, *, limit: Optional[Any] = None, cursor: Optional[Any] = None) -> Any:
64
+ """The calling studio's sessions, most recently opened first. (GET /sessions)"""
65
+ return self._t.request("GET", "/sessions", query={"limit": limit, "cursor": cursor}, headers={}, expect="json")
66
+
67
+ def create(self, body: Dict[str, Any]) -> Any:
68
+ """Create a session. (POST /sessions)"""
69
+ return self._t.request("POST", "/sessions", query={}, headers={}, expect="json", json_body=body, content_type="application/json")
70
+
71
+ def get(self, id: str) -> Any:
72
+ """One session. A deleted session, or another studio's, is 404. (GET /sessions/{id})"""
73
+ return self._t.request("GET", "/sessions/" + quote(id), query={}, headers={}, expect="json")
74
+
75
+ def update(self, id: str, body: Dict[str, Any], *, if_match: Optional[Any] = None) -> Any:
76
+ """Rename, or merge changes into the state document. (PATCH /sessions/{id})"""
77
+ return self._t.request("PATCH", "/sessions/" + quote(id), query={}, headers={"If-Match": if_match}, expect="json", json_body=body, content_type="application/merge-patch+json")
78
+
79
+ def delete(self, id: str, *, if_match: Optional[Any] = None) -> Any:
80
+ """Soft-delete. Its name becomes free; items keep their session_id. (DELETE /sessions/{id})"""
81
+ return self._t.request("DELETE", "/sessions/" + quote(id), query={}, headers={"If-Match": if_match}, expect="empty")
82
+
83
+ def duplicate(self, id: str, body: Dict[str, Any]) -> Any:
84
+ """Copy a session's state under a new name. The copy has never been opened. (POST /sessions/{id}:duplicate)"""
85
+ return self._t.request("POST", "/sessions/" + quote(id) + ":duplicate", query={}, headers={}, expect="json", json_body=body, content_type="application/json")
86
+
87
+ def activate(self, id: str) -> Any:
88
+ """Set opened_at to now. Changes neither the state nor the etag, nor any process. (POST /sessions/{id}:activate)"""
89
+ return self._t.request("POST", "/sessions/" + quote(id) + ":activate", query={}, headers={}, expect="empty")
90
+
91
+ class RecordsGroup:
92
+ """The records group."""
93
+
94
+ def __init__(self, transport: Transport) -> None:
95
+ self._t = transport
96
+
97
+ def query(self, collection: str, *, where: Optional[Sequence[str]] = None, order: Optional[Any] = None, limit: Optional[Any] = None, cursor: Optional[Any] = None) -> Any:
98
+ """Query a collection with the closed filter language. Never SQL. (GET /records/{collection})"""
99
+ return self._t.request("GET", "/records/" + quote(collection), query={"where": where, "order": order, "limit": limit, "cursor": cursor}, headers={}, expect="json")
100
+
101
+ def insert(self, collection: str, body: Dict[str, Any]) -> Any:
102
+ """Insert a document; returns its id and etag. (POST /records/{collection})"""
103
+ return self._t.request("POST", "/records/" + quote(collection), query={}, headers={}, expect="json", json_body=body, content_type="application/json")
104
+
105
+ def get(self, collection: str, id: str) -> Any:
106
+ """One record. Deleted, in another collection, or another studio's is 404. (GET /records/{collection}/{id})"""
107
+ return self._t.request("GET", "/records/" + quote(collection) + "/" + quote(id), query={}, headers={}, expect="json")
108
+
109
+ def replace(self, collection: str, id: str, body: Dict[str, Any], *, if_match: Optional[Any] = None) -> Any:
110
+ """Replace a document. If-Match on the etag; 409 on conflict. (PUT /records/{collection}/{id})"""
111
+ return self._t.request("PUT", "/records/" + quote(collection) + "/" + quote(id), query={}, headers={"If-Match": if_match}, expect="json", json_body=body, content_type="application/json")
112
+
113
+ def patch(self, collection: str, id: str, body: Dict[str, Any], *, if_match: Optional[Any] = None) -> Any:
114
+ """Apply a JSON merge patch (RFC 7396). If-Match on the etag; 409 on conflict. (PATCH /records/{collection}/{id})"""
115
+ return self._t.request("PATCH", "/records/" + quote(collection) + "/" + quote(id), query={}, headers={"If-Match": if_match}, expect="json", json_body=body, content_type="application/merge-patch+json")
116
+
117
+ def delete(self, collection: str, id: str, *, if_match: Optional[Any] = None) -> Any:
118
+ """Soft-delete, so an undo is possible and a sync never sees a gap. (DELETE /records/{collection}/{id})"""
119
+ return self._t.request("DELETE", "/records/" + quote(collection) + "/" + quote(id), query={}, headers={"If-Match": if_match}, expect="empty")
120
+
121
+ class AssetsGroup:
122
+ """The assets group."""
123
+
124
+ def __init__(self, transport: Transport) -> None:
125
+ self._t = transport
126
+
127
+ def upload(self, body: bytes, content_type: str, *, kind: Any, filename: Optional[Any] = None, pinned: Optional[Any] = None, width: Optional[Any] = None, height: Optional[Any] = None, duration_s: Optional[Any] = None, fps: Optional[Any] = None) -> Any:
128
+ """Upload bytes. Idempotent on content. (POST /assets)"""
129
+ return self._t.request("POST", "/assets", query={"kind": kind, "filename": filename, "pinned": pinned, "width": width, "height": height, "duration_s": duration_s, "fps": fps}, headers={}, expect="json", raw_body=body, content_type=content_type)
130
+
131
+ def adopt(self, body: Dict[str, Any]) -> Any:
132
+ """Adopt a file from the stage or data directory by hardlink — no copy, no HTTP body. (POST /assets:adopt)"""
133
+ return self._t.request("POST", "/assets:adopt", query={}, headers={}, expect="json", json_body=body, content_type="application/json")
134
+
135
+ def read(self, id: str, *, range: Optional[Any] = None) -> Any:
136
+ """The asset's bytes, with Range. (GET /assets/{id})"""
137
+ return self._t.request("GET", "/assets/" + quote(id), query={}, headers={"Range": range}, expect="raw")
138
+
139
+ def thumb(self, id: str, *, w: Optional[Any] = None) -> Any:
140
+ """A thumbnail, generated once and cached. (GET /assets/{id}/thumb)"""
141
+ return self._t.request("GET", "/assets/" + quote(id) + "/thumb", query={"w": w}, headers={}, expect="raw")
142
+
143
+ def lineage(self, id: str, *, limit: Optional[Any] = None, cursor: Optional[Any] = None) -> Any:
144
+ """Downstream provenance — every item ever made from this asset, recursively. (GET /assets/{id}/lineage)"""
145
+ return self._t.request("GET", "/assets/" + quote(id) + "/lineage", query={"limit": limit, "cursor": cursor}, headers={}, expect="json")
146
+
147
+ class GalleryGroup:
148
+ """The gallery group."""
149
+
150
+ def __init__(self, transport: Transport) -> None:
151
+ self._t = transport
152
+
153
+ def add(self, body: Dict[str, Any]) -> Any:
154
+ """Record an output with its params, inputs, tags and session. One call per generation. (POST /gallery/items)"""
155
+ return self._t.request("POST", "/gallery/items", query={}, headers={}, expect="json", json_body=body, content_type="application/json")
156
+
157
+ def query(self, *, scope: Optional[Any] = None, studio: Optional[Any] = None, kind: Optional[Any] = None, tag: Optional[Sequence[str]] = None, session_id: Optional[Any] = None, starred: Optional[Any] = None, asset_id: Optional[Any] = None, since: Optional[Any] = None, until: Optional[Any] = None, q: Optional[Any] = None, limit: Optional[Any] = None, cursor: Optional[Any] = None) -> Any:
158
+ """Query items, scoped to the caller unless scope=all. (GET /gallery/items)"""
159
+ return self._t.request("GET", "/gallery/items", query={"scope": scope, "studio": studio, "kind": kind, "tag": tag, "session_id": session_id, "starred": starred, "asset_id": asset_id, "since": since, "until": until, "q": q, "limit": limit, "cursor": cursor}, headers={}, expect="json")
160
+
161
+ def get(self, id: str) -> Any:
162
+ """One item. Another studio's without gallery.read_all, or a deleted one, is 404. (GET /gallery/items/{id})"""
163
+ return self._t.request("GET", "/gallery/items/" + quote(id), query={}, headers={}, expect="json")
164
+
165
+ def update(self, id: str, body: Dict[str, Any]) -> Any:
166
+ """Star, tag or rename one of the caller's own items. (PATCH /gallery/items/{id})"""
167
+ return self._t.request("PATCH", "/gallery/items/" + quote(id), query={}, headers={}, expect="json", json_body=body, content_type="application/merge-patch+json")
168
+
169
+ def delete(self, id: str) -> Any:
170
+ """Soft-delete one of the caller's own items. (DELETE /gallery/items/{id})"""
171
+ return self._t.request("DELETE", "/gallery/items/" + quote(id), query={}, headers={}, expect="empty")
172
+
173
+ def lineage(self, id: str, *, limit: Optional[Any] = None, cursor: Optional[Any] = None) -> Any:
174
+ """Upstream provenance — the items whose outputs this item was made from, recursively. (GET /gallery/items/{id}/lineage)"""
175
+ return self._t.request("GET", "/gallery/items/" + quote(id) + "/lineage", query={"limit": limit, "cursor": cursor}, headers={}, expect="json")
176
+
177
+ class HandoffGroup:
178
+ """The handoff group."""
179
+
180
+ def __init__(self, transport: Transport) -> None:
181
+ self._t = transport
182
+
183
+ def send(self, body: Dict[str, Any]) -> Any:
184
+ """Put an item in another studio's inbox. (POST /handoff)"""
185
+ return self._t.request("POST", "/handoff", query={}, headers={}, expect="json", json_body=body, content_type="application/json")
186
+
187
+ class InboxGroup:
188
+ """The inbox group."""
189
+
190
+ def __init__(self, transport: Transport) -> None:
191
+ self._t = transport
192
+
193
+ def list(self, *, limit: Optional[Any] = None, cursor: Optional[Any] = None) -> Any:
194
+ """Handoffs to the caller not yet consumed, oldest first. (GET /inbox)"""
195
+ return self._t.request("GET", "/inbox", query={"limit": limit, "cursor": cursor}, headers={}, expect="json")
196
+
197
+ def consume(self, id: str) -> Any:
198
+ """Mark an entry handled. Idempotent. (POST /inbox/{id}:consume)"""
199
+ return self._t.request("POST", "/inbox/" + quote(id) + ":consume", query={}, headers={}, expect="empty")
200
+
201
+ class JobsGroup:
202
+ """The jobs group."""
203
+
204
+ def __init__(self, transport: Transport) -> None:
205
+ self._t = transport
206
+
207
+ def list(self, *, limit: Optional[Any] = None, cursor: Optional[Any] = None) -> Any:
208
+ """The calling studio's jobs, of every kind, newest first. (GET /jobs)"""
209
+ return self._t.request("GET", "/jobs", query={"limit": limit, "cursor": cursor}, headers={}, expect="json")
210
+
211
+ def create(self, body: Dict[str, Any]) -> Any:
212
+ """Report a long-running piece of the studio's own work as a task job. (POST /jobs)"""
213
+ return self._t.request("POST", "/jobs", query={}, headers={}, expect="json", json_body=body, content_type="application/json")
214
+
215
+ def get(self, id: str) -> Any:
216
+ """One of the calling studio's jobs. Another studio's is 404. (GET /jobs/{id})"""
217
+ return self._t.request("GET", "/jobs/" + quote(id), query={}, headers={}, expect="json")
218
+
219
+ def update(self, id: str, body: Dict[str, Any]) -> Any:
220
+ """Report progress on, or finish, a task job the calling studio created. (PATCH /jobs/{id})"""
221
+ return self._t.request("PATCH", "/jobs/" + quote(id), query={}, headers={}, expect="json", json_body=body, content_type="application/merge-patch+json")
222
+
223
+ def cancel(self, id: str) -> Any:
224
+ """Ask for one of the calling studio's task jobs to be cancelled. (POST /jobs/{id}:cancel)"""
225
+ return self._t.request("POST", "/jobs/" + quote(id) + ":cancel", query={}, headers={}, expect="empty")
226
+
227
+ def logs(self, id: str) -> Any:
228
+ """One of the calling studio's job logs as server-sent events, from the start, then live until the job finishes. (GET /jobs/{id}/logs)"""
229
+ return self._t.request("GET", "/jobs/" + quote(id) + "/logs", query={}, headers={}, expect="sse")
230
+
231
+ def append_log(self, id: str, body: Dict[str, Any]) -> Any:
232
+ """Append lines to a running task job's log. (POST /jobs/{id}/logs)"""
233
+ return self._t.request("POST", "/jobs/" + quote(id) + "/logs", query={}, headers={}, expect="empty", json_body=body, content_type="application/json")
234
+
235
+ class TimelineGroup:
236
+ """The timeline group."""
237
+
238
+ def __init__(self, transport: Transport) -> None:
239
+ self._t = transport
240
+
241
+ def create(self, body: Dict[str, Any]) -> Any:
242
+ """Create a sequence from clips, or from whole tracks. (POST /timeline)"""
243
+ return self._t.request("POST", "/timeline", query={}, headers={}, expect="json", json_body=body, content_type="application/json")
244
+
245
+ def list(self, *, limit: Optional[Any] = None, cursor: Optional[Any] = None) -> Any:
246
+ """The sequences the caller may read, newest first. (GET /timeline)"""
247
+ return self._t.request("GET", "/timeline", query={"limit": limit, "cursor": cursor}, headers={}, expect="json")
248
+
249
+ def get(self, id: str) -> Any:
250
+ """One sequence. One the caller may not read is 404. (GET /timeline/{id})"""
251
+ return self._t.request("GET", "/timeline/" + quote(id), query={}, headers={}, expect="json")
252
+
253
+ def update(self, id: str, body: Dict[str, Any], *, if_match: Any) -> Any:
254
+ """Edit a sequence. Every edit is a revision. (PATCH /timeline/{id})"""
255
+ return self._t.request("PATCH", "/timeline/" + quote(id), query={}, headers={"If-Match": if_match}, expect="json", json_body=body, content_type="application/merge-patch+json")
256
+
257
+ def delete(self, id: str, *, if_match: Optional[Any] = None) -> Any:
258
+ """Delete a sequence. Its exports and their lineage stay. (DELETE /timeline/{id})"""
259
+ return self._t.request("DELETE", "/timeline/" + quote(id), query={}, headers={"If-Match": if_match}, expect="empty")
260
+
261
+ def revisions(self, id: str, *, limit: Optional[Any] = None, cursor: Optional[Any] = None) -> Any:
262
+ """The sequence's earlier revisions, newest first. (GET /timeline/{id}/revisions)"""
263
+ return self._t.request("GET", "/timeline/" + quote(id) + "/revisions", query={"limit": limit, "cursor": cursor}, headers={}, expect="json")
264
+
265
+ def revert(self, id: str, body: Dict[str, Any], *, if_match: Any) -> Any:
266
+ """Write an earlier revision back as the newest one. (POST /timeline/{id}:revert)"""
267
+ return self._t.request("POST", "/timeline/" + quote(id) + ":revert", query={}, headers={"If-Match": if_match}, expect="json", json_body=body, content_type="application/json")
268
+
269
+ def append(self, body: Dict[str, Any]) -> Any:
270
+ """Add one asset to the end of a track, without stealing focus. (POST /timeline:append)"""
271
+ return self._t.request("POST", "/timeline:append", query={}, headers={}, expect="json", json_body=body, content_type="application/json")
272
+
273
+ def open(self, id: str) -> Any:
274
+ """Ask the framework to show its editor on this sequence. (POST /timeline/{id}:open)"""
275
+ return self._t.request("POST", "/timeline/" + quote(id) + ":open", query={}, headers={}, expect="json")
276
+
277
+ def plan(self, id: str, *, preset: Optional[Any] = None) -> Any:
278
+ """Which path an export would take, and why. (GET /timeline/{id}:plan)"""
279
+ return self._t.request("GET", "/timeline/" + quote(id) + ":plan", query={"preset": preset}, headers={}, expect="json")
280
+
281
+ def export(self, id: str, body: Dict[str, Any]) -> Any:
282
+ """Export the sequence. Answers with the job that renders it. (POST /timeline/{id}:export)"""
283
+ return self._t.request("POST", "/timeline/" + quote(id) + ":export", query={}, headers={}, expect="json", json_body=body, content_type="application/json")
284
+
285
+ def exports(self, id: str, *, limit: Optional[Any] = None, cursor: Optional[Any] = None) -> Any:
286
+ """The sequence's export jobs, newest first. (GET /timeline/{id}/exports)"""
287
+ return self._t.request("GET", "/timeline/" + quote(id) + "/exports", query={"limit": limit, "cursor": cursor}, headers={}, expect="json")
288
+
289
+ def cancel_export(self, id: str, job: str) -> Any:
290
+ """Stop a running export and leave nothing behind. (POST /timeline/{id}/exports/{job}:cancel)"""
291
+ return self._t.request("POST", "/timeline/" + quote(id) + "/exports/" + quote(job) + ":cancel", query={}, headers={}, expect="empty")
292
+
293
+
294
+ class Client:
295
+ """One attribute per API group, the same names as the Go and Node clients."""
296
+
297
+ def __init__(self, transport: Transport) -> None:
298
+ self.me = MeGroup(transport)
299
+ self.events = EventsGroup(transport)
300
+ self.kv = KVGroup(transport)
301
+ self.sessions = SessionsGroup(transport)
302
+ self.records = RecordsGroup(transport)
303
+ self.assets = AssetsGroup(transport)
304
+ self.gallery = GalleryGroup(transport)
305
+ self.handoff = HandoffGroup(transport)
306
+ self.inbox = InboxGroup(transport)
307
+ self.jobs = JobsGroup(transport)
308
+ self.timeline = TimelineGroup(transport)
@@ -0,0 +1,148 @@
1
+ """HTTP transport for the helmstudio runtime SDK. Standard library only."""
2
+
3
+ import datetime
4
+ import json
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+ from typing import Any, Dict, Iterator, Optional
9
+
10
+
11
+ def quote(value: str) -> str:
12
+ """Escape one path segment."""
13
+ return urllib.parse.quote(str(value), safe=":")
14
+
15
+
16
+ # The one typed error shape across languages (docs/design/04-packages.md §4).
17
+ KINDS = {
18
+ 400: "Invalid", 413: "Invalid", 416: "Invalid", 422: "Invalid",
19
+ 401: "Unauthenticated",
20
+ 403: "Forbidden", 421: "Forbidden",
21
+ 404: "NotFound", 410: "NotFound",
22
+ 409: "Conflict",
23
+ 429: "QuotaExceeded", 507: "QuotaExceeded",
24
+ 501: "Unsupported",
25
+ 503: "Unavailable",
26
+ }
27
+
28
+
29
+ class HelmError(Exception):
30
+ """Every refusal: status, stable code, message and details."""
31
+
32
+ def __init__(self, status: int, code: str, message: str, details: Optional[Dict[str, Any]] = None) -> None:
33
+ super().__init__("%s (%d): %s" % (code, status, message))
34
+ self.status = status
35
+ self.code = code
36
+ self.message = message
37
+ self.details = details or {}
38
+
39
+ @property
40
+ def kind(self) -> str:
41
+ if self.status == 0:
42
+ return "Unavailable"
43
+ return KINDS.get(self.status, "Internal")
44
+
45
+
46
+ class RawResponse:
47
+ """An asset's bytes or a thumbnail."""
48
+
49
+ def __init__(self, response: Any) -> None:
50
+ self.status = response.status
51
+ self.headers = dict(response.headers.items())
52
+ self._response = response
53
+
54
+ def read(self) -> bytes:
55
+ try:
56
+ return self._response.read()
57
+ finally:
58
+ self._response.close()
59
+
60
+
61
+ class Event:
62
+ def __init__(self, event_id: str, name: str, data: str) -> None:
63
+ self.id = event_id
64
+ self.name = name
65
+ self.data = data
66
+
67
+ def json(self) -> Any:
68
+ return json.loads(self.data) if self.data else None
69
+
70
+
71
+ def _events(response: Any) -> Iterator[Event]:
72
+ event_id, name, data = "", "", []
73
+ try:
74
+ for raw in response:
75
+ line = raw.decode("utf-8").rstrip("\r\n")
76
+ if line == "":
77
+ if name or data:
78
+ yield Event(event_id, name, "\n".join(data))
79
+ event_id, name, data = "", "", []
80
+ elif line.startswith(":"):
81
+ continue
82
+ elif line.startswith("id:"):
83
+ event_id = line[3:].strip()
84
+ elif line.startswith("event:"):
85
+ name = line[6:].strip()
86
+ elif line.startswith("data:"):
87
+ data.append(line[5:].lstrip(" "))
88
+ finally:
89
+ response.close()
90
+
91
+
92
+ def _value(v: Any) -> str:
93
+ if isinstance(v, bool):
94
+ return "true" if v else "false"
95
+ if isinstance(v, datetime.datetime):
96
+ return v.isoformat()
97
+ return str(v)
98
+
99
+
100
+ class Transport:
101
+ def __init__(self, base: str, token: str, timeout: float = 60.0) -> None:
102
+ self.base = base.rstrip("/")
103
+ self.token = token
104
+ self.timeout = timeout
105
+
106
+ def request(self, method: str, path: str, query: Dict[str, Any], headers: Dict[str, Any], expect: str,
107
+ json_body: Any = None, raw_body: Optional[bytes] = None, content_type: Optional[str] = None) -> Any:
108
+ pairs = []
109
+ for k, v in query.items():
110
+ if v is None:
111
+ continue
112
+ if isinstance(v, (list, tuple)):
113
+ pairs.extend((k, _value(x)) for x in v)
114
+ else:
115
+ pairs.append((k, _value(v)))
116
+ url = self.base + path + ("?" + urllib.parse.urlencode(pairs) if pairs else "")
117
+ body = None
118
+ if json_body is not None:
119
+ body = json.dumps(json_body).encode("utf-8")
120
+ elif raw_body is not None:
121
+ body = raw_body
122
+ req = urllib.request.Request(url, data=body, method=method)
123
+ req.add_header("Authorization", "Bearer " + self.token)
124
+ if content_type:
125
+ req.add_header("Content-Type", content_type)
126
+ for k, v in headers.items():
127
+ if v is not None:
128
+ req.add_header(k, _value(v))
129
+ try:
130
+ response = urllib.request.urlopen(req, timeout=None if expect == "sse" else self.timeout)
131
+ except urllib.error.HTTPError as e:
132
+ raw = e.read()
133
+ try:
134
+ doc = json.loads(raw)
135
+ raise HelmError(e.code, doc.get("error", "unexpected_response"), doc.get("message", ""), doc.get("details")) from None
136
+ except ValueError:
137
+ raise HelmError(e.code, "unexpected_response", raw.decode("utf-8", "replace")) from None
138
+ except urllib.error.URLError as e:
139
+ raise HelmError(0, "unavailable", str(e.reason)) from None
140
+ if expect == "sse":
141
+ return _events(response)
142
+ if expect == "raw":
143
+ return RawResponse(response)
144
+ with response:
145
+ raw = response.read()
146
+ if expect == "json":
147
+ return json.loads(raw)
148
+ return None
@@ -0,0 +1,259 @@
1
+ """The same-origin proxy a studio mounts at /helm/, so its page never holds a token.
2
+
3
+ Standard library only. It behaves exactly as the Go and Node runtime SDKs'
4
+ proxies do (docs/decisions.md M6 Q10; test/conformance holds all three to it):
5
+
6
+ - ``/helm/api/v1/<studio-api path>`` is forwarded to ``HELM_API`` with
7
+ ``HELM_TOKEN`` added. Only the studio API and the theme stream; a launcher
8
+ path is 404 and never reaches the daemon.
9
+ - ``/helm/sdk/v1/<file>`` (GET and HEAD) is forwarded to ``HELM_SDK_BASE``
10
+ without a token.
11
+ - ``/helm/accent.css`` is the studio's hue for each theme.
12
+
13
+ The page's own Authorization, cookies, Origin and Referer are never forwarded,
14
+ and the daemon's Set-Cookie never comes back.
15
+
16
+ For a stdlib ``http.server`` studio::
17
+
18
+ from helm_runtime_sdk.proxy import Proxy
19
+ proxy = Proxy.from_env()
20
+
21
+ class Handler(BaseHTTPRequestHandler):
22
+ def do_GET(self):
23
+ if proxy.handle_http(self):
24
+ return
25
+ ...
26
+
27
+ For a WSGI studio, mount ``proxy.wsgi`` under ``/helm``.
28
+ """
29
+
30
+ import json
31
+ import os
32
+ import re
33
+ import urllib.error
34
+ import urllib.request
35
+ from typing import Dict, Iterable, Iterator, List, Optional, Tuple
36
+
37
+ PREFIX = "/helm/"
38
+
39
+ # First path segments under /api/v1/ that are forwarded: every studio-api
40
+ # operation's, and the theme stream's.
41
+ STUDIO_SEGMENTS = frozenset(
42
+ [
43
+ "me", "events", "kv", "sessions", "records", "assets", "assets:adopt", "gallery",
44
+ "handoff", "inbox", "jobs", "theme", "timeline", "timeline:append",
45
+ ]
46
+ )
47
+
48
+ REQUEST_HEADERS = ["Accept", "Content-Type", "Range", "If-Range", "If-Match", "If-None-Match", "Last-Event-ID"]
49
+ RESPONSE_HEADERS = ["Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified", "Cache-Control", "Content-Disposition", "Allow"]
50
+ METHODS = frozenset(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"])
51
+
52
+ _HEX = re.compile(r"^#[0-9a-fA-F]{6}$")
53
+
54
+
55
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
56
+ def redirect_request(self, *args, **kwargs): # never follow: forward as is
57
+ return None
58
+
59
+
60
+ _opener = urllib.request.build_opener(_NoRedirect)
61
+
62
+ Response = Tuple[int, List[Tuple[str, str]], Iterable[bytes]]
63
+
64
+
65
+ def _luminance(hex_colour: str) -> float:
66
+ out = []
67
+ for i in range(3):
68
+ c = int(hex_colour[1 + 2 * i : 3 + 2 * i], 16) / 255
69
+ out.append(c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4)
70
+ return 0.2126 * out[0] + 0.7152 * out[1] + 0.0722 * out[2]
71
+
72
+
73
+ def _on(hex_colour: str) -> str:
74
+ lum = _luminance(hex_colour)
75
+ dark = (lum + 0.05) / (_luminance("#1a1400") + 0.05)
76
+ white = 1.05 / (lum + 0.05)
77
+ return "#1a1400" if dark >= white else "#ffffff"
78
+
79
+
80
+ def accent_css(dark: str, light: str) -> Optional[str]:
81
+ """/helm/accent.css for a hue pair, or None unless both are #rrggbb."""
82
+ if not dark or not light or not _HEX.match(dark) or not _HEX.match(light):
83
+ return None
84
+ dark, light = dark.lower(), light.lower()
85
+
86
+ def block(h: str) -> str:
87
+ return "--helm-studio-accent: " + h + "; --helm-on-studio-accent: " + _on(h) + ";"
88
+
89
+ return (
90
+ ":root { " + block(dark) + " }\n"
91
+ + "@media (prefers-color-scheme: light) { :root:not([data-theme]) { " + block(light) + " } }\n"
92
+ + ':root[data-theme="light"] { ' + block(light) + " }\n"
93
+ )
94
+
95
+
96
+ def safe_path(path: str) -> bool:
97
+ """Refuse dot segments, empty segments and encoded slashes, backslashes or dots."""
98
+ lower = path.lower()
99
+ if "%2f" in lower or "%5c" in lower or "%2e" in lower or "\\" in path:
100
+ return False
101
+ segs = path.lstrip("/").split("/")
102
+ for i, s in enumerate(segs):
103
+ if s in (".", "..") or (s == "" and i != len(segs) - 1):
104
+ return False
105
+ return True
106
+
107
+
108
+ def _error(status: int, code: str, message: str) -> Response:
109
+ body = json.dumps({"error": code, "message": message}).encode() + b"\n"
110
+ return status, [("Content-Type", "application/json"), ("X-Content-Type-Options", "nosniff")], [body]
111
+
112
+
113
+ def _not_found() -> Response:
114
+ return _error(404, "not_found", "the helm proxy forwards only the studio API, the theme stream, the SDK files and accent.css")
115
+
116
+
117
+ class Proxy:
118
+ def __init__(self, api: str = "", token: str = "", sdk_base: str = "", accent_dark: str = "", accent_light: str = "") -> None:
119
+ self.api = api.rstrip("/")
120
+ self.token = token
121
+ self.sdk_base = sdk_base.rstrip("/")
122
+ self.accent_dark = accent_dark
123
+ self.accent_light = accent_light
124
+
125
+ @classmethod
126
+ def from_env(cls, env: Optional[Dict[str, str]] = None) -> "Proxy":
127
+ e = os.environ if env is None else env
128
+ return cls(e.get("HELM_API", ""), e.get("HELM_TOKEN", ""), e.get("HELM_SDK_BASE", ""),
129
+ e.get("HELM_ACCENT_DARK", ""), e.get("HELM_ACCENT_LIGHT", ""))
130
+
131
+ def respond(self, method: str, raw_path: str, query: str, headers: Dict[str, str], body: Optional[bytes]) -> Response:
132
+ """Answer one request. raw_path is the escaped path, starting /helm/."""
133
+ if not raw_path.startswith(PREFIX):
134
+ return _not_found()
135
+ rest = raw_path[len(PREFIX) - 1 :]
136
+ if not safe_path(rest):
137
+ return _not_found()
138
+ if rest == "/accent.css":
139
+ css = accent_css(self.accent_dark, self.accent_light)
140
+ if css is None or method not in ("GET", "HEAD"):
141
+ return _not_found()
142
+ payload = [] if method == "HEAD" else [css.encode()]
143
+ return 200, [("Content-Type", "text/css; charset=utf-8"), ("Cache-Control", "no-cache"), ("X-Content-Type-Options", "nosniff")], payload
144
+ if rest.startswith("/api/v1/"):
145
+ sub = rest[len("/api/v1/") :]
146
+ first = sub.split("/", 1)[0]
147
+ if not self.api or first not in STUDIO_SEGMENTS:
148
+ return _not_found()
149
+ return self._forward(method, self.api + "/" + sub, query, headers, body, self.token)
150
+ if rest.startswith("/sdk/v1/"):
151
+ if not self.sdk_base or method not in ("GET", "HEAD"):
152
+ return _not_found()
153
+ return self._forward(method, self.sdk_base + "/" + rest[len("/sdk/v1/") :], query, headers, None, "")
154
+ return _not_found()
155
+
156
+ def _forward(self, method: str, target: str, query: str, headers: Dict[str, str], body: Optional[bytes], token: str) -> Response:
157
+ if method not in METHODS:
158
+ return _error(405, "method_not_allowed", method + " is not forwarded")
159
+ if query:
160
+ target += "?" + query
161
+ lower = {k.lower(): v for k, v in headers.items()}
162
+ out = {}
163
+ for h in REQUEST_HEADERS:
164
+ if h.lower() in lower:
165
+ out[h] = lower[h.lower()]
166
+ if token:
167
+ out["Authorization"] = "Bearer " + token
168
+ data = body if method not in ("GET", "HEAD") else None
169
+ if method in ("POST", "PUT", "PATCH") and data is None:
170
+ data = b""
171
+ req = urllib.request.Request(target, data=data, method=method, headers=out)
172
+ try:
173
+ res = _opener.open(req)
174
+ except urllib.error.HTTPError as e:
175
+ res = e
176
+ except (urllib.error.URLError, OSError):
177
+ # Never the error text: it can carry the upstream URL.
178
+ return _error(503, "unavailable", "helmstudio did not answer")
179
+ status = res.status if hasattr(res, "status") else res.code
180
+ resp_headers = [("X-Content-Type-Options", "nosniff")]
181
+ for h in RESPONSE_HEADERS:
182
+ for v in res.headers.get_all(h) or []:
183
+ resp_headers.append((h, v))
184
+ if method == "HEAD":
185
+ res.close()
186
+ return status, resp_headers, []
187
+ return status, resp_headers, _chunks(res)
188
+
189
+ def wsgi(self, environ, start_response):
190
+ """A WSGI application for everything under /helm/."""
191
+ raw = environ.get("RAW_URI") or environ.get("REQUEST_URI") or ""
192
+ raw = raw.split("?", 1)[0] if raw else (environ.get("SCRIPT_NAME", "") + environ.get("PATH_INFO", ""))
193
+ headers = {k[5:].replace("_", "-").title(): v for k, v in environ.items() if k.startswith("HTTP_")}
194
+ if environ.get("CONTENT_TYPE"):
195
+ headers["Content-Type"] = environ["CONTENT_TYPE"]
196
+ length = int(environ.get("CONTENT_LENGTH") or 0)
197
+ body = environ["wsgi.input"].read(length) if length else None
198
+ status, hdrs, payload = self.respond(environ.get("REQUEST_METHOD", "GET"), raw, environ.get("QUERY_STRING", ""), headers, body)
199
+ start_response("%d %s" % (status, _reason(status)), hdrs)
200
+ return payload
201
+
202
+ def handle_http(self, handler) -> bool:
203
+ """Serve a request on a http.server.BaseHTTPRequestHandler if it is under /helm/.
204
+
205
+ Returns False, having written nothing, for any other path.
206
+ """
207
+ path, _, query = handler.path.partition("?")
208
+ if not path.startswith(PREFIX):
209
+ return False
210
+ length = int(handler.headers.get("Content-Length") or 0)
211
+ body = handler.rfile.read(length) if length else None
212
+ status, hdrs, payload = self.respond(handler.command, path, query, dict(handler.headers.items()), body)
213
+ if isinstance(payload, list):
214
+ # Fully known: say how long it is, so a kept-alive connection ends the response.
215
+ data = b"".join(payload)
216
+ payload = [data]
217
+ if handler.command != "HEAD":
218
+ hdrs = [(k, v) for k, v in hdrs if k != "Content-Length"] + [("Content-Length", str(len(data)))]
219
+ elif not any(k == "Content-Length" for k, _ in hdrs):
220
+ hdrs = hdrs + [("Content-Length", "0")]
221
+ elif not any(k == "Content-Length" for k, _ in hdrs):
222
+ # A stream of unknown length ends when the connection closes.
223
+ handler.close_connection = True
224
+ hdrs = hdrs + [("Connection", "close")]
225
+ handler.send_response(status)
226
+ for k, v in hdrs:
227
+ handler.send_header(k, v)
228
+ handler.end_headers()
229
+ try:
230
+ for chunk in payload:
231
+ handler.wfile.write(chunk)
232
+ handler.wfile.flush()
233
+ except (BrokenPipeError, ConnectionResetError):
234
+ pass
235
+ finally:
236
+ close = getattr(payload, "close", None)
237
+ if close:
238
+ close()
239
+ return True
240
+
241
+
242
+ def _chunks(res) -> Iterator[bytes]:
243
+ try:
244
+ while True:
245
+ chunk = res.read1(65536) if hasattr(res, "read1") else res.read(65536)
246
+ if not chunk:
247
+ return
248
+ yield chunk
249
+ finally:
250
+ res.close()
251
+
252
+
253
+ def _reason(status: int) -> str:
254
+ from http import HTTPStatus
255
+
256
+ try:
257
+ return HTTPStatus(status).phrase
258
+ except ValueError:
259
+ return ""
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: helm-runtime-sdk
3
+ Version: 1.0.0rc1
4
+ Summary: helmstudio's runtime SDK: a client for the platform API
5
+ Author: Janishar Ali
6
+ License: MIT
7
+ Project-URL: Documentation, https://helmstudio.in/docs/
8
+ Project-URL: Repository, https://github.com/janishar/helmstudio
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # helm-runtime-sdk
19
+
20
+ helmstudio's runtime SDK for Python: the client a studio uses to reach the
21
+ platform API — keep its files in the library, record what it makes in the
22
+ gallery with the parameters and inputs that made it, keep sessions and
23
+ settings, hand clips to the timeline — and the same-origin proxy that lets its
24
+ page use the API and helm-css without holding a token.
25
+
26
+ pip install --pre helm-runtime-sdk
27
+
28
+ It uses the standard library only, and needs Python 3.9 or newer.
29
+
30
+ A studio built with it runs under helmstudio, or on its own under `helm dev`,
31
+ which set `HELM_API` and `HELM_TOKEN` for it:
32
+
33
+ from helm_runtime_sdk import from_env
34
+
35
+ helm = from_env()
36
+ asset = helm.assets.adopt({"path": path, "kind": "image"})
37
+ helm.gallery.add({"kind": "image", "asset_id": asset["id"], "params": {"prompt": prompt}})
38
+
39
+ The proxy is `helm_runtime_sdk.proxy`.
40
+
41
+ The documentation — the quickstart, the guides, and every call in Python, Go
42
+ and JavaScript — is at https://helmstudio.in/docs/.
@@ -0,0 +1,9 @@
1
+ helm_runtime_sdk/__init__.py,sha256=v_jl-6Ma5_8F9tbEQVUG6JPGyfyzdnPsE0FlMJH1I7I,1448
2
+ helm_runtime_sdk/_generated.py,sha256=96boCcxXRU3ymssgm42bQf-f6cX3lSE-_o1wz_pGhlk,19454
3
+ helm_runtime_sdk/_transport.py,sha256=fJ4jWtrQ5Of2qqeXFNqiPfk6dmJL2uQjQlarMqFXkYg,4972
4
+ helm_runtime_sdk/proxy.py,sha256=WpQFGSrjdKHJrVDNmzxsviyKl-9J7sTAsj9vqijzxPU,10710
5
+ helm_runtime_sdk-1.0.0rc1.dist-info/licenses/LICENSE,sha256=Pi8mZuoS8vyZDHm7Fbh-abXjQcZz3W4TdhezZR6rHPw,1069
6
+ helm_runtime_sdk-1.0.0rc1.dist-info/METADATA,sha256=MplKl5LZiTGbbZY2qwii2dXdCRcnYA7RMIeAynDpcvw,1587
7
+ helm_runtime_sdk-1.0.0rc1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ helm_runtime_sdk-1.0.0rc1.dist-info/top_level.txt,sha256=b7WyuDEvqDmJgoqpUw9S4TVGgCfQdfBMkass5UY1IH8,17
9
+ helm_runtime_sdk-1.0.0rc1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Janishar Ali
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ helm_runtime_sdk