lab-sync-driver-sdk 0.3.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lab Sync
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ # The tests run from the repository; they are not part of the distribution.
2
+ prune tests
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: lab-sync-driver-sdk
3
+ Version: 0.3.0
4
+ Summary: Public driver-authoring SDK for the device engine: subclass Device, decorate commands, serve() over stdio
5
+ Author: Lab Sync
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Classifier: Topic :: Scientific/Engineering
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.14
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: lab-sync-driver-protocol==0.3.0
18
+ Provides-Extra: lab
19
+ Requires-Dist: lab-device-sdk>=0.1.12; extra == "lab"
20
+ Requires-Dist: fastapi>=0.110; extra == "lab"
21
+ Requires-Dist: uvicorn>=0.30; extra == "lab"
22
+ Dynamic: license-file
23
+
24
+ # lab-sync-driver-sdk
25
+
26
+ Write a lab device driver as a decorated Python class and serve it as a
27
+ supervised child process. A driver subclasses `Device`, decorates async
28
+ methods with `@command` and the typed in/out decorators, and calls
29
+ `serve(device)` to speak the stdio wire protocol defined by
30
+ [`lab-sync-driver-protocol`](https://pypi.org/project/lab-sync-driver-protocol/). The SDK depends on
31
+ nothing but that package, so a driver ships with its own runtime and never
32
+ imports the supervisor that launches it.
33
+
34
+ ```bash
35
+ pip install lab-sync-driver-sdk
36
+ ```
37
+
38
+ ## A driver
39
+
40
+ ```python
41
+ import asyncio
42
+
43
+ from lab_sync_driver_sdk import Device, command, float_in, string_in, string_out, serve
44
+
45
+
46
+ class Greeter(Device):
47
+ device_type_id = "acme-greeter"
48
+ manufacturer = "Acme"
49
+ model = "Greeter 1"
50
+
51
+ @command("Greet", "Say hello", default_sim_sec=1.0)
52
+ @string_in("Name", "who to greet", default="world", maps_to="name")
53
+ @float_in("Delay", "seconds to wait first", default=0.0, min_value=0.0, maps_to="delay")
54
+ @string_out("Greeting", "what was said")
55
+ async def greet(self, name: str = "world", delay: float = 0.0, progress=None) -> dict[str, object]:
56
+ await asyncio.sleep(delay)
57
+ if progress is not None:
58
+ await progress(1.0, "greeted")
59
+ return {"Greeting": f"Hello, {name}!"}
60
+
61
+
62
+ if __name__ == "__main__":
63
+ asyncio.run(serve(Greeter()))
64
+ ```
65
+
66
+ The decorators produce the driver's `describe` reply: its commands, their
67
+ inputs and outputs, and any optional metadata the class declares (`category`,
68
+ physical dimensions, `default_nests`, `configurations`, telemetry channels).
69
+ A command returns a dict, and every key in it must be declared with an output
70
+ decorator. An undeclared key fails the command with `OutputValidationError`.
71
+ A command that takes a `progress` parameter is handed an async
72
+ `progress(fraction, message)` reporter.
73
+
74
+ Once `serve` starts, stdout carries protocol frames only. The host points
75
+ `sys.stdout` at stderr, so a stray `print` lands on the log channel rather
76
+ than corrupting a frame.
77
+
78
+ ## Testing a driver
79
+
80
+ `lab_sync_driver_sdk.testing.run_command` runs one command in-process, applying the
81
+ same output validation the host applies:
82
+
83
+ ```python
84
+ from lab_sync_driver_sdk.testing import run_command
85
+
86
+ result = await run_command(Greeter(), "Greet", {"name": "lab"})
87
+ assert result == {"Greeting": "Hello, lab!"}
88
+ ```
89
+
90
+ ## Connection settings
91
+
92
+ `@settings()` plus the typed input decorators declare the form an operator fills
93
+ in. `connect` receives the saved values in one of two shapes:
94
+
95
+ ```python
96
+ # The dict, keyed by display name, beside a carrier that holds the form.
97
+ @settings()
98
+ @string_in("Host", "device address", default="10.0.0.1")
99
+ async def connection_settings(self) -> None: ...
100
+
101
+ async def connect(self, settings: dict[str, Any] | None = None) -> None:
102
+ host = str((settings or {}).get("Host", "10.0.0.1"))
103
+
104
+ # Or one parameter per setting, with @settings() on connect itself.
105
+ @settings()
106
+ @string_in("Host", "device address", default="10.0.0.1", maps_to="host")
107
+ @int_in("Port", "device port", default=4321, maps_to="port")
108
+ async def connect(self, host: str, port: int) -> None: ...
109
+ ```
110
+
111
+ In the second shape the host binds each parameter by `maps_to` (else the
112
+ display name). The value comes from the saved setting, else the declared
113
+ default, else the signature's own default, and is coerced to the declared type,
114
+ so `connect` receives an `int` even when the value was saved as text. A setting
115
+ with no matching parameter, or a required parameter with no setting, fails the
116
+ connect with a message naming it. See `lab_sync_driver_sdk.settings_binding`.
117
+
118
+ ## Hosting a lab-device-sdk driver
119
+
120
+ `lab_sync_driver_sdk.lab_host` serves a driver written against
121
+ [`lab-device-sdk`](https://pypi.org/project/lab-device-sdk/) over the same
122
+ protocol, unchanged. It needs the `lab` extra, which also brings in FastAPI
123
+ and uvicorn for drivers that serve HTTP routes of their own:
124
+
125
+ ```bash
126
+ pip install "lab-sync-driver-sdk[lab]"
127
+ ```
128
+
129
+ It hosts one device per process, so it has to be told which instance it is
130
+ running. A driver manifest declares that with the supervisor's `{device_id}`
131
+ token rather than a hardcoded id, which is what lets a site run several
132
+ devices of the same type:
133
+
134
+ ```toml
135
+ [launch]
136
+ type = "command"
137
+ command = ["python", "-m", "lab_sync_driver_sdk.lab_host",
138
+ "--device", "my_pkg.my_device:MyDevice",
139
+ "--device-id", "{device_id}"]
140
+ ```
141
+
142
+ ## License
143
+
144
+ MIT
@@ -0,0 +1,121 @@
1
+ # lab-sync-driver-sdk
2
+
3
+ Write a lab device driver as a decorated Python class and serve it as a
4
+ supervised child process. A driver subclasses `Device`, decorates async
5
+ methods with `@command` and the typed in/out decorators, and calls
6
+ `serve(device)` to speak the stdio wire protocol defined by
7
+ [`lab-sync-driver-protocol`](https://pypi.org/project/lab-sync-driver-protocol/). The SDK depends on
8
+ nothing but that package, so a driver ships with its own runtime and never
9
+ imports the supervisor that launches it.
10
+
11
+ ```bash
12
+ pip install lab-sync-driver-sdk
13
+ ```
14
+
15
+ ## A driver
16
+
17
+ ```python
18
+ import asyncio
19
+
20
+ from lab_sync_driver_sdk import Device, command, float_in, string_in, string_out, serve
21
+
22
+
23
+ class Greeter(Device):
24
+ device_type_id = "acme-greeter"
25
+ manufacturer = "Acme"
26
+ model = "Greeter 1"
27
+
28
+ @command("Greet", "Say hello", default_sim_sec=1.0)
29
+ @string_in("Name", "who to greet", default="world", maps_to="name")
30
+ @float_in("Delay", "seconds to wait first", default=0.0, min_value=0.0, maps_to="delay")
31
+ @string_out("Greeting", "what was said")
32
+ async def greet(self, name: str = "world", delay: float = 0.0, progress=None) -> dict[str, object]:
33
+ await asyncio.sleep(delay)
34
+ if progress is not None:
35
+ await progress(1.0, "greeted")
36
+ return {"Greeting": f"Hello, {name}!"}
37
+
38
+
39
+ if __name__ == "__main__":
40
+ asyncio.run(serve(Greeter()))
41
+ ```
42
+
43
+ The decorators produce the driver's `describe` reply: its commands, their
44
+ inputs and outputs, and any optional metadata the class declares (`category`,
45
+ physical dimensions, `default_nests`, `configurations`, telemetry channels).
46
+ A command returns a dict, and every key in it must be declared with an output
47
+ decorator. An undeclared key fails the command with `OutputValidationError`.
48
+ A command that takes a `progress` parameter is handed an async
49
+ `progress(fraction, message)` reporter.
50
+
51
+ Once `serve` starts, stdout carries protocol frames only. The host points
52
+ `sys.stdout` at stderr, so a stray `print` lands on the log channel rather
53
+ than corrupting a frame.
54
+
55
+ ## Testing a driver
56
+
57
+ `lab_sync_driver_sdk.testing.run_command` runs one command in-process, applying the
58
+ same output validation the host applies:
59
+
60
+ ```python
61
+ from lab_sync_driver_sdk.testing import run_command
62
+
63
+ result = await run_command(Greeter(), "Greet", {"name": "lab"})
64
+ assert result == {"Greeting": "Hello, lab!"}
65
+ ```
66
+
67
+ ## Connection settings
68
+
69
+ `@settings()` plus the typed input decorators declare the form an operator fills
70
+ in. `connect` receives the saved values in one of two shapes:
71
+
72
+ ```python
73
+ # The dict, keyed by display name, beside a carrier that holds the form.
74
+ @settings()
75
+ @string_in("Host", "device address", default="10.0.0.1")
76
+ async def connection_settings(self) -> None: ...
77
+
78
+ async def connect(self, settings: dict[str, Any] | None = None) -> None:
79
+ host = str((settings or {}).get("Host", "10.0.0.1"))
80
+
81
+ # Or one parameter per setting, with @settings() on connect itself.
82
+ @settings()
83
+ @string_in("Host", "device address", default="10.0.0.1", maps_to="host")
84
+ @int_in("Port", "device port", default=4321, maps_to="port")
85
+ async def connect(self, host: str, port: int) -> None: ...
86
+ ```
87
+
88
+ In the second shape the host binds each parameter by `maps_to` (else the
89
+ display name). The value comes from the saved setting, else the declared
90
+ default, else the signature's own default, and is coerced to the declared type,
91
+ so `connect` receives an `int` even when the value was saved as text. A setting
92
+ with no matching parameter, or a required parameter with no setting, fails the
93
+ connect with a message naming it. See `lab_sync_driver_sdk.settings_binding`.
94
+
95
+ ## Hosting a lab-device-sdk driver
96
+
97
+ `lab_sync_driver_sdk.lab_host` serves a driver written against
98
+ [`lab-device-sdk`](https://pypi.org/project/lab-device-sdk/) over the same
99
+ protocol, unchanged. It needs the `lab` extra, which also brings in FastAPI
100
+ and uvicorn for drivers that serve HTTP routes of their own:
101
+
102
+ ```bash
103
+ pip install "lab-sync-driver-sdk[lab]"
104
+ ```
105
+
106
+ It hosts one device per process, so it has to be told which instance it is
107
+ running. A driver manifest declares that with the supervisor's `{device_id}`
108
+ token rather than a hardcoded id, which is what lets a site run several
109
+ devices of the same type:
110
+
111
+ ```toml
112
+ [launch]
113
+ type = "command"
114
+ command = ["python", "-m", "lab_sync_driver_sdk.lab_host",
115
+ "--device", "my_pkg.my_device:MyDevice",
116
+ "--device-id", "{device_id}"]
117
+ ```
118
+
119
+ ## License
120
+
121
+ MIT
@@ -0,0 +1,43 @@
1
+ """lab-sync-driver-sdk: the driver-authoring SDK.
2
+
3
+ A driver subclasses `Device`, decorates async methods with `@command` and the
4
+ in/out decorators, and runs `serve(device)` (over stdio). It depends only on
5
+ the shared wire contract (`lab_sync_driver_protocol`), never on the supervisor that
6
+ launches the driver.
7
+ """
8
+
9
+ from lab_sync_driver_sdk.decorators import OutputValidationError
10
+ from lab_sync_driver_sdk.decorators import bool_in
11
+ from lab_sync_driver_sdk.decorators import bool_out
12
+ from lab_sync_driver_sdk.decorators import command
13
+ from lab_sync_driver_sdk.decorators import float_in
14
+ from lab_sync_driver_sdk.decorators import float_out
15
+ from lab_sync_driver_sdk.decorators import int_in
16
+ from lab_sync_driver_sdk.decorators import int_out
17
+ from lab_sync_driver_sdk.decorators import settings
18
+ from lab_sync_driver_sdk.decorators import string_in
19
+ from lab_sync_driver_sdk.decorators import string_out
20
+ from lab_sync_driver_sdk.decorators import telemetry
21
+ from lab_sync_driver_sdk.device import Device
22
+ from lab_sync_driver_sdk.device import DeviceCategory
23
+ from lab_sync_driver_sdk.device import NestOffset
24
+ from lab_sync_driver_sdk.host import serve
25
+
26
+ __all__ = [
27
+ "Device",
28
+ "DeviceCategory",
29
+ "NestOffset",
30
+ "OutputValidationError",
31
+ "bool_in",
32
+ "bool_out",
33
+ "command",
34
+ "float_in",
35
+ "float_out",
36
+ "int_in",
37
+ "int_out",
38
+ "serve",
39
+ "settings",
40
+ "string_in",
41
+ "string_out",
42
+ "telemetry",
43
+ ]
@@ -0,0 +1,349 @@
1
+ """Driver authoring decorators.
2
+
3
+ A driver author annotates async methods on a `Device` subclass:
4
+
5
+ @command("Echo", "Echo the message")
6
+ @string_in("message", "text to echo")
7
+ @string_out("message", "echoed text")
8
+ async def echo(self, message: str = "") -> dict[str, object]:
9
+ return {"message": message}
10
+
11
+ Decorators accumulate a CommandMeta on the function; the host runtime
12
+ (`lab_sync_driver_sdk.host`) builds the Describe message from it and validates outputs.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass
19
+ from dataclasses import field
20
+ from typing import Protocol
21
+ from typing import TypeVar
22
+
23
+ F = TypeVar("F", bound=Callable[..., object])
24
+
25
+ # Wire type names (match the protocol ParamDescriptor.type and the SDK surface).
26
+ TYPE_STRING = "string"
27
+ TYPE_INT = "int"
28
+ TYPE_FLOAT = "float"
29
+ TYPE_BOOL = "bool"
30
+
31
+
32
+ @dataclass
33
+ class Param:
34
+ name: str
35
+ description: str
36
+ type: str
37
+ options: list[str] | None = None # constrained-to-options choices, when the param is an enum
38
+ dynamic_options: bool = False # options are computed by the driver at runtime (see describe-options)
39
+ maps_to: str | None = None
40
+ default: object = None
41
+ min_value: float | None = None
42
+ max_value: float | None = None
43
+ string_type: str | None = None
44
+ type_details: str | None = None
45
+ allow_empty: bool = False
46
+ group: str = ""
47
+
48
+
49
+ @dataclass
50
+ class CommandMeta:
51
+ name: str = ""
52
+ description: str = ""
53
+ inputs: list[Param] = field(default_factory=list[Param])
54
+ outputs: list[Param] = field(default_factory=list[Param])
55
+ exclusive: bool = True
56
+ concurrent: bool = False
57
+ category: str = "" # display grouping in operator UIs; "" lands in the default group
58
+ operator_visible: bool = True # hidden commands stay callable but are not listed for operators
59
+ default_sim_sec: float | None = None # nominal duration, the UI's progress-bar estimate
60
+ # Workflow phase hint: setup/processing/teardown/maintenance/emergency, or
61
+ # storage_present/storage_receive/storage_store/storage_position/storage_inventory.
62
+ # "" is unset. See `command` for what each phase tells a scheduler.
63
+ operation_phase: str = ""
64
+ # The command simulates itself, so the host must not stand in for it by pacing
65
+ # out `default_sim_sec` and returning typed defaults.
66
+ handles_simulation: bool = False
67
+
68
+
69
+ @dataclass
70
+ class TelemetryMeta:
71
+ """Declaration of a sampler that runs only while somebody is watching.
72
+
73
+ A telemetry channel is the opposite of a command in every way that matters:
74
+ nobody asks for an individual reading, the frames are un-correlated (they
75
+ carry no request id), and a frame nobody wants should never be produced at
76
+ all. That last part is the reason this exists rather than a driver simply
77
+ publishing on a timer -- sampling usually costs a round trip on the same
78
+ link the device's real work goes over, so the supervisor tells the driver when to
79
+ start and stop, and the default is stopped.
80
+ """
81
+
82
+ channel: str = ""
83
+ hz: float = 5.0
84
+ description: str = ""
85
+
86
+
87
+ _ATTR = "__studio_command__"
88
+ _SETTINGS_ATTR = "__studio_settings__"
89
+ _TELEMETRY_ATTR = "__studio_telemetry__"
90
+
91
+
92
+ def _meta(fn: object) -> CommandMeta:
93
+ meta = getattr(fn, _ATTR, None)
94
+ if not isinstance(meta, CommandMeta):
95
+ meta = CommandMeta()
96
+ setattr(fn, _ATTR, meta)
97
+ return meta
98
+
99
+
100
+ def get_command_meta(fn: object) -> CommandMeta | None:
101
+ meta = getattr(fn, _ATTR, None)
102
+ return meta if isinstance(meta, CommandMeta) else None
103
+
104
+
105
+ def command( # noqa: PLR0913 - the decorator surface is the API; each field is a distinct declaration
106
+ name: str,
107
+ description: str = "",
108
+ *,
109
+ exclusive: bool = True,
110
+ concurrent: bool = False,
111
+ category: str = "",
112
+ operator_visible: bool = True,
113
+ default_sim_sec: float | None = None,
114
+ operation_phase: str = "",
115
+ handles_simulation: bool = False,
116
+ ) -> Callable[[F], F]:
117
+ """Declare a command.
118
+
119
+ `exclusive` means it needs the device to itself: the supervisor admits it only when no other
120
+ exclusive command is in flight there, so hardware expecting one command at a time is not
121
+ driven twice at once. Clear it for a command that guards no physical resource.
122
+
123
+ `concurrent` allows several invocations of this same command at once, which a stateless
124
+ service device needs when every running sequence routes its steps through one instance.
125
+
126
+ `operation_phase` is a workflow phase hint — setup/processing/teardown/maintenance/emergency,
127
+ plus the storage phases for a storage device that acts on a plate's way in or out itself.
128
+ Leave it unset unless it is true: a scheduler treats every `processing` command as a step a
129
+ plate can be sent to, so marking a command that does not process a plate offers a
130
+ misleading step. A scheduler does not offer the storage phases as steps; it runs them
131
+ itself when a plate leaves or enters storage, each at a fixed point around the robot's pick
132
+ or place:
133
+
134
+ - `storage_present` fetches a plate onto the device's *own* nest, **before** the robot
135
+ picks it on a get.
136
+ - `storage_receive` gets the device ready to take a plate, **before** the robot places it
137
+ on a put — a stacker raising its stack so a plate set into its ring lands on top. Put
138
+ side only: it never runs before a get.
139
+ - `storage_store` files a plate away off the device's nest, **after** the robot placed it
140
+ on a put.
141
+ - `storage_position` moves the storage to the robot (a carousel turning a hotel round),
142
+ first on **both** sides.
143
+ - `storage_inventory` reports which barcode is in each location the supervisor asks about.
144
+
145
+ A put therefore runs `storage_position` -> `storage_receive` -> the robot ->
146
+ `storage_store`, skipping whichever the device does not declare. Pick by *when* the device
147
+ has to act: a command that must run before the robot lets go of the plate is
148
+ `storage_receive`, never `storage_store`.
149
+
150
+ `handles_simulation` says the command does its own simulation. Leave it clear and the host
151
+ stands in for a simulated device by pacing out `default_sim_sec` and returning typed
152
+ defaults; set it and the command body runs even in simulation.
153
+ """
154
+
155
+ def deco(fn: F) -> F:
156
+ meta = _meta(fn)
157
+ meta.name = name
158
+ meta.description = description
159
+ meta.exclusive = exclusive
160
+ meta.concurrent = concurrent
161
+ meta.category = category
162
+ meta.operator_visible = operator_visible
163
+ meta.default_sim_sec = default_sim_sec
164
+ meta.operation_phase = operation_phase
165
+ meta.handles_simulation = handles_simulation
166
+ return fn
167
+
168
+ return deco
169
+
170
+
171
+ def get_telemetry_meta(fn: object) -> TelemetryMeta | None:
172
+ """The telemetry declaration on a method, or None if it has none."""
173
+ meta = getattr(fn, _TELEMETRY_ATTR, None)
174
+ return meta if isinstance(meta, TelemetryMeta) else None
175
+
176
+
177
+ def telemetry(channel: str, *, hz: float = 5.0, description: str = "") -> Callable[[F], F]:
178
+ """Declare a demand-gated sampler.
179
+
180
+ The decorated method takes no arguments and returns a JSON-serializable
181
+ mapping -- one reading. The host calls it about `hz` times a second, but
182
+ **only while the supervisor says someone is watching**, and never before then: a
183
+ driver that is connected but unobserved samples nothing.
184
+
185
+ That gating is the whole point. Reading a robot's joint angles means a round
186
+ trip on the serial link its motion commands share, so doing it for a page
187
+ nobody has open would steal bandwidth from the hardware. The supervisor
188
+ switches a channel on only while something downstream is subscribed to it,
189
+ so the thing that switches the sampler on is the thing that wants the data.
190
+
191
+ `hz` is a ceiling and a target, not a guarantee: a sampler that takes longer
192
+ than its period simply runs less often, and one that raises is logged and
193
+ retried on the next tick rather than tearing the channel down. Neither a
194
+ slow device nor a flaky read should end the stream.
195
+
196
+ @telemetry("articulation", hz=10)
197
+ async def sample_joints(self) -> dict[str, object]:
198
+ return {"joints": dict(zip(JOINT_IDS, await self.read_joints()))}
199
+
200
+ Args:
201
+ channel: Channel name, unique on the device. The supervisor publishes
202
+ its readings under this name.
203
+ hz: How often to sample while demand is on.
204
+ description: What the channel carries, for humans reading a describe.
205
+ """
206
+
207
+ def deco(fn: F) -> F:
208
+ setattr(fn, _TELEMETRY_ATTR, TelemetryMeta(channel=channel, hz=hz, description=description))
209
+ return fn
210
+
211
+ return deco
212
+
213
+
214
+ def settings() -> Callable[[F], F]:
215
+ """Declare the device's connection settings on a method (conventionally `connect`).
216
+
217
+ The input decorators stacked under this one describe the settings form the operator fills in; the host reports them in describe and passes
218
+ the saved values dict into `connect(settings)`. Decorate `connect` itself with one parameter per setting (named by `maps_to`, else the
219
+ display name) and the host binds and coerces them instead -- see `lab_sync_driver_sdk.settings_binding`. A settings method is not a command.
220
+ """
221
+
222
+ def deco(fn: F) -> F:
223
+ setattr(fn, _SETTINGS_ATTR, True)
224
+ return fn
225
+
226
+ return deco
227
+
228
+
229
+ def is_settings_method(fn: object) -> bool:
230
+ return bool(getattr(fn, _SETTINGS_ATTR, False))
231
+
232
+
233
+ class _InputFactory(Protocol):
234
+ def __call__( # noqa: PLR0913 - mirrors the input-decorator surface
235
+ self,
236
+ name: str,
237
+ description: str = "",
238
+ *,
239
+ default: object = None,
240
+ maps_to: str | None = None,
241
+ min_value: float | None = None,
242
+ max_value: float | None = None,
243
+ options: list[str] | None = None,
244
+ dynamic_options: bool = False,
245
+ string_type: str | None = None,
246
+ type_details: str | None = None,
247
+ allow_empty: bool = False,
248
+ group: str = "",
249
+ ) -> Callable[[F], F]: ...
250
+
251
+
252
+ def _input(type_name: str) -> _InputFactory:
253
+ # insert(0) so that, since decorators apply bottom-up, source order is kept.
254
+ def make( # noqa: PLR0913 - the decorator surface is the API; each field is a distinct declaration
255
+ name: str,
256
+ description: str = "",
257
+ *,
258
+ default: object = None,
259
+ maps_to: str | None = None,
260
+ min_value: float | None = None,
261
+ max_value: float | None = None,
262
+ options: list[str] | None = None,
263
+ dynamic_options: bool = False,
264
+ string_type: str | None = None,
265
+ type_details: str | None = None,
266
+ allow_empty: bool = False,
267
+ group: str = "",
268
+ ) -> Callable[[F], F]:
269
+ def deco(fn: F) -> F:
270
+ _meta(fn).inputs.insert(
271
+ 0,
272
+ Param(
273
+ name,
274
+ description,
275
+ type_name,
276
+ options=options,
277
+ dynamic_options=dynamic_options,
278
+ maps_to=maps_to,
279
+ default=default,
280
+ min_value=min_value,
281
+ max_value=max_value,
282
+ string_type=string_type,
283
+ type_details=type_details,
284
+ allow_empty=allow_empty,
285
+ group=group,
286
+ ),
287
+ )
288
+ return fn
289
+
290
+ return deco
291
+
292
+ return make
293
+
294
+
295
+ def _output(type_name: str) -> Callable[[str, str], Callable[[F], F]]:
296
+ def make(name: str, description: str = "") -> Callable[[F], F]:
297
+ def deco(fn: F) -> F:
298
+ _meta(fn).outputs.insert(0, Param(name, description, type_name))
299
+ return fn
300
+
301
+ return deco
302
+
303
+ return make
304
+
305
+
306
+ string_in = _input(TYPE_STRING)
307
+ int_in = _input(TYPE_INT)
308
+ float_in = _input(TYPE_FLOAT)
309
+ bool_in = _input(TYPE_BOOL)
310
+
311
+ string_out = _output(TYPE_STRING)
312
+ int_out = _output(TYPE_INT)
313
+ float_out = _output(TYPE_FLOAT)
314
+ bool_out = _output(TYPE_BOOL)
315
+
316
+
317
+ def param_dict(param: Param) -> dict[str, object]:
318
+ """Serialize a Param to its wire descriptor (describe inputs / settings entries)."""
319
+ descriptor: dict[str, object] = {"name": param.name, "description": param.description, "type": param.type}
320
+ optional: dict[str, object | None] = {
321
+ "options": param.options,
322
+ "maps_to": param.maps_to,
323
+ "default": param.default,
324
+ "min_value": param.min_value,
325
+ "max_value": param.max_value,
326
+ "string_type": param.string_type,
327
+ "type_details": param.type_details,
328
+ }
329
+ descriptor.update({key: value for key, value in optional.items() if value is not None})
330
+ if param.dynamic_options:
331
+ descriptor["dynamic_options"] = True
332
+ if param.allow_empty:
333
+ descriptor["allow_empty"] = True
334
+ if param.group:
335
+ descriptor["group"] = param.group
336
+ return descriptor
337
+
338
+
339
+ class OutputValidationError(Exception):
340
+ """A command returned fields not declared with an output decorator."""
341
+
342
+
343
+ def validate_outputs(meta: CommandMeta, result: dict[str, object]) -> None:
344
+ """Raise `OutputValidationError` if the result has a field no output decorator declared."""
345
+ declared = {p.name for p in meta.outputs}
346
+ extra = set(result) - declared
347
+ if extra:
348
+ msg = f"command '{meta.name}' returned undeclared output fields: {sorted(extra)}"
349
+ raise OutputValidationError(msg)