infimal 0.2.1__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.
- infimal/__init__.py +154 -0
- infimal/_synth.py +347 -0
- infimal/app.py +253 -0
- infimal/client.py +697 -0
- infimal/frame.py +76 -0
- infimal/gateway.py +686 -0
- infimal/harness.py +499 -0
- infimal/models.py +202 -0
- infimal/plan.py +244 -0
- infimal/request.py +766 -0
- infimal/ring.py +294 -0
- infimal/runtime.py +100 -0
- infimal-0.2.1.dist-info/METADATA +152 -0
- infimal-0.2.1.dist-info/RECORD +15 -0
- infimal-0.2.1.dist-info/WHEEL +4 -0
infimal/__init__.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""infimal: snapshot-native serverless GPU endpoints.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
from infimal import App, Latency, Scale
|
|
5
|
+
|
|
6
|
+
app = App("voice", perf=Latency(p95_ms=300), scale=Scale(max_replicas=4))
|
|
7
|
+
|
|
8
|
+
@app.setup
|
|
9
|
+
def load():
|
|
10
|
+
return load_model() # runs once, on a builder GPU, then frozen into a snapshot
|
|
11
|
+
|
|
12
|
+
@app.handler
|
|
13
|
+
def transcribe(model, request):
|
|
14
|
+
return model(request["audio"])
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`infimal apps deploy` gives you an HTTPS endpoint that scales to zero and comes back from that
|
|
18
|
+
snapshot instead of a cold load. You never name a GPU: declare a latency or throughput target, or a
|
|
19
|
+
tier, and the platform picks the silicon.
|
|
20
|
+
|
|
21
|
+
Calling a model that is already served is the client:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from infimal import Client
|
|
25
|
+
|
|
26
|
+
client = Client.from_env()
|
|
27
|
+
print(client.model("qwen3-8b").chat("Explain this code.").max_tokens(128).run().text)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The client names (`Client`, `InfimalError`, `Job`, `Request`, `ChatResult` and the rest) are
|
|
31
|
+
imported on first use, not by `import infimal`: an App module is imported inside the serving
|
|
32
|
+
container by the harness, and that import must not pull in the HTTP stack.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from typing import TYPE_CHECKING, Any
|
|
36
|
+
|
|
37
|
+
from .app import App, SnapshotBoundaryError, discover
|
|
38
|
+
from .gateway import Credential, Flat, Gateway, Model, PerToken, Provider, Source, Wire
|
|
39
|
+
from .models import (
|
|
40
|
+
Batch,
|
|
41
|
+
Deployment,
|
|
42
|
+
Endpoint,
|
|
43
|
+
Engine,
|
|
44
|
+
Latency,
|
|
45
|
+
Reservation,
|
|
46
|
+
Scale,
|
|
47
|
+
Throughput,
|
|
48
|
+
Tier,
|
|
49
|
+
TrafficEnvelope,
|
|
50
|
+
)
|
|
51
|
+
from .ring import Frame, FrameKind, Ring, RingError, RingFull
|
|
52
|
+
from .runtime import (
|
|
53
|
+
is_build,
|
|
54
|
+
production_vram_bytes,
|
|
55
|
+
snapshot_vram_bytes,
|
|
56
|
+
snapshot_vram_fraction,
|
|
57
|
+
target_vram_bytes,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if TYPE_CHECKING:
|
|
61
|
+
from .client import Client, InfimalError, InfimalUnreachable
|
|
62
|
+
from .request import (
|
|
63
|
+
Artifact,
|
|
64
|
+
Capabilities,
|
|
65
|
+
ChatResult,
|
|
66
|
+
Job,
|
|
67
|
+
JobResult,
|
|
68
|
+
LoraSpec,
|
|
69
|
+
ModelHandle,
|
|
70
|
+
Request,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
__version__ = "0.2.1"
|
|
74
|
+
|
|
75
|
+
#: The client surface, by the module that defines it. Loaded lazily (PEP 562) so that
|
|
76
|
+
#: `import infimal` inside a container never imports httpx. `GlowError` and `GlowUnreachable` are
|
|
77
|
+
#: the pre-rename names of the two errors, resolvable in 0.2 only and left out of `__all__`.
|
|
78
|
+
_LAZY = {
|
|
79
|
+
"Client": "client",
|
|
80
|
+
"InfimalError": "client",
|
|
81
|
+
"InfimalUnreachable": "client",
|
|
82
|
+
"GlowError": "client",
|
|
83
|
+
"GlowUnreachable": "client",
|
|
84
|
+
"ModelHandle": "request",
|
|
85
|
+
"Request": "request",
|
|
86
|
+
"ChatResult": "request",
|
|
87
|
+
"JobResult": "request",
|
|
88
|
+
"Job": "request",
|
|
89
|
+
"Artifact": "request",
|
|
90
|
+
"Capabilities": "request",
|
|
91
|
+
"LoraSpec": "request",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def __getattr__(name: str) -> Any:
|
|
96
|
+
module = _LAZY.get(name)
|
|
97
|
+
if module is None:
|
|
98
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
99
|
+
from importlib import import_module
|
|
100
|
+
|
|
101
|
+
value = getattr(import_module(f".{module}", __name__), name)
|
|
102
|
+
globals()[name] = value
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def __dir__() -> list[str]:
|
|
107
|
+
return sorted(set(globals()) | set(_LAZY))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = [
|
|
111
|
+
"App",
|
|
112
|
+
"Artifact",
|
|
113
|
+
"Batch",
|
|
114
|
+
"Capabilities",
|
|
115
|
+
"ChatResult",
|
|
116
|
+
"Client",
|
|
117
|
+
"Credential",
|
|
118
|
+
"Deployment",
|
|
119
|
+
"Endpoint",
|
|
120
|
+
"Engine",
|
|
121
|
+
"Flat",
|
|
122
|
+
"Frame",
|
|
123
|
+
"FrameKind",
|
|
124
|
+
"Gateway",
|
|
125
|
+
"InfimalError",
|
|
126
|
+
"InfimalUnreachable",
|
|
127
|
+
"Job",
|
|
128
|
+
"JobResult",
|
|
129
|
+
"Latency",
|
|
130
|
+
"LoraSpec",
|
|
131
|
+
"Model",
|
|
132
|
+
"ModelHandle",
|
|
133
|
+
"PerToken",
|
|
134
|
+
"Provider",
|
|
135
|
+
"Request",
|
|
136
|
+
"Reservation",
|
|
137
|
+
"Ring",
|
|
138
|
+
"RingError",
|
|
139
|
+
"RingFull",
|
|
140
|
+
"Scale",
|
|
141
|
+
"SnapshotBoundaryError",
|
|
142
|
+
"Source",
|
|
143
|
+
"Throughput",
|
|
144
|
+
"Tier",
|
|
145
|
+
"TrafficEnvelope",
|
|
146
|
+
"Wire",
|
|
147
|
+
"__version__",
|
|
148
|
+
"discover",
|
|
149
|
+
"is_build",
|
|
150
|
+
"production_vram_bytes",
|
|
151
|
+
"snapshot_vram_bytes",
|
|
152
|
+
"snapshot_vram_fraction",
|
|
153
|
+
"target_vram_bytes",
|
|
154
|
+
]
|
infimal/_synth.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""`python -m infimal._synth`: the SDK's half of `infimal apps plan`, `apply` and `deploy`.
|
|
2
|
+
|
|
3
|
+
Private. The native `infimal` binary is the one customer CLI (D361); an App's topology is Python, and
|
|
4
|
+
the only way to know what a module declares is to execute it. So the binary runs this module as a
|
|
5
|
+
subprocess: one JSON request on stdin, one JSON reply on stdout. Nothing else calls it, and its shape
|
|
6
|
+
is versioned by the `synth` field rather than promised to anyone else. The contract is written down
|
|
7
|
+
in `docs/sdk.md`, "The synth contract".
|
|
8
|
+
|
|
9
|
+
It never opens a connection and reads no credential. Everything that crosses the wire is the
|
|
10
|
+
binary's: it fetches the live app between the two operations, sends the deploy or the configuration
|
|
11
|
+
this module computed, and renders the answer. What stays here is what has to be Python — executing
|
|
12
|
+
the customer's module — and what is Python today and is reused rather than ported: the diff engine
|
|
13
|
+
(`infimal.plan`) and the deploy body (`infimal.client.deploy_body`).
|
|
14
|
+
|
|
15
|
+
Two operations:
|
|
16
|
+
|
|
17
|
+
{"synth": 1, "op": "declare", "module": "main", "app": null}
|
|
18
|
+
executes the module (from the working directory) and returns the App's declaration, the
|
|
19
|
+
source digest, a description to print, and the deploy request.
|
|
20
|
+
|
|
21
|
+
{"synth": 1, "op": "diff", "app": "voice", "declaration": {…}, "source_digest": "sha256:…",
|
|
22
|
+
"live": null | <GET /v1/apps/{app}>}
|
|
23
|
+
diffs a declaration against the live app without executing anything, and returns the plan,
|
|
24
|
+
its lines, and the request `apply` sends (null when there is nothing to change).
|
|
25
|
+
|
|
26
|
+
Two more, for `infimal gateway plan` and `apply` (`infimal.gateway`):
|
|
27
|
+
|
|
28
|
+
{"synth": 1, "op": "gateway.declare", "module": "gateway", "gateway": null}
|
|
29
|
+
executes the module and returns the Gateway's declaration, the credentials it names as
|
|
30
|
+
`{name, env}` (the binary reads the variables; this module never does), a summary, and the
|
|
31
|
+
`PUT /v1/gateway` request.
|
|
32
|
+
{"synth": 1, "op": "gateway.diff", "declaration": {…}, "live": null | <GET /v1/gateway>}
|
|
33
|
+
diffs the declaration against the live gateway and returns the plan, its lines, and the
|
|
34
|
+
request `apply` sends (null when nothing changed).
|
|
35
|
+
|
|
36
|
+
Every reply carries `"synth": 1` and `"ok"`; a failure is `{"ok": false, "error", "message",
|
|
37
|
+
"next"}`, the API's envelope, so the binary shows it the way it shows a refusal. The exit status
|
|
38
|
+
is 0 with a reply that is ok, 1 with a failure reply, and 2 when the request itself could not be
|
|
39
|
+
read. What the customer's module prints while it is imported goes to stderr: stdout is the reply
|
|
40
|
+
and nothing else.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import contextlib
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
import sys
|
|
49
|
+
from dataclasses import asdict
|
|
50
|
+
from typing import Any
|
|
51
|
+
from urllib.parse import quote
|
|
52
|
+
|
|
53
|
+
#: The contract's version. The binary refuses a reply that does not carry the one it speaks.
|
|
54
|
+
VERSION = 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Refusal(Exception):
|
|
58
|
+
"""A failure with the envelope the binary prints: a code, a sentence and the next step."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, error: str, message: str, next: str):
|
|
61
|
+
super().__init__(message)
|
|
62
|
+
self.error = error
|
|
63
|
+
self.message = message
|
|
64
|
+
self.next = next
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _path(*segments: str) -> str:
|
|
68
|
+
return "/" + "/".join(quote(str(s), safe="") for s in segments)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _summary(deployment: Any) -> list[str]:
|
|
72
|
+
"""What the module declares, in the lines `infimal apps plan` prints above the diff."""
|
|
73
|
+
lines = [f"app: {deployment.name}", f" tier {deployment.tier.value}"]
|
|
74
|
+
if deployment.perf is not None:
|
|
75
|
+
target = deployment.perf.model_dump()
|
|
76
|
+
target.pop("kind", None)
|
|
77
|
+
shown = ", ".join(f"{k}={v}" for k, v in target.items())
|
|
78
|
+
lines.append(f" perf {deployment.perf.kind}({shown})")
|
|
79
|
+
scale = deployment.scale
|
|
80
|
+
lines.append(
|
|
81
|
+
f" scale {scale.min_replicas}..{scale.max_replicas}, "
|
|
82
|
+
f"target queue {scale.target_queue_ms} ms"
|
|
83
|
+
)
|
|
84
|
+
lines.append(
|
|
85
|
+
f" residency park after {scale.idle_park_ms / 1000:.0f}s idle, "
|
|
86
|
+
f"checkpoint after {scale.parked_checkpoint_ms / 1000:.0f}s parked"
|
|
87
|
+
)
|
|
88
|
+
if deployment.batch.enabled:
|
|
89
|
+
lines.append(
|
|
90
|
+
f" batching up to {deployment.batch.max_size} in {deployment.batch.window_ms} ms"
|
|
91
|
+
)
|
|
92
|
+
if deployment.reservation is not None:
|
|
93
|
+
lines.append(
|
|
94
|
+
f" reservation snapshot {deployment.reservation.snapshot_gb:.0f} GB, "
|
|
95
|
+
f"production {deployment.reservation.production_gb:.0f} GB"
|
|
96
|
+
)
|
|
97
|
+
return lines
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _load(module: str, app_name: str | None) -> Any:
|
|
101
|
+
"""Execute the customer's module from the working directory and return its App, validated."""
|
|
102
|
+
from .harness import load_app
|
|
103
|
+
|
|
104
|
+
sys.path.insert(0, os.getcwd())
|
|
105
|
+
try:
|
|
106
|
+
# The module's own output goes to stderr: stdout carries the reply and nothing else.
|
|
107
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
108
|
+
app = load_app(module, app_name)
|
|
109
|
+
except ModuleNotFoundError as exc:
|
|
110
|
+
if exc.name and (exc.name == module or module.startswith(exc.name + ".")):
|
|
111
|
+
raise Refusal(
|
|
112
|
+
"module_not_found",
|
|
113
|
+
f"no module named {module!r} in {os.getcwd()}",
|
|
114
|
+
"Run from the directory that contains it, and name it as an import path "
|
|
115
|
+
"(services.voice), not as a file (services/voice.py).",
|
|
116
|
+
) from None
|
|
117
|
+
raise Refusal(
|
|
118
|
+
"module_failed",
|
|
119
|
+
f"importing {module!r} failed: ModuleNotFoundError: {exc}",
|
|
120
|
+
"Install the module's dependencies in the Python environment that runs the SDK.",
|
|
121
|
+
) from None
|
|
122
|
+
except SystemExit as exc:
|
|
123
|
+
if not isinstance(exc.code, str):
|
|
124
|
+
raise Refusal(
|
|
125
|
+
"module_failed",
|
|
126
|
+
f"importing {module!r} exited with status {exc.code!r}",
|
|
127
|
+
"Remove the exit from the module's import path: planning executes it.",
|
|
128
|
+
) from None
|
|
129
|
+
# `load_app` names what it did not find: no App at all, no App of that name, or several.
|
|
130
|
+
raise Refusal(
|
|
131
|
+
"app_not_found",
|
|
132
|
+
str(exc.code),
|
|
133
|
+
"Declare one infimal.App in the module, or pass --app NAME to choose one.",
|
|
134
|
+
) from None
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
raise Refusal(
|
|
137
|
+
"module_failed",
|
|
138
|
+
f"importing {module!r} failed: {type(exc).__name__}: {exc}",
|
|
139
|
+
"Fix the module: planning executes it, so its imports must be safe to run.",
|
|
140
|
+
) from None
|
|
141
|
+
try:
|
|
142
|
+
app.validate()
|
|
143
|
+
except ValueError as exc:
|
|
144
|
+
raise Refusal("invalid_app", str(exc), "Fix the App declaration, then plan again.") from None
|
|
145
|
+
return app
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def declare(request: dict[str, Any]) -> dict[str, Any]:
|
|
149
|
+
from .client import deploy_body
|
|
150
|
+
from .plan import source_digest
|
|
151
|
+
|
|
152
|
+
module = request.get("module") or "main"
|
|
153
|
+
app = _load(str(module), request.get("app") or None)
|
|
154
|
+
wire = app.deployment.to_wire()
|
|
155
|
+
digest = source_digest(os.getcwd())
|
|
156
|
+
body = deploy_body(wire, tier=wire["tier"], region=wire.get("region"), source_digest=digest)
|
|
157
|
+
return {
|
|
158
|
+
"app": app.name,
|
|
159
|
+
"declaration": wire,
|
|
160
|
+
"source_digest": digest,
|
|
161
|
+
"summary": _summary(app.deployment),
|
|
162
|
+
"deploy": {"method": "POST", "path": _path("v1", "apps", app.name, "deploy"), "body": body},
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def plan_data(plan: Any) -> dict[str, Any]:
|
|
167
|
+
"""The plan as JSON: the dataclass, and the three properties a script branches on."""
|
|
168
|
+
return {
|
|
169
|
+
**asdict(plan),
|
|
170
|
+
"kind": plan.kind.value,
|
|
171
|
+
"requires_rebuild": plan.requires_rebuild,
|
|
172
|
+
"keeps_snapshot": plan.keeps_snapshot,
|
|
173
|
+
"keeps_rate_lock": plan.keeps_rate_lock,
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def diff(request: dict[str, Any]) -> dict[str, Any]:
|
|
178
|
+
from .client import deploy_body
|
|
179
|
+
from .plan import ChangeKind, diff_wire, render
|
|
180
|
+
|
|
181
|
+
app = request.get("app")
|
|
182
|
+
wire = request.get("declaration")
|
|
183
|
+
if not isinstance(app, str) or not app or not isinstance(wire, dict):
|
|
184
|
+
raise Refusal(
|
|
185
|
+
"invalid_request",
|
|
186
|
+
"diff needs the app name and the declaration that declare returned",
|
|
187
|
+
"Upgrade the infimal binary and the SDK together.",
|
|
188
|
+
)
|
|
189
|
+
live = request.get("live")
|
|
190
|
+
digest = request.get("source_digest")
|
|
191
|
+
plan = diff_wire(app, wire, live if isinstance(live, dict) else None, local_source_digest=digest)
|
|
192
|
+
apply: dict[str, Any] | None = None
|
|
193
|
+
if plan.kind is not ChangeKind.NOOP:
|
|
194
|
+
if plan.requires_rebuild:
|
|
195
|
+
body = deploy_body(wire, tier=wire.get("tier"), region=wire.get("region"),
|
|
196
|
+
source_digest=digest)
|
|
197
|
+
apply = {"method": "POST", "path": _path("v1", "apps", app, "deploy"), "body": body}
|
|
198
|
+
else:
|
|
199
|
+
# A configuration merges onto the saved row: provided fields replace, omitted ones are
|
|
200
|
+
# kept (D327). So a target or a region the module no longer declares is sent as an
|
|
201
|
+
# explicit null, which is the only way to clear it.
|
|
202
|
+
body = dict(wire)
|
|
203
|
+
for change in plan.changes:
|
|
204
|
+
if change.after is None and change.field in ("perf", "region"):
|
|
205
|
+
body["slo" if change.field == "perf" else "region"] = None
|
|
206
|
+
apply = {"method": "PUT", "path": _path("v1", "apps", app, "config"), "body": body}
|
|
207
|
+
return {"plan": plan_data(plan), "lines": render(plan), "apply": apply}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _load_gateway(module: str, namespace: str | None) -> Any:
|
|
211
|
+
"""Execute the customer's module and return the `Gateway` it declares, validated on construction."""
|
|
212
|
+
from importlib import import_module
|
|
213
|
+
|
|
214
|
+
from .gateway import discover
|
|
215
|
+
|
|
216
|
+
sys.path.insert(0, os.getcwd())
|
|
217
|
+
try:
|
|
218
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
219
|
+
loaded = import_module(module)
|
|
220
|
+
except ModuleNotFoundError as exc:
|
|
221
|
+
if exc.name and (exc.name == module or module.startswith(exc.name + ".")):
|
|
222
|
+
raise Refusal(
|
|
223
|
+
"module_not_found",
|
|
224
|
+
f"no module named {module!r} in {os.getcwd()}",
|
|
225
|
+
"Run from the directory that contains it, and name it as an import path "
|
|
226
|
+
"(gateway), not as a file (gateway.py).",
|
|
227
|
+
) from None
|
|
228
|
+
raise Refusal(
|
|
229
|
+
"module_failed",
|
|
230
|
+
f"importing {module!r} failed: ModuleNotFoundError: {exc}",
|
|
231
|
+
"Install the module's dependencies in the Python environment that runs the SDK.",
|
|
232
|
+
) from None
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
# A pydantic refusal inside the module is the declaration being invalid, not the import.
|
|
235
|
+
name = type(exc).__name__
|
|
236
|
+
error = "invalid_gateway" if name == "ValidationError" else "module_failed"
|
|
237
|
+
raise Refusal(
|
|
238
|
+
error,
|
|
239
|
+
f"importing {module!r} failed: {name}: {exc}",
|
|
240
|
+
"Fix the Gateway declaration, then plan again." if error == "invalid_gateway"
|
|
241
|
+
else "Fix the module: planning executes it, so its imports must be safe to run.",
|
|
242
|
+
) from None
|
|
243
|
+
try:
|
|
244
|
+
return discover(loaded, namespace)
|
|
245
|
+
except LookupError as exc:
|
|
246
|
+
raise Refusal(
|
|
247
|
+
"gateway_not_found",
|
|
248
|
+
str(exc),
|
|
249
|
+
"Declare one infimal.Gateway in the module, or pass --gateway NAMESPACE to choose one.",
|
|
250
|
+
) from None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def gateway_declare(request: dict[str, Any]) -> dict[str, Any]:
|
|
254
|
+
"""Execute the module and return the declaration, its credentials by variable name, and the apply request.
|
|
255
|
+
|
|
256
|
+
The declaration carries no credential value. `credentials` lists `{name, env}`; the binary reads
|
|
257
|
+
each variable and sends `{"name", "key"}` under `credentials` in the body, refusing before any
|
|
258
|
+
request when one is unset.
|
|
259
|
+
"""
|
|
260
|
+
module = request.get("module") or "gateway"
|
|
261
|
+
gateway = _load_gateway(str(module), request.get("gateway") or None)
|
|
262
|
+
wire = gateway.to_wire()
|
|
263
|
+
return {
|
|
264
|
+
"gateway": gateway.namespace,
|
|
265
|
+
"declaration": wire,
|
|
266
|
+
"credentials": wire["credentials"],
|
|
267
|
+
"summary": gateway.summary(),
|
|
268
|
+
"apply": {"method": "PUT", "path": _path("v1", "gateway"), "body": wire},
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def gateway_diff(request: dict[str, Any]) -> dict[str, Any]:
|
|
273
|
+
"""Diff a declaration against `GET /v1/gateway` without executing anything."""
|
|
274
|
+
from .gateway import GatewayChangeKind, diff_wire, render
|
|
275
|
+
|
|
276
|
+
wire = request.get("declaration")
|
|
277
|
+
if not isinstance(wire, dict) or not isinstance(wire.get("namespace"), str):
|
|
278
|
+
raise Refusal(
|
|
279
|
+
"invalid_request",
|
|
280
|
+
"gateway.diff needs the declaration that gateway.declare returned",
|
|
281
|
+
"Upgrade the infimal binary and the SDK together.",
|
|
282
|
+
)
|
|
283
|
+
live = request.get("live")
|
|
284
|
+
plan = diff_wire(wire, live if isinstance(live, dict) else None)
|
|
285
|
+
apply: dict[str, Any] | None = None
|
|
286
|
+
if plan.kind is not GatewayChangeKind.NOOP:
|
|
287
|
+
apply = {"method": "PUT", "path": _path("v1", "gateway"), "body": wire}
|
|
288
|
+
return {
|
|
289
|
+
"plan": {
|
|
290
|
+
**asdict(plan),
|
|
291
|
+
"kind": plan.kind.value,
|
|
292
|
+
"changes": [asdict(c) for c in plan.changes],
|
|
293
|
+
},
|
|
294
|
+
"lines": render(plan),
|
|
295
|
+
"apply": apply,
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
OPERATIONS = {
|
|
300
|
+
"declare": declare,
|
|
301
|
+
"diff": diff,
|
|
302
|
+
"gateway.declare": gateway_declare,
|
|
303
|
+
"gateway.diff": gateway_diff,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def run(raw: str) -> tuple[dict[str, Any], int]:
|
|
308
|
+
"""One request in, one reply and an exit status out."""
|
|
309
|
+
try:
|
|
310
|
+
request = json.loads(raw)
|
|
311
|
+
except json.JSONDecodeError as exc:
|
|
312
|
+
return _failure("invalid_request", f"the request is not JSON: {exc}"), 2
|
|
313
|
+
if not isinstance(request, dict) or request.get("synth") != VERSION:
|
|
314
|
+
return _failure(
|
|
315
|
+
"version_mismatch",
|
|
316
|
+
f"this SDK speaks synth {VERSION}; the request asked for {request.get('synth') if isinstance(request, dict) else None!r}",
|
|
317
|
+
), 2
|
|
318
|
+
operation = OPERATIONS.get(str(request.get("op")))
|
|
319
|
+
if operation is None:
|
|
320
|
+
return _failure("invalid_request", f"unknown operation {request.get('op')!r}"), 2
|
|
321
|
+
try:
|
|
322
|
+
reply = operation(request)
|
|
323
|
+
except Refusal as exc:
|
|
324
|
+
return {"synth": VERSION, "ok": False, "error": exc.error, "message": exc.message,
|
|
325
|
+
"next": exc.next}, 1
|
|
326
|
+
return {"synth": VERSION, "ok": True, "op": request["op"], **reply}, 0
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _failure(error: str, message: str) -> dict[str, Any]:
|
|
330
|
+
return {
|
|
331
|
+
"synth": VERSION,
|
|
332
|
+
"ok": False,
|
|
333
|
+
"error": error,
|
|
334
|
+
"message": message,
|
|
335
|
+
"next": "Install the SDK that matches this infimal binary; they are released together.",
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def main() -> int:
|
|
340
|
+
reply, status = run(sys.stdin.read())
|
|
341
|
+
sys.stdout.write(json.dumps(reply, default=str) + "\n")
|
|
342
|
+
sys.stdout.flush()
|
|
343
|
+
return status
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
if __name__ == "__main__":
|
|
347
|
+
raise SystemExit(main())
|