zygo-sdk 0.1.2__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.
- zygo_sdk/__init__.py +119 -0
- zygo_sdk/_endpoint.py +79 -0
- zygo_sdk/_errors.py +241 -0
- zygo_sdk/_models.py +474 -0
- zygo_sdk/_sync.py +1103 -0
- zygo_sdk/aio.py +839 -0
- zygo_sdk-0.1.2.dist-info/METADATA +82 -0
- zygo_sdk-0.1.2.dist-info/RECORD +11 -0
- zygo_sdk-0.1.2.dist-info/WHEEL +4 -0
- zygo_sdk-0.1.2.dist-info/licenses/LICENSE +202 -0
- zygo_sdk-0.1.2.dist-info/licenses/NOTICE +11 -0
zygo_sdk/__init__.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Zygo — warm sandboxes for function-shaped code, from Python.
|
|
3
|
+
|
|
4
|
+
import zygo_sdk as zygo
|
|
5
|
+
|
|
6
|
+
client = zygo.connect() # `zygo api` on loopback
|
|
7
|
+
resize = client.fn("resize") # a function from sandbox.toml
|
|
8
|
+
out = resize({"url": "..."}) # ~2 ms, a fresh process
|
|
9
|
+
print(out.result)
|
|
10
|
+
|
|
11
|
+
A warm function costs about a millisecond and gets a clean process per request.
|
|
12
|
+
A one-shot sandbox costs tens of milliseconds and needs nothing declared in
|
|
13
|
+
advance:
|
|
14
|
+
|
|
15
|
+
r = client.run("python:3.12-slim", ["python3", "-c", "print(6*7)"])
|
|
16
|
+
print(r.stdout)
|
|
17
|
+
|
|
18
|
+
For an event loop, ``zygo.aio`` is the same API with every call a coroutine:
|
|
19
|
+
|
|
20
|
+
async with zygo.aio.connect() as client:
|
|
21
|
+
out = await client.call("resize", {"url": "..."})
|
|
22
|
+
|
|
23
|
+
A refused request — :class:`Busy`, or :class:`Unavailable` while the host is
|
|
24
|
+
still building something — can be retried for you: ``zygo.connect(retries=3)``
|
|
25
|
+
waits the server's ``Retry-After`` and sends it again. Off by default.
|
|
26
|
+
|
|
27
|
+
This package talks to ``zygo api`` over HTTP — on a unix socket when the API is
|
|
28
|
+
on this machine, which is the usual case and needs no token. It has no
|
|
29
|
+
dependencies.
|
|
30
|
+
|
|
31
|
+
What it is *not* is a second implementation of Zygo. Every boundary a sandbox
|
|
32
|
+
has is built by the Zygo binary and enforced by the kernel; nothing here can
|
|
33
|
+
widen one, and an API started without ``--allow-deploy`` will not let this
|
|
34
|
+
package create a sandbox at all.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from ._endpoint import DEFAULT_URL, Endpoint
|
|
38
|
+
from ._errors import (
|
|
39
|
+
AuthError,
|
|
40
|
+
Busy,
|
|
41
|
+
Cancelled,
|
|
42
|
+
HandlerError,
|
|
43
|
+
NotFound,
|
|
44
|
+
SpecError,
|
|
45
|
+
Stuck,
|
|
46
|
+
Timeout,
|
|
47
|
+
TransportError,
|
|
48
|
+
Unavailable,
|
|
49
|
+
ZygoError,
|
|
50
|
+
)
|
|
51
|
+
from ._models import (
|
|
52
|
+
Deps,
|
|
53
|
+
Event,
|
|
54
|
+
Function,
|
|
55
|
+
LogEntry,
|
|
56
|
+
LogPage,
|
|
57
|
+
Metrics,
|
|
58
|
+
Minted,
|
|
59
|
+
Result,
|
|
60
|
+
Run,
|
|
61
|
+
Runtime,
|
|
62
|
+
Script,
|
|
63
|
+
Served,
|
|
64
|
+
Tenant,
|
|
65
|
+
Token,
|
|
66
|
+
)
|
|
67
|
+
from ._sync import Client, FunctionHandle, connect
|
|
68
|
+
|
|
69
|
+
__version__ = "0.1.2"
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
"aio",
|
|
73
|
+
"Client",
|
|
74
|
+
"FunctionHandle",
|
|
75
|
+
"connect",
|
|
76
|
+
"DEFAULT_URL",
|
|
77
|
+
"Endpoint",
|
|
78
|
+
# results
|
|
79
|
+
"Deps",
|
|
80
|
+
"Event",
|
|
81
|
+
"Function",
|
|
82
|
+
"LogEntry",
|
|
83
|
+
"LogPage",
|
|
84
|
+
"Metrics",
|
|
85
|
+
"Minted",
|
|
86
|
+
"Result",
|
|
87
|
+
"Run",
|
|
88
|
+
"Runtime",
|
|
89
|
+
"Script",
|
|
90
|
+
"Served",
|
|
91
|
+
"Tenant",
|
|
92
|
+
"Token",
|
|
93
|
+
# failures
|
|
94
|
+
"AuthError",
|
|
95
|
+
"Busy",
|
|
96
|
+
"Cancelled",
|
|
97
|
+
"HandlerError",
|
|
98
|
+
"NotFound",
|
|
99
|
+
"SpecError",
|
|
100
|
+
"Stuck",
|
|
101
|
+
"Timeout",
|
|
102
|
+
"TransportError",
|
|
103
|
+
"Unavailable",
|
|
104
|
+
"ZygoError",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def __getattr__(name: str): # noqa: ANN202 - PEP 562, a module attribute
|
|
109
|
+
"""``zygo_sdk.aio`` without a second import line.
|
|
110
|
+
|
|
111
|
+
Loaded on first use rather than here, so that ``import zygo_sdk`` does not
|
|
112
|
+
pull in :mod:`asyncio` for a script that never opens an event loop. Both
|
|
113
|
+
``import zygo_sdk.aio`` and ``zygo_sdk.aio`` reach the same module.
|
|
114
|
+
"""
|
|
115
|
+
if name == "aio":
|
|
116
|
+
from importlib import import_module
|
|
117
|
+
|
|
118
|
+
return import_module(".aio", __name__)
|
|
119
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
zygo_sdk/_endpoint.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Where the API is, and how it was decided.
|
|
3
|
+
|
|
4
|
+
One function, because the rule has to be the same for the synchronous and the
|
|
5
|
+
asynchronous client. Two discovery orders that drift is a support question
|
|
6
|
+
nobody can answer from the traceback.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
#: What ``zygo api`` listens on when nothing says otherwise.
|
|
16
|
+
DEFAULT_URL = "http://127.0.0.1:7700"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Endpoint:
|
|
21
|
+
"""A parsed address: either a unix socket path or a host and port."""
|
|
22
|
+
|
|
23
|
+
url: str
|
|
24
|
+
socket_path: Optional[str] = None
|
|
25
|
+
host: str = "127.0.0.1"
|
|
26
|
+
port: int = 7700
|
|
27
|
+
tls: bool = False
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def is_unix(self) -> bool:
|
|
31
|
+
return self.socket_path is not None
|
|
32
|
+
|
|
33
|
+
def __str__(self) -> str:
|
|
34
|
+
return self.url
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve(url: Optional[str] = None) -> Endpoint:
|
|
38
|
+
"""Work out which API to talk to.
|
|
39
|
+
|
|
40
|
+
In order: the argument, then ``ZYGO_API_URL``, then loopback on the port
|
|
41
|
+
``zygo api`` uses by default. Accepts ``unix:///path/to.sock``,
|
|
42
|
+
``http://host:port``, ``https://host:port`` and a bare ``host:port``.
|
|
43
|
+
"""
|
|
44
|
+
text = url or os.environ.get("ZYGO_API_URL") or DEFAULT_URL
|
|
45
|
+
return parse(text)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse(text: str) -> Endpoint:
|
|
49
|
+
text = text.strip()
|
|
50
|
+
if text.startswith("unix://"):
|
|
51
|
+
path = text[len("unix://") :]
|
|
52
|
+
if not path:
|
|
53
|
+
raise ValueError("unix:// needs a path, e.g. unix:///run/user/1000/zygo/api.sock")
|
|
54
|
+
return Endpoint(url=text, socket_path=path)
|
|
55
|
+
|
|
56
|
+
tls = False
|
|
57
|
+
rest = text
|
|
58
|
+
if text.startswith("https://"):
|
|
59
|
+
tls, rest = True, text[len("https://") :]
|
|
60
|
+
elif text.startswith("http://"):
|
|
61
|
+
rest = text[len("http://") :]
|
|
62
|
+
elif "://" in text:
|
|
63
|
+
scheme = text.split("://", 1)[0]
|
|
64
|
+
raise ValueError(f"`{scheme}://` is not an address Zygo serves; use http://, https:// or unix://")
|
|
65
|
+
|
|
66
|
+
# A trailing path is dropped rather than honoured: every route this client
|
|
67
|
+
# calls is rooted, and silently prefixing them would turn a typo in the
|
|
68
|
+
# address into a 404 on every call.
|
|
69
|
+
rest = rest.split("/", 1)[0]
|
|
70
|
+
host, _, port_text = rest.rpartition(":")
|
|
71
|
+
if not host:
|
|
72
|
+
host, port_text = rest, "443" if tls else "7700"
|
|
73
|
+
try:
|
|
74
|
+
port = int(port_text)
|
|
75
|
+
except ValueError as e:
|
|
76
|
+
raise ValueError(f"`{port_text}` is not a port number, in `{text}`") from e
|
|
77
|
+
|
|
78
|
+
scheme = "https" if tls else "http"
|
|
79
|
+
return Endpoint(url=f"{scheme}://{host}:{port}", host=host, port=port, tls=tls)
|
zygo_sdk/_errors.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""What can go wrong, as types a caller can branch on.
|
|
3
|
+
|
|
4
|
+
The distinctions here are the ones that change what a caller should do next,
|
|
5
|
+
and no others. A handler that raised is not the same as a sandbox that ran out
|
|
6
|
+
of time, which is not the same as a pool that is full — the first is a bug in
|
|
7
|
+
the function, the second is a limit doing its job, and the third is worth
|
|
8
|
+
retrying in a moment. Collapsing them into one exception with a message is how
|
|
9
|
+
a caller ends up parsing English to decide whether to retry.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ZygoError(Exception):
|
|
18
|
+
"""Base class, so ``except zygo_sdk.ZygoError`` catches everything from here."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TransportError(ZygoError):
|
|
22
|
+
"""The API could not be reached, or answered with something unreadable.
|
|
23
|
+
|
|
24
|
+
Never raised because the *sandbox* failed: that always arrives as one of
|
|
25
|
+
the classes below, with the sandbox's own output attached.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuthError(ZygoError):
|
|
30
|
+
"""The token was missing, wrong, or not permitted to do this.
|
|
31
|
+
|
|
32
|
+
A 403 usually means the API was started without ``--allow-deploy`` and the
|
|
33
|
+
call was one that creates or destroys a sandbox.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class NotFound(ZygoError):
|
|
38
|
+
"""No function under that name."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Busy(ZygoError):
|
|
42
|
+
"""The function is at its concurrency limit and refused the request.
|
|
43
|
+
|
|
44
|
+
Not a failure of the call: it is backpressure, and the request never ran.
|
|
45
|
+
Retrying after ``retry_after`` seconds is the intended response.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
message: str,
|
|
51
|
+
*,
|
|
52
|
+
in_flight: int = 0,
|
|
53
|
+
queued: int = 0,
|
|
54
|
+
limit: int = 0,
|
|
55
|
+
retry_after: float = 1.0,
|
|
56
|
+
) -> None:
|
|
57
|
+
super().__init__(message)
|
|
58
|
+
self.in_flight = in_flight
|
|
59
|
+
self.queued = queued
|
|
60
|
+
self.limit = limit
|
|
61
|
+
self.retry_after = retry_after
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Unavailable(ZygoError):
|
|
65
|
+
"""The host cannot do this *yet*: a `503`, and nothing about the request
|
|
66
|
+
needs changing.
|
|
67
|
+
|
|
68
|
+
Three things answer this way. A pool named against a dependency set that
|
|
69
|
+
is still building (``code == "deps_building"``, and the host suggests a
|
|
70
|
+
``retry_after`` of a few seconds); a function whose zygote failed to warm
|
|
71
|
+
(``code == "warm_failed"``); and an API that is draining, which is what
|
|
72
|
+
:meth:`~zygo_sdk.Client.health` raises once the supervisor is stopping.
|
|
73
|
+
|
|
74
|
+
Like :class:`Busy`, the request never ran, so sending it again is safe.
|
|
75
|
+
Unlike :class:`Busy` it is not backpressure: the wait is for something the
|
|
76
|
+
host is doing, not for room.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, message: str, *, code: str = "", retry_after: float = 1.0) -> None:
|
|
80
|
+
super().__init__(message)
|
|
81
|
+
self.code = code
|
|
82
|
+
self.retry_after = retry_after
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Timeout(ZygoError):
|
|
86
|
+
"""The request exceeded the function's timeout and was killed.
|
|
87
|
+
|
|
88
|
+
Zygo's supervisor records that *it* killed the request, rather than
|
|
89
|
+
inferring it: a deadline kill and an out-of-memory kill both surface as
|
|
90
|
+
exit 137, and only the side that enforced the deadline can tell them apart.
|
|
91
|
+
So this is never a guess.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(self, message: str, *, stderr: str = "", metrics: Optional[Dict[str, Any]] = None) -> None:
|
|
95
|
+
super().__init__(message)
|
|
96
|
+
self.stderr = stderr
|
|
97
|
+
self.metrics = metrics or {}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class Stuck(ZygoError):
|
|
101
|
+
"""The sandbox stopped reporting this request, and it was killed.
|
|
102
|
+
|
|
103
|
+
Deliberately not a :class:`Timeout`. A timeout says the work is too slow or
|
|
104
|
+
the limit is too tight, and both are about numbers you chose. This says the
|
|
105
|
+
sandbox went quiet with budget left — an agent that stopped scheduling, a
|
|
106
|
+
child wedged where no signal it can send will reach it — so the thing to
|
|
107
|
+
look at is the function, not its `timeout`.
|
|
108
|
+
|
|
109
|
+
Only reachable for a request long enough to miss a heartbeat. A short one
|
|
110
|
+
that wedges is killed by its own deadline and raises :class:`Timeout`.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
message: str,
|
|
116
|
+
*,
|
|
117
|
+
request_id: str = "",
|
|
118
|
+
stdout: str = "",
|
|
119
|
+
stderr: str = "",
|
|
120
|
+
metrics: Optional[Dict[str, Any]] = None,
|
|
121
|
+
) -> None:
|
|
122
|
+
super().__init__(message)
|
|
123
|
+
self.request_id = request_id
|
|
124
|
+
self.stdout = stdout
|
|
125
|
+
self.stderr = stderr
|
|
126
|
+
self.metrics = metrics or {}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Cancelled(ZygoError):
|
|
130
|
+
"""Somebody stopped this request — usually the caller.
|
|
131
|
+
|
|
132
|
+
The third reading of exit 137. A cancel kill, a deadline kill and an
|
|
133
|
+
out-of-memory kill are one signal and three different things to tell a
|
|
134
|
+
caller, and only the side that sent the signal knows which it was: the
|
|
135
|
+
supervisor does, so this is never a guess either.
|
|
136
|
+
|
|
137
|
+
Distinct from :class:`Timeout` on purpose. A timeout says the work is too
|
|
138
|
+
slow or the limit is too tight; this says the answer stopped being wanted,
|
|
139
|
+
which needs no action at all.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
def __init__(
|
|
143
|
+
self,
|
|
144
|
+
message: str,
|
|
145
|
+
*,
|
|
146
|
+
request_id: str = "",
|
|
147
|
+
stdout: str = "",
|
|
148
|
+
stderr: str = "",
|
|
149
|
+
metrics: Optional[Dict[str, Any]] = None,
|
|
150
|
+
) -> None:
|
|
151
|
+
super().__init__(message)
|
|
152
|
+
self.request_id = request_id
|
|
153
|
+
self.stdout = stdout
|
|
154
|
+
self.stderr = stderr
|
|
155
|
+
self.metrics = metrics or {}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class HandlerError(ZygoError):
|
|
159
|
+
"""The handler raised. The exception text and both streams are attached."""
|
|
160
|
+
|
|
161
|
+
def __init__(
|
|
162
|
+
self,
|
|
163
|
+
message: str,
|
|
164
|
+
*,
|
|
165
|
+
stdout: str = "",
|
|
166
|
+
stderr: str = "",
|
|
167
|
+
exit_code: int = 0,
|
|
168
|
+
metrics: Optional[Dict[str, Any]] = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
super().__init__(message)
|
|
171
|
+
self.stdout = stdout
|
|
172
|
+
self.stderr = stderr
|
|
173
|
+
self.exit_code = exit_code
|
|
174
|
+
self.metrics = metrics or {}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class SpecError(ZygoError):
|
|
178
|
+
"""The sandbox as described could not be resolved.
|
|
179
|
+
|
|
180
|
+
A bad image reference, a limit that contradicts another, a mount that has
|
|
181
|
+
to be absolute and is not. Always a problem with the request, never with
|
|
182
|
+
the host.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def from_response(status: int, body: Dict[str, Any], retry_after: float = 1.0) -> ZygoError:
|
|
187
|
+
"""Map one HTTP answer onto the exception a caller should see.
|
|
188
|
+
|
|
189
|
+
Status first, then the body's ``code`` where the status is ambiguous. The
|
|
190
|
+
fallback carries the status, because an unmapped code is a version skew
|
|
191
|
+
worth reporting rather than swallowing.
|
|
192
|
+
"""
|
|
193
|
+
message = str(body.get("error") or body.get("message") or f"HTTP {status}")
|
|
194
|
+
if status in (401, 403):
|
|
195
|
+
return AuthError(message)
|
|
196
|
+
if status == 404:
|
|
197
|
+
return NotFound(message)
|
|
198
|
+
if status == 408:
|
|
199
|
+
return Timeout(message, stderr=str(body.get("stderr", "")), metrics=body.get("metrics"))
|
|
200
|
+
# 499 is nginx's for a client that went away, and the nearest thing to a
|
|
201
|
+
# registered code for a request the caller stopped.
|
|
202
|
+
if status == 504 or body.get("stuck") is True:
|
|
203
|
+
return Stuck(
|
|
204
|
+
message,
|
|
205
|
+
request_id=str(body.get("request_id", "")),
|
|
206
|
+
stdout=str(body.get("stdout", "")),
|
|
207
|
+
stderr=str(body.get("stderr", "")),
|
|
208
|
+
metrics=body.get("metrics"),
|
|
209
|
+
)
|
|
210
|
+
if status == 499 or body.get("cancelled") is True:
|
|
211
|
+
return Cancelled(
|
|
212
|
+
message,
|
|
213
|
+
request_id=str(body.get("request_id", "")),
|
|
214
|
+
stdout=str(body.get("stdout", "")),
|
|
215
|
+
stderr=str(body.get("stderr", "")),
|
|
216
|
+
metrics=body.get("metrics"),
|
|
217
|
+
)
|
|
218
|
+
if status == 429:
|
|
219
|
+
return Busy(
|
|
220
|
+
message,
|
|
221
|
+
in_flight=int(body.get("in_flight", 0)),
|
|
222
|
+
queued=int(body.get("queued", 0)),
|
|
223
|
+
limit=int(body.get("limit", 0)),
|
|
224
|
+
retry_after=retry_after,
|
|
225
|
+
)
|
|
226
|
+
if status == 400:
|
|
227
|
+
return SpecError(message)
|
|
228
|
+
if status == 503:
|
|
229
|
+
# `/healthz` says `status: stopping` and carries no `error`.
|
|
230
|
+
if "error" not in body and "message" not in body and body.get("status"):
|
|
231
|
+
message = f"the API is {body['status']}"
|
|
232
|
+
return Unavailable(message, code=str(body.get("code", "")), retry_after=retry_after)
|
|
233
|
+
if status == 500 and "exit_code" in body:
|
|
234
|
+
return HandlerError(
|
|
235
|
+
message,
|
|
236
|
+
stdout=str(body.get("stdout", "")),
|
|
237
|
+
stderr=str(body.get("stderr", "")),
|
|
238
|
+
exit_code=int(body.get("exit_code", 1)),
|
|
239
|
+
metrics=body.get("metrics"),
|
|
240
|
+
)
|
|
241
|
+
return ZygoError(f"{message} (HTTP {status})")
|