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 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()