lobole 0.1.0__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.
- lobole/__init__.py +27 -0
- lobole/client.py +267 -0
- lobole/components.py +156 -0
- lobole/contracts.py +181 -0
- lobole/errors.py +129 -0
- lobole/inspection.py +90 -0
- lobole/mcp.py +2266 -0
- lobole/py.typed +0 -0
- lobole/replay.py +59 -0
- lobole/rpc.py +3687 -0
- lobole/sdk.py +3744 -0
- lobole/snapshots.py +81 -0
- lobole/validation.py +89 -0
- lobole-0.1.0.dist-info/METADATA +9 -0
- lobole-0.1.0.dist-info/RECORD +19 -0
- lobole-0.1.0.dist-info/WHEEL +5 -0
- lobole-0.1.0.dist-info/entry_points.txt +3 -0
- lobole-0.1.0.dist-info/licenses/LICENSE +661 -0
- lobole-0.1.0.dist-info/top_level.txt +1 -0
lobole/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Elias Högbom Aronsson and Loböle contributors
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
"""Drive a running Loböle instance from Python (docs/scripting/README.md).
|
|
5
|
+
|
|
6
|
+
from lobole import connect
|
|
7
|
+
app = connect() # the one running instance, or port=
|
|
8
|
+
part = app.part("bracket")
|
|
9
|
+
|
|
10
|
+
`Client` is the plain JSON-RPC client (dict in, dict out); `App` is the SDK
|
|
11
|
+
over it, with the whole RPC surface generated from the running app's
|
|
12
|
+
`describe` and geometry handles instead of transcribed numbers. Every error
|
|
13
|
+
either raises is a `LoboleError`; a refused call is a `Refused`
|
|
14
|
+
(lobole.errors).
|
|
15
|
+
"""
|
|
16
|
+
from lobole.client import Client
|
|
17
|
+
from lobole.errors import (BudgetExceeded, ConnectionLost, InternalError,
|
|
18
|
+
InvalidArgument, LoboleError, MissingArgument,
|
|
19
|
+
NonFiniteError, Refused, SdkError, StaleRevision,
|
|
20
|
+
StaleSnapshot, UnknownArgument)
|
|
21
|
+
from lobole.sdk import App, connect
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"App", "BudgetExceeded", "Client", "ConnectionLost", "InternalError",
|
|
25
|
+
"InvalidArgument", "LoboleError", "MissingArgument", "NonFiniteError",
|
|
26
|
+
"Refused", "SdkError", "StaleRevision", "StaleSnapshot",
|
|
27
|
+
"UnknownArgument", "connect"]
|
lobole/client.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-FileCopyrightText: Elias Högbom Aronsson and Loböle contributors
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
"""Remote-control client for Loböle (start the app with --remote <port>).
|
|
6
|
+
|
|
7
|
+
Library:
|
|
8
|
+
from lobole.client import Client
|
|
9
|
+
d = Client(8437)
|
|
10
|
+
d.call("add_feature", feature={"type": "Box", "name": "Box 1",
|
|
11
|
+
"on": "Top",
|
|
12
|
+
"width": 2, "depth": 2, "height": 2})
|
|
13
|
+
print(d.call("get_state"))
|
|
14
|
+
|
|
15
|
+
CLI:
|
|
16
|
+
python3 -m lobole.client <port> <method> ['<params-json>']
|
|
17
|
+
python3 -m lobole.client 8437 get_state
|
|
18
|
+
python3 -m lobole.client 8437 menu_click '{"path": "File/New"}'
|
|
19
|
+
python3 -m lobole.client 8437 screenshot '{"path": "/tmp/shot.png"}'
|
|
20
|
+
"""
|
|
21
|
+
import json
|
|
22
|
+
import socket
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
from lobole.errors import (ConnectionLost, LoboleError, NonFiniteError,
|
|
27
|
+
Refused, UnknownArgument, refusal)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _check_result(method, result):
|
|
31
|
+
"""A strict client holds an answer to its row's typed `returns` column
|
|
32
|
+
(docs/scripting/rpc.md#typed-results): every smoke runs strict, so a
|
|
33
|
+
key the handler adds, drops or retypes without its row fails the test
|
|
34
|
+
that saw it, instead of reaching a caller as a KeyError."""
|
|
35
|
+
from lobole.rpc import RESULT_SCHEMAS
|
|
36
|
+
schema = RESULT_SCHEMAS.get(method)
|
|
37
|
+
if schema is None:
|
|
38
|
+
return
|
|
39
|
+
from lobole.contracts import validate
|
|
40
|
+
try:
|
|
41
|
+
validate(schema, result, method, "/result")
|
|
42
|
+
except Refused as e:
|
|
43
|
+
raise LoboleError(f"{method}: the answer breaks its returns "
|
|
44
|
+
f"contract — {e}") from None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Client:
|
|
48
|
+
"""`strict`: an answer holding a non-finite number raises NonFiniteError
|
|
49
|
+
instead of warning. Unset, it follows LOBOLE_REMOTE_STRICT=1, which
|
|
50
|
+
tests/smoke_app.py sets, so every smoke runs strict."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, port, host="127.0.0.1", timeout=60.0, strict=None):
|
|
53
|
+
self.sock = socket.create_connection((host, port), timeout=timeout)
|
|
54
|
+
self.file = self.sock.makefile("rw")
|
|
55
|
+
self._id = 0
|
|
56
|
+
if strict is None:
|
|
57
|
+
import os
|
|
58
|
+
strict = os.environ.get("LOBOLE_REMOTE_STRICT") == "1"
|
|
59
|
+
self.strict = strict
|
|
60
|
+
|
|
61
|
+
# `method` is POSITIONAL-ONLY, so an RPC with a parameter of that name —
|
|
62
|
+
# `describe {method}` — is reachable as call("describe", method="x"). It
|
|
63
|
+
# used to be an ordinary argument, the keyword collided with it, and the
|
|
64
|
+
# workaround was to know to use request() instead.
|
|
65
|
+
def call(self, method, /, **params):
|
|
66
|
+
return self.request(method, params)
|
|
67
|
+
|
|
68
|
+
def help(self, method=""):
|
|
69
|
+
"""What `method` does and takes — `method_help` below, the same
|
|
70
|
+
answer the MCP server's help() gives. With no method, the index.
|
|
71
|
+
`call("help", method=...)` and `request("help", ...)` reach it too:
|
|
72
|
+
the MCP instructions tell an agent to ask help(), and over this
|
|
73
|
+
client that used to be "unknown method"."""
|
|
74
|
+
return method_help(self.call, method)
|
|
75
|
+
|
|
76
|
+
# Params as a DICT rather than keywords.
|
|
77
|
+
def request(self, method, params=None, timeout=None):
|
|
78
|
+
"""`timeout` (seconds) overrides the connection default for THIS
|
|
79
|
+
call. A modal solve on a fine mesh outlived the 60 s default, the
|
|
80
|
+
client died mid-call, and the server finished anyway — which reads
|
|
81
|
+
as a hang followed by a mystery. mesh_study/run_study want a long
|
|
82
|
+
one (or run_study {async: true} + study_status polling)."""
|
|
83
|
+
# `help` is answered here, from `describe`, rather than being a
|
|
84
|
+
# second copy of the method table in the app (method_help).
|
|
85
|
+
if method == "help":
|
|
86
|
+
extra = sorted(set(params or {}) - {"method"})
|
|
87
|
+
if extra:
|
|
88
|
+
raise UnknownArgument(
|
|
89
|
+
f"unknown parameter(s) {extra} for 'help'. Accepted: "
|
|
90
|
+
"method",
|
|
91
|
+
{"operation": "help", "code": "unknown_argument",
|
|
92
|
+
"path": f"/params/{extra[0]}", "param": extra[0],
|
|
93
|
+
"expected": ["method"],
|
|
94
|
+
"observed": (params or {})[extra[0]],
|
|
95
|
+
"accepted": ["method"]})
|
|
96
|
+
return self.help((params or {}).get("method", ""))
|
|
97
|
+
self._id += 1
|
|
98
|
+
request = {"id": self._id, "method": method, "params": {} if params is None else params}
|
|
99
|
+
previous = self.sock.gettimeout()
|
|
100
|
+
if timeout is not None:
|
|
101
|
+
self.sock.settimeout(timeout)
|
|
102
|
+
try:
|
|
103
|
+
self.file.write(json.dumps(request, allow_nan=False) + "\n")
|
|
104
|
+
self.file.flush()
|
|
105
|
+
while True:
|
|
106
|
+
line = self.file.readline()
|
|
107
|
+
if not line:
|
|
108
|
+
raise ConnectionLost(f"{method}: the app disconnected "
|
|
109
|
+
"before answering")
|
|
110
|
+
msg = json.loads(line)
|
|
111
|
+
# A line the server could not parse is answered with a null
|
|
112
|
+
# id (it cannot know ours); with one request in flight it is
|
|
113
|
+
# this one's, and waiting for our id would wait forever.
|
|
114
|
+
if msg.get("id") is None and str(
|
|
115
|
+
msg.get("error", "")).startswith("parse:"):
|
|
116
|
+
raise refusal(msg["error"], msg.get("diagnostic"))
|
|
117
|
+
if msg.get("id") == self._id:
|
|
118
|
+
if "nonfinite" in msg:
|
|
119
|
+
said = (f"{method}: the answer held non-finite "
|
|
120
|
+
f"numbers, sent as null, at "
|
|
121
|
+
f"{', '.join(msg['nonfinite'][:5])}")
|
|
122
|
+
if self.strict:
|
|
123
|
+
raise NonFiniteError(said, msg["nonfinite"],
|
|
124
|
+
msg.get("result"))
|
|
125
|
+
import warnings
|
|
126
|
+
warnings.warn(said, RuntimeWarning, stacklevel=3)
|
|
127
|
+
if "error" in msg:
|
|
128
|
+
raise refusal(msg["error"], msg.get("diagnostic"))
|
|
129
|
+
if self.strict:
|
|
130
|
+
_check_result(method, msg.get("result"))
|
|
131
|
+
return msg.get("result")
|
|
132
|
+
finally:
|
|
133
|
+
if timeout is not None:
|
|
134
|
+
self.sock.settimeout(previous)
|
|
135
|
+
|
|
136
|
+
def close(self):
|
|
137
|
+
# makefile() dups the socket fd — close BOTH, or the server never
|
|
138
|
+
# sees EOF and keeps serving a ghost client.
|
|
139
|
+
#
|
|
140
|
+
# The server accepts up to EIGHT clients (RemoteServer.cpp's
|
|
141
|
+
# runaway-connection backstop) and drains them through ONE main-thread
|
|
142
|
+
# queue, so it is execution that is serialized, not connection. An
|
|
143
|
+
# earlier note here said it handled one client at a time; it does not,
|
|
144
|
+
# and several agents driving several instances is the point of
|
|
145
|
+
# docs/scripting/README.md#fan-out-several-agents-several-parts-one-project.
|
|
146
|
+
self.file.close()
|
|
147
|
+
self.sock.close()
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def instances():
|
|
151
|
+
"""Running instances that announced themselves, newest first.
|
|
152
|
+
|
|
153
|
+
Each is {"pid", "port", "mode": "gui"|"headless"|"offscreen",
|
|
154
|
+
"started"}. The open project is not here — it changes while the
|
|
155
|
+
instance runs; `ping` reports it. The app
|
|
156
|
+
writes one file per open remote port under
|
|
157
|
+
$XDG_STATE_HOME/lobole/instances (see src/remote/InstanceRegistry.h);
|
|
158
|
+
a crash leaves the file behind, so the pid is the truth. Entries whose
|
|
159
|
+
process is gone are skipped, and removed when we own them.
|
|
160
|
+
"""
|
|
161
|
+
import os
|
|
162
|
+
import pathlib
|
|
163
|
+
state = os.environ.get("XDG_STATE_HOME") or os.path.expanduser(
|
|
164
|
+
"~/.local/state")
|
|
165
|
+
directory = pathlib.Path(state) / "lobole" / "instances"
|
|
166
|
+
found = []
|
|
167
|
+
for path in sorted(directory.glob("*.json")) if (
|
|
168
|
+
directory.is_dir()) else []:
|
|
169
|
+
try:
|
|
170
|
+
info = json.loads(path.read_text())
|
|
171
|
+
pid = int(info.get("pid", 0))
|
|
172
|
+
os.kill(pid, 0) # liveness, not a signal
|
|
173
|
+
except PermissionError:
|
|
174
|
+
pass # someone else's process: alive, just not ours to poke
|
|
175
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
176
|
+
try:
|
|
177
|
+
path.unlink() # stale or unreadable: it announces nothing
|
|
178
|
+
except OSError:
|
|
179
|
+
pass
|
|
180
|
+
continue
|
|
181
|
+
found.append(info)
|
|
182
|
+
return sorted(found, key=lambda i: i.get("started", 0), reverse=True)
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def wait_for(port, timeout=30.0, strict=None):
|
|
186
|
+
"""Connect, retrying while the app starts up."""
|
|
187
|
+
deadline = time.time() + timeout
|
|
188
|
+
last = None
|
|
189
|
+
while time.time() < deadline:
|
|
190
|
+
try:
|
|
191
|
+
return Client(port, strict=strict)
|
|
192
|
+
except OSError as e:
|
|
193
|
+
last = e
|
|
194
|
+
time.sleep(0.3)
|
|
195
|
+
raise TimeoutError(f"Loböle not reachable on port {port}: {last}")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def method_help(call, method="", reference=None):
|
|
199
|
+
"""help(method) for every client: what the method does, the parameter
|
|
200
|
+
keys it takes, and what it returns, from the app's own `describe` row.
|
|
201
|
+
|
|
202
|
+
Shared by this client and the MCP server (lobole.mcp), so the
|
|
203
|
+
two cannot drift into different answers. `call(name, **params)` reaches
|
|
204
|
+
the app. `reference` is a client's own prose for methods it wraps,
|
|
205
|
+
{method: {"doc": ..., ...}}: the MCP server passes the tool descriptions
|
|
206
|
+
it keeps out of the context, and they win over the app's one-liner;
|
|
207
|
+
the parameters, their types and the answer are always the app's row. An
|
|
208
|
+
unknown method is refused naming close matches — the app's for its own
|
|
209
|
+
methods, plus the close ones among `reference`.
|
|
210
|
+
"""
|
|
211
|
+
reference = reference or {}
|
|
212
|
+
if not method:
|
|
213
|
+
rows = call("describe")["methods"]
|
|
214
|
+
return {"methods": sorted({r["name"] for r in rows} | set(reference)),
|
|
215
|
+
"hint": "help('add_mate') for one method; describe() for "
|
|
216
|
+
"the app's type tables and enums"}
|
|
217
|
+
try:
|
|
218
|
+
row = call("describe", method=method)["methods"][0]
|
|
219
|
+
except Refused as e:
|
|
220
|
+
# A client's own name for something that is not an RPC (the MCP
|
|
221
|
+
# server's ui_action) has only its prose.
|
|
222
|
+
if method in reference:
|
|
223
|
+
return {"method": method, **reference[method]}
|
|
224
|
+
import difflib
|
|
225
|
+
near = [n for n in difflib.get_close_matches(method, sorted(reference),
|
|
226
|
+
n=5)
|
|
227
|
+
if n not in str(e)]
|
|
228
|
+
raise type(e)(str(e) + (f"; also {', '.join(near)}" if near else ""),
|
|
229
|
+
e.diagnostic) from None
|
|
230
|
+
out = {"method": method,
|
|
231
|
+
"doc": reference.get(method, {}).get("doc") or row.get("doc", "")}
|
|
232
|
+
if row.get("params_free_form"):
|
|
233
|
+
out["params_free_form"] = True
|
|
234
|
+
else:
|
|
235
|
+
out["params"] = row.get("params", [])
|
|
236
|
+
# A typed method (docs/scripting/rpc.md#typed-contracts): each key's
|
|
237
|
+
# type in words, from the same schema the server validates against.
|
|
238
|
+
schema = row.get("request_schema") or {}
|
|
239
|
+
if schema.get("properties"):
|
|
240
|
+
from lobole.contracts import describe
|
|
241
|
+
required = set(schema.get("required", []))
|
|
242
|
+
out["param_types"] = {
|
|
243
|
+
k: describe(s) + ("" if k in required else " (optional)")
|
|
244
|
+
for k, s in schema["properties"].items()}
|
|
245
|
+
for key in ("returns", "requires_part_doc"):
|
|
246
|
+
if key in row:
|
|
247
|
+
out[key] = row[key]
|
|
248
|
+
return out
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def main():
|
|
252
|
+
if len(sys.argv) < 3:
|
|
253
|
+
print(__doc__)
|
|
254
|
+
return 2
|
|
255
|
+
port = int(sys.argv[1])
|
|
256
|
+
method = sys.argv[2]
|
|
257
|
+
params = json.loads(sys.argv[3]) if len(sys.argv) > 3 else {}
|
|
258
|
+
d = Client(port)
|
|
259
|
+
try:
|
|
260
|
+
print(json.dumps(d.request(method, params), indent=2))
|
|
261
|
+
finally:
|
|
262
|
+
d.close()
|
|
263
|
+
return 0
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
if __name__ == "__main__":
|
|
267
|
+
sys.exit(main())
|
lobole/components.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Elias Högbom Aronsson and Loböle contributors
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
"""Explicit ownership and staged updates for single-part Python generators.
|
|
5
|
+
|
|
6
|
+
The application owns the manifest and undoable apply. This module plans builder
|
|
7
|
+
work and assigns stable feature IDs while creating a disposable staging part.
|
|
8
|
+
"""
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from lobole.sdk import Part, SdkError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _StagingPart(Part):
|
|
17
|
+
def __init__(self, app, slot, features):
|
|
18
|
+
super().__init__(app, slot)
|
|
19
|
+
self._keys = {}
|
|
20
|
+
ordinal = defaultdict(int)
|
|
21
|
+
for f in features:
|
|
22
|
+
stem = (f['type'], f.get('name', ''))
|
|
23
|
+
key = (*stem, ordinal[stem])
|
|
24
|
+
ordinal[stem] += 1
|
|
25
|
+
self._keys[key] = f['id']
|
|
26
|
+
self._seen = defaultdict(int)
|
|
27
|
+
self._next = max((f['id'] for f in features), default=0) + 1
|
|
28
|
+
|
|
29
|
+
def _stable_id(self, type_, name):
|
|
30
|
+
stem = (type_, name)
|
|
31
|
+
key = (*stem, self._seen[stem])
|
|
32
|
+
self._seen[stem] += 1
|
|
33
|
+
fid = self._keys.get(key)
|
|
34
|
+
if fid is None:
|
|
35
|
+
fid = self._next
|
|
36
|
+
self._next += 1
|
|
37
|
+
return fid
|
|
38
|
+
|
|
39
|
+
def _add(self, feature):
|
|
40
|
+
name = feature.get('name', '')
|
|
41
|
+
if feature['type'] in _PRIMITIVES:
|
|
42
|
+
# A primitive is stored as the features it expands to, a Sketch
|
|
43
|
+
# named "<name> sketch" and the solid named <name>
|
|
44
|
+
# (docs/modeling/README.md#primitives): pin both, as they are
|
|
45
|
+
# what the manifest holds.
|
|
46
|
+
solid = self._stable_id(_PRIMITIVES[feature['type']], name)
|
|
47
|
+
sketch = self._stable_id('Sketch', name + ' sketch')
|
|
48
|
+
return super()._add(dict(feature, ids=[solid, sketch]))
|
|
49
|
+
return super()._add(dict(feature,
|
|
50
|
+
id=self._stable_id(feature['type'], name)))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# What a primitive's solid is stored as.
|
|
54
|
+
_PRIMITIVES = {'Box': 'Extrude', 'Cylinder': 'Extrude', 'Sphere': 'Revolve'}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _BuilderApp:
|
|
58
|
+
def __init__(self, app, name, staging):
|
|
59
|
+
self._app, self._name, self._staging = app, name, staging
|
|
60
|
+
|
|
61
|
+
def part(self, name, create=True, reuse=False):
|
|
62
|
+
# The staging part IS the builder's own part, whichever of create /
|
|
63
|
+
# reuse the builder says (a builder shared with a full build may say
|
|
64
|
+
# reuse=True); only opening someone else's is a different request.
|
|
65
|
+
if not create:
|
|
66
|
+
raise SdkError('a component builder builds its part; it cannot '
|
|
67
|
+
f'open an existing one ({name!r}, create=False)')
|
|
68
|
+
if name != self._name:
|
|
69
|
+
raise SdkError('a component builder must create exactly its declared part')
|
|
70
|
+
self._staging.activate()
|
|
71
|
+
return self._staging
|
|
72
|
+
|
|
73
|
+
def __getattr__(self, key):
|
|
74
|
+
return getattr(self._app, key)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class ComponentUpdate:
|
|
79
|
+
app: object
|
|
80
|
+
part: Part
|
|
81
|
+
staging: Part | None
|
|
82
|
+
fingerprint: str
|
|
83
|
+
before: dict
|
|
84
|
+
source: dict | None
|
|
85
|
+
report: dict
|
|
86
|
+
|
|
87
|
+
def apply(self):
|
|
88
|
+
if self.staging is None:
|
|
89
|
+
now = self.app.component_status(part_id=self.part.id)
|
|
90
|
+
if now['revision'] != self.before['revision'] or now['state'] != 'owned':
|
|
91
|
+
raise SdkError('component changed after the reuse plan')
|
|
92
|
+
return self.part
|
|
93
|
+
self.report = self.app.update_component(
|
|
94
|
+
part_id=self.part.id, source_part_id=self.staging.id,
|
|
95
|
+
fingerprint=self.fingerprint,
|
|
96
|
+
expected_revision=self.before['revision'],
|
|
97
|
+
source_revision=self.source['revision'], apply=True)
|
|
98
|
+
self.discard()
|
|
99
|
+
return self.part
|
|
100
|
+
|
|
101
|
+
def discard(self):
|
|
102
|
+
if self.staging is not None:
|
|
103
|
+
self.app.delete_part(part=self.staging.id)
|
|
104
|
+
self.staging = None
|
|
105
|
+
|
|
106
|
+
def __enter__(self):
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def __exit__(self, exc_type, exc, tb):
|
|
110
|
+
self.discard()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def claim(part, *, namespace, key, fingerprint):
|
|
114
|
+
before = part.app.component_status(part_id=part.id)
|
|
115
|
+
return part.app.claim_component(part_id=part.id, namespace=namespace,
|
|
116
|
+
key=key, fingerprint=fingerprint,
|
|
117
|
+
expected_revision=before['revision'])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def plan(app, *, namespace, key, fingerprint, builder):
|
|
121
|
+
"""Build only when needed; caller reviews .report before explicit apply().
|
|
122
|
+
|
|
123
|
+
fingerprint must include all builder inputs and dependencies. The caller
|
|
124
|
+
declares it: undeclared ambient inputs must not be treated as cacheable.
|
|
125
|
+
Existing feature names/types plus ordinal within duplicates are stable keys;
|
|
126
|
+
rename/retirement conflicts are refused rather than guessed.
|
|
127
|
+
"""
|
|
128
|
+
before = app.component_status(namespace=namespace, key=key)
|
|
129
|
+
if before['state'] != 'owned':
|
|
130
|
+
raise SdkError(f'component {namespace}/{key}: {before["state"]}; '
|
|
131
|
+
'claim fresh output explicitly, resolve human edits before updating')
|
|
132
|
+
slot = next((p for p in app.parts() if p['id'] == before['part_id']), None)
|
|
133
|
+
if slot is None:
|
|
134
|
+
raise SdkError('component part disappeared')
|
|
135
|
+
part = Part(app, slot)
|
|
136
|
+
if before['generator']['fingerprint'] == fingerprint:
|
|
137
|
+
return ComponentUpdate(app, part, None, fingerprint, before, None,
|
|
138
|
+
dict(action='reuse', part_id=part.id, applied=False))
|
|
139
|
+
features = part.state()['features']
|
|
140
|
+
slot = app.add_part(name='staging-' + uuid.uuid4().hex)
|
|
141
|
+
# add_part returns id plus index/name depending on the server version.
|
|
142
|
+
staging = _StagingPart(app, dict(id=slot['id'], name=slot.get('name', 'staging')), features)
|
|
143
|
+
staging.name = part.name
|
|
144
|
+
try:
|
|
145
|
+
made = builder(_BuilderApp(app, part.name, staging))
|
|
146
|
+
if not isinstance(made, Part) or made.id != staging.id:
|
|
147
|
+
raise SdkError('component builder did not return its staging part')
|
|
148
|
+
source = app.component_status(part_id=staging.id)
|
|
149
|
+
report = app.update_component(part_id=part.id, source_part_id=staging.id,
|
|
150
|
+
fingerprint=fingerprint,
|
|
151
|
+
expected_revision=before['revision'],
|
|
152
|
+
source_revision=source['revision'], apply=False)
|
|
153
|
+
return ComponentUpdate(app, part, staging, fingerprint, before, source, report)
|
|
154
|
+
except BaseException:
|
|
155
|
+
app.delete_part(part=staging.id)
|
|
156
|
+
raise
|
lobole/contracts.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Elias Högbom Aronsson and Loböle contributors
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
"""Runtime validation of the documented contract JSON Schema subset.
|
|
5
|
+
|
|
6
|
+
The client-side twin of ValidateInspectionValue (src/remote/InspectionContracts.h):
|
|
7
|
+
the same walk in the same order, building the same diagnostic, so a preflight
|
|
8
|
+
refusal and the server's name the same fault field for field
|
|
9
|
+
(docs/scripting/rpc.md#structured-errors).
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import math
|
|
13
|
+
|
|
14
|
+
from lobole.errors import Refused, refusal
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _accepted(schema):
|
|
18
|
+
out = list(schema.get('enum', []))
|
|
19
|
+
for alt in schema.get('anyOf', []):
|
|
20
|
+
out += alt.get('enum', [])
|
|
21
|
+
return out
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _num(v):
|
|
25
|
+
return str(int(v)) if float(v).is_integer() and abs(v) < 1e18 else repr(v)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def describe(schema):
|
|
29
|
+
"""A schema in words, as the server's DescribeSchema says it."""
|
|
30
|
+
if 'anyOf' in schema:
|
|
31
|
+
return ' or '.join(describe(a) for a in schema['anyOf'])
|
|
32
|
+
if 'enum' in schema:
|
|
33
|
+
vals = [v if isinstance(v, str) else json.dumps(v) for v in schema['enum']]
|
|
34
|
+
if len(vals) == 1:
|
|
35
|
+
return f'"{vals[0]}"'
|
|
36
|
+
more = f', +{len(vals) - 12} more' if len(vals) > 12 else ''
|
|
37
|
+
return 'one of: ' + ', '.join(vals[:12]) + more
|
|
38
|
+
if 'type' not in schema:
|
|
39
|
+
return 'any value'
|
|
40
|
+
t = schema['type']
|
|
41
|
+
s = dict(integer='an integer', number='a number', string='a string',
|
|
42
|
+
boolean='true or false', object='an object', null='null',
|
|
43
|
+
array='an array').get(t, t)
|
|
44
|
+
for key, op in (('minimum', '>='), ('exclusiveMinimum', '>'),
|
|
45
|
+
('maximum', '<='), ('exclusiveMaximum', '<')):
|
|
46
|
+
if key in schema:
|
|
47
|
+
s += f' {op} {_num(schema[key])}'
|
|
48
|
+
if 'minLength' in schema:
|
|
49
|
+
s += f' of at least {_num(schema["minLength"])} character(s)'
|
|
50
|
+
if t == 'object' and 'properties' in schema:
|
|
51
|
+
s += ' {' + ', '.join(schema['properties']) + '}'
|
|
52
|
+
if t == 'array':
|
|
53
|
+
lo, hi = 'minItems' in schema, 'maxItems' in schema
|
|
54
|
+
if lo and hi and schema['minItems'] == schema['maxItems']:
|
|
55
|
+
s += f' of {_num(schema["minItems"])}'
|
|
56
|
+
elif lo or hi:
|
|
57
|
+
s += (f' of {_num(schema["minItems"]) if lo else "0"}..'
|
|
58
|
+
f'{_num(schema["maxItems"]) if hi else "any"}')
|
|
59
|
+
if 'items' in schema:
|
|
60
|
+
s += (' × ' if lo or hi else ' of ') + describe(schema['items'])
|
|
61
|
+
if schema.get('uniqueItems'):
|
|
62
|
+
s += ', no repeats'
|
|
63
|
+
return s
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def contract_refusal(operation, code, path, expected, observed, message=None,
|
|
67
|
+
accepted=None):
|
|
68
|
+
"""A client preflight refusal: the Refused subclass the server would
|
|
69
|
+
raise for `code`, with the diagnostic it would send (lobole.errors)."""
|
|
70
|
+
diagnostic = dict(operation=operation, code=code, path=path,
|
|
71
|
+
expected=expected, observed=observed)
|
|
72
|
+
if path.startswith('/params/'):
|
|
73
|
+
diagnostic['param'] = path[len('/params/'):].split('/')[0]
|
|
74
|
+
if accepted:
|
|
75
|
+
diagnostic['accepted'] = accepted
|
|
76
|
+
return refusal(message or
|
|
77
|
+
f'{operation}: {code} at {path}; expected {expected}',
|
|
78
|
+
diagnostic)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _where(path):
|
|
82
|
+
return 'its params' if path == '/params' else f"'{path[len('/params/'):]}'"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _shown(value):
|
|
86
|
+
try:
|
|
87
|
+
s = json.dumps(value)
|
|
88
|
+
except (TypeError, ValueError):
|
|
89
|
+
s = repr(value)
|
|
90
|
+
return s if len(s) <= 80 else s[:77] + '...'
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _is_number(value):
|
|
94
|
+
return (isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
95
|
+
and (isinstance(value, int) or math.isfinite(value)))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def accepts(schema, value):
|
|
99
|
+
try:
|
|
100
|
+
validate(schema, value, '', '/params')
|
|
101
|
+
return True
|
|
102
|
+
except Refused:
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def validate(schema, value, operation, path='/params'):
|
|
107
|
+
def fail(expected=schema):
|
|
108
|
+
# A vocabulary's remedy for the usual mistake rides as `description`.
|
|
109
|
+
hint = expected.get('description') or next(
|
|
110
|
+
(a['description'] for a in expected.get('anyOf', [])
|
|
111
|
+
if a.get('description')), '')
|
|
112
|
+
raise contract_refusal(
|
|
113
|
+
operation, 'invalid_argument', path, expected, value,
|
|
114
|
+
f'{operation}: {_where(path)} must be {describe(expected)}; '
|
|
115
|
+
f'got {_shown(value)}' + (f'. {hint}' if hint else ''),
|
|
116
|
+
_accepted(expected))
|
|
117
|
+
|
|
118
|
+
if 'anyOf' in schema:
|
|
119
|
+
if any(accepts(alt, value) for alt in schema['anyOf']):
|
|
120
|
+
return
|
|
121
|
+
fail()
|
|
122
|
+
number = _is_number(value)
|
|
123
|
+
if 'type' in schema:
|
|
124
|
+
kind = schema['type']
|
|
125
|
+
valid = {'object': isinstance(value, dict),
|
|
126
|
+
'array': isinstance(value, (list, tuple)),
|
|
127
|
+
'string': isinstance(value, str),
|
|
128
|
+
'boolean': isinstance(value, bool),
|
|
129
|
+
'null': value is None,
|
|
130
|
+
'number': number,
|
|
131
|
+
'integer': number and (isinstance(value, int) or value.is_integer())}
|
|
132
|
+
if not valid[kind]:
|
|
133
|
+
fail()
|
|
134
|
+
if 'enum' in schema and value not in schema['enum']:
|
|
135
|
+
fail()
|
|
136
|
+
if number:
|
|
137
|
+
if 'minimum' in schema and value < schema['minimum']:
|
|
138
|
+
fail()
|
|
139
|
+
if 'maximum' in schema and value > schema['maximum']:
|
|
140
|
+
fail()
|
|
141
|
+
if 'exclusiveMinimum' in schema and value <= schema['exclusiveMinimum']:
|
|
142
|
+
fail()
|
|
143
|
+
if 'exclusiveMaximum' in schema and value >= schema['exclusiveMaximum']:
|
|
144
|
+
fail()
|
|
145
|
+
if isinstance(value, str) and len(value) < schema.get('minLength', 0):
|
|
146
|
+
fail()
|
|
147
|
+
if isinstance(value, dict) and 'properties' in schema:
|
|
148
|
+
props = schema['properties']
|
|
149
|
+
# Sorted: the server's objects iterate in key order. Unknown keys
|
|
150
|
+
# first, then missing ones, then each value -- the server's order.
|
|
151
|
+
for key in sorted(value):
|
|
152
|
+
# An open object (a result's `{..., *}`) carries keys it does
|
|
153
|
+
# not list. Every request schema closes its objects, so this is
|
|
154
|
+
# still the server's walk for everything the server validates.
|
|
155
|
+
if key not in props and \
|
|
156
|
+
schema.get('additionalProperties', True) is False:
|
|
157
|
+
at = path + '/' + key
|
|
158
|
+
raise contract_refusal(
|
|
159
|
+
operation, 'unknown_argument', at, props, value[key],
|
|
160
|
+
f'{operation}: unknown parameter {_where(at)}; it takes: '
|
|
161
|
+
+ ', '.join(sorted(props)), sorted(props))
|
|
162
|
+
for key in schema.get('required', []):
|
|
163
|
+
if key not in value:
|
|
164
|
+
at = path + '/' + key
|
|
165
|
+
raise contract_refusal(
|
|
166
|
+
operation, 'missing_argument', at, props[key], None,
|
|
167
|
+
f'{operation}: missing required parameter {_where(at)} '
|
|
168
|
+
f'({describe(props[key])})')
|
|
169
|
+
for key in sorted(value):
|
|
170
|
+
if key in props:
|
|
171
|
+
validate(props[key], value[key], operation, path + '/' + key)
|
|
172
|
+
if isinstance(value, (list, tuple)):
|
|
173
|
+
if len(value) < schema.get('minItems', 0):
|
|
174
|
+
fail()
|
|
175
|
+
if 'maxItems' in schema and len(value) > schema['maxItems']:
|
|
176
|
+
fail()
|
|
177
|
+
for i, item in enumerate(value):
|
|
178
|
+
if 'items' in schema:
|
|
179
|
+
validate(schema['items'], item, operation, path + '/' + str(i))
|
|
180
|
+
if schema.get('uniqueItems') and item in value[:i]:
|
|
181
|
+
fail()
|