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 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})")