helm-runtime-sdk 1.0.0rc1__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.
- helm_runtime_sdk-1.0.0rc1/LICENSE +21 -0
- helm_runtime_sdk-1.0.0rc1/PKG-INFO +42 -0
- helm_runtime_sdk-1.0.0rc1/README.md +25 -0
- helm_runtime_sdk-1.0.0rc1/helm_runtime_sdk/__init__.py +35 -0
- helm_runtime_sdk-1.0.0rc1/helm_runtime_sdk/_generated.py +308 -0
- helm_runtime_sdk-1.0.0rc1/helm_runtime_sdk/_transport.py +148 -0
- helm_runtime_sdk-1.0.0rc1/helm_runtime_sdk/proxy.py +259 -0
- helm_runtime_sdk-1.0.0rc1/helm_runtime_sdk.egg-info/PKG-INFO +42 -0
- helm_runtime_sdk-1.0.0rc1/helm_runtime_sdk.egg-info/SOURCES.txt +11 -0
- helm_runtime_sdk-1.0.0rc1/helm_runtime_sdk.egg-info/dependency_links.txt +1 -0
- helm_runtime_sdk-1.0.0rc1/helm_runtime_sdk.egg-info/top_level.txt +1 -0
- helm_runtime_sdk-1.0.0rc1/pyproject.toml +29 -0
- helm_runtime_sdk-1.0.0rc1/setup.cfg +4 -0
|
@@ -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,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,25 @@
|
|
|
1
|
+
# helm-runtime-sdk
|
|
2
|
+
|
|
3
|
+
helmstudio's runtime SDK for Python: the client a studio uses to reach the
|
|
4
|
+
platform API — keep its files in the library, record what it makes in the
|
|
5
|
+
gallery with the parameters and inputs that made it, keep sessions and
|
|
6
|
+
settings, hand clips to the timeline — and the same-origin proxy that lets its
|
|
7
|
+
page use the API and helm-css without holding a token.
|
|
8
|
+
|
|
9
|
+
pip install --pre helm-runtime-sdk
|
|
10
|
+
|
|
11
|
+
It uses the standard library only, and needs Python 3.9 or newer.
|
|
12
|
+
|
|
13
|
+
A studio built with it runs under helmstudio, or on its own under `helm dev`,
|
|
14
|
+
which set `HELM_API` and `HELM_TOKEN` for it:
|
|
15
|
+
|
|
16
|
+
from helm_runtime_sdk import from_env
|
|
17
|
+
|
|
18
|
+
helm = from_env()
|
|
19
|
+
asset = helm.assets.adopt({"path": path, "kind": "image"})
|
|
20
|
+
helm.gallery.add({"kind": "image", "asset_id": asset["id"], "params": {"prompt": prompt}})
|
|
21
|
+
|
|
22
|
+
The proxy is `helm_runtime_sdk.proxy`.
|
|
23
|
+
|
|
24
|
+
The documentation — the quickstart, the guides, and every call in Python, Go
|
|
25
|
+
and JavaScript — is at https://helmstudio.in/docs/.
|
|
@@ -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,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
helm_runtime_sdk/__init__.py
|
|
5
|
+
helm_runtime_sdk/_generated.py
|
|
6
|
+
helm_runtime_sdk/_transport.py
|
|
7
|
+
helm_runtime_sdk/proxy.py
|
|
8
|
+
helm_runtime_sdk.egg-info/PKG-INFO
|
|
9
|
+
helm_runtime_sdk.egg-info/SOURCES.txt
|
|
10
|
+
helm_runtime_sdk.egg-info/dependency_links.txt
|
|
11
|
+
helm_runtime_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
helm_runtime_sdk
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "helm-runtime-sdk"
|
|
3
|
+
version = "1.0.0rc1"
|
|
4
|
+
description = "helmstudio's runtime SDK: a client for the platform API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Janishar Ali" }]
|
|
9
|
+
dependencies = []
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Operating System :: OS Independent",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Documentation = "https://helmstudio.in/docs/"
|
|
19
|
+
Repository = "https://github.com/janishar/helmstudio"
|
|
20
|
+
|
|
21
|
+
# No embedded extra: a Python studio runs standalone under helm dev
|
|
22
|
+
# (docs/decisions.md M5 Q14).
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["setuptools>=61"]
|
|
26
|
+
build-backend = "setuptools.build_meta"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools]
|
|
29
|
+
packages = ["helm_runtime_sdk"]
|