lab-sync-driver-sdk 0.3.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.
@@ -0,0 +1,162 @@
1
+ """Bind a device's saved settings onto a `connect` that declares them as parameters.
2
+
3
+ The supervisor sends a device's connection settings as one dict, keyed by each
4
+ setting's display name. A driver can take them in either of two shapes, and the
5
+ host picks by looking at `connect`:
6
+
7
+ * ``connect(self, settings)`` -- the dict, as sent. Every driver written before
8
+ this module existed takes this shape, usually beside an empty
9
+ ``@settings()``-decorated ``connection_settings`` method that carries the form.
10
+ * ``@settings()`` on ``connect`` itself, with one parameter per setting --
11
+ lab-device-sdk's shape::
12
+
13
+ @settings()
14
+ @string_in("Host", "device address", default="10.0.0.1", maps_to="host")
15
+ @int_in("Port", "device port", default=4321, maps_to="port")
16
+ async def connect(self, host: str, port: int) -> None: ...
17
+
18
+ Each parameter is named by its setting's ``maps_to``, else its display name,
19
+ and is filled from the saved value, else the declared default, else the
20
+ signature's own default, else the type's zero value -- the same fallback the
21
+ supervisor's settings form uses -- and coerced to the declared type, since a
22
+ value saved from a form may arrive as text.
23
+
24
+ Binding needs both the decoration and a parameter named for a declared setting.
25
+ The host has always passed a decorated ``connect`` the dict, whatever its one
26
+ parameter is called, so ``connect(self, settings)``, ``connect(self, config)``
27
+ and a parameterless ``connect(self)`` keep exactly the call they had.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import dataclasses
33
+ import inspect
34
+ from typing import TYPE_CHECKING
35
+
36
+ from lab_sync_driver_sdk.decorators import TYPE_BOOL
37
+ from lab_sync_driver_sdk.decorators import TYPE_FLOAT
38
+ from lab_sync_driver_sdk.decorators import TYPE_INT
39
+ from lab_sync_driver_sdk.decorators import TYPE_STRING
40
+ from lab_sync_driver_sdk.decorators import get_command_meta
41
+ from lab_sync_driver_sdk.decorators import is_settings_method
42
+
43
+ if TYPE_CHECKING:
44
+ from collections.abc import Callable
45
+ from collections.abc import Mapping
46
+
47
+ from lab_sync_driver_sdk.decorators import Param
48
+
49
+ # What the supervisor's command coercion accepts as a true boolean given as text.
50
+ _TRUTHY_TEXT = ("true", "yes", "1", "on")
51
+ _ZERO: dict[str, object] = {TYPE_INT: 0, TYPE_FLOAT: 0.0, TYPE_BOOL: False, TYPE_STRING: ""}
52
+
53
+
54
+ class SettingsBindingError(ValueError):
55
+ """The declared settings and `connect`'s parameters do not fit together, or a value will not coerce."""
56
+
57
+
58
+ def binds_settings(connect: Callable[..., object]) -> bool:
59
+ """Whether `connect` takes its settings as keyword arguments rather than as one dict.
60
+
61
+ Pass the bound method: an unbound function's `self` would count as a parameter.
62
+ """
63
+ meta = get_command_meta(connect)
64
+ if meta is None or not is_settings_method(connect):
65
+ return False
66
+ parameters = inspect.signature(connect).parameters.values()
67
+ named = {p.name for p in parameters if p.kind not in {p.VAR_POSITIONAL, p.VAR_KEYWORD}}
68
+ if not named:
69
+ return any(p.kind is p.VAR_KEYWORD for p in parameters)
70
+ return any((param.maps_to or param.name) in named for param in meta.inputs)
71
+
72
+
73
+ def described_settings(fn: Callable[..., object]) -> list[Param]:
74
+ """The settings `fn` declares, each defaulting to its parameter's default when it declares none.
75
+
76
+ The supervisor fills a setting nobody has saved with the declared default, else
77
+ the type's zero value, before it ever reaches the driver. So a default that
78
+ exists only in `connect`'s signature has to be described, or the form shows
79
+ 0 and `connect(timeout_s=7.5)` is handed 0.0. Undecorated carriers have no
80
+ parameters to read, so their settings come back unchanged.
81
+ """
82
+ meta = get_command_meta(fn)
83
+ if meta is None:
84
+ return []
85
+ parameters = inspect.signature(fn).parameters
86
+ described: list[Param] = []
87
+ for param in meta.inputs:
88
+ target = parameters.get(param.maps_to or param.name)
89
+ if (
90
+ param.default is None
91
+ and target is not None
92
+ and target.default is not target.empty
93
+ and target.default is not None
94
+ ):
95
+ param = dataclasses.replace(param, default=target.default) # noqa: PLW2901 - a copy, never the decorator's
96
+ described.append(param)
97
+ return described
98
+
99
+
100
+ def bind_settings(connect: Callable[..., object], saved: Mapping[str, object] | None) -> dict[str, object]:
101
+ """Build `connect`'s keyword arguments from the saved settings and their declarations.
102
+
103
+ Raises:
104
+ SettingsBindingError: A setting names a parameter `connect` does not have,
105
+ a required parameter has no setting, or a value does not fit its type.
106
+ """
107
+ declared = described_settings(connect)
108
+ parameters = inspect.signature(connect).parameters
109
+ accepts_any = any(p.kind is p.VAR_KEYWORD for p in parameters.values())
110
+ values = saved or {}
111
+
112
+ kwargs: dict[str, object] = {}
113
+ for param in declared:
114
+ kwarg = param.maps_to or param.name
115
+ target = parameters.get(kwarg)
116
+ if target is None and not accepts_any:
117
+ msg = f"setting {param.name!r} maps to {kwarg!r}, which connect() has no parameter for"
118
+ raise SettingsBindingError(msg)
119
+ # Keyed by display name on the wire; the kwarg spelling is accepted too.
120
+ value = values.get(param.name, values.get(kwarg))
121
+ if value is None:
122
+ value = param.default
123
+ if value is None:
124
+ if target is not None and target.default is not target.empty:
125
+ continue # the signature's own default applies
126
+ value = _ZERO.get(param.type, "")
127
+ kwargs[kwarg] = _coerce(param, value)
128
+
129
+ unbound = [
130
+ name
131
+ for name, p in parameters.items()
132
+ if p.kind in {p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY} and p.default is p.empty and name not in kwargs
133
+ ]
134
+ if unbound:
135
+ msg = f"connect() parameter(s) {', '.join(map(repr, unbound))} have no @settings declaration"
136
+ raise SettingsBindingError(msg)
137
+ return kwargs
138
+
139
+
140
+ def _coerce(param: Param, value: object) -> object:
141
+ try:
142
+ match param.type:
143
+ case "int":
144
+ # bool is an int subclass; a flag silently becoming 0/1 is a bug, not a value.
145
+ if isinstance(value, bool) or not isinstance(value, int | float | str):
146
+ raise TypeError(param.type)
147
+ return int(value)
148
+ case "float":
149
+ if isinstance(value, bool) or not isinstance(value, int | float | str):
150
+ raise TypeError(param.type)
151
+ return float(value)
152
+ case "bool":
153
+ if isinstance(value, str):
154
+ return value.strip().lower() in _TRUTHY_TEXT
155
+ return bool(value)
156
+ case "string":
157
+ return str(value)
158
+ case _:
159
+ return value
160
+ except (TypeError, ValueError) as exc:
161
+ msg = f"setting {param.name!r} expects {param.type}, got {value!r}"
162
+ raise SettingsBindingError(msg) from exc
@@ -0,0 +1,41 @@
1
+ """In-process test harness: drive a Device's command without the stdio protocol.
2
+
3
+ With the same output validation the host applies. Lets a driver author
4
+ unit-test commands.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import inspect
10
+ from collections.abc import Awaitable
11
+ from collections.abc import Callable
12
+ from typing import TYPE_CHECKING
13
+
14
+ from lab_sync_driver_sdk.decorators import validate_outputs
15
+
16
+ if TYPE_CHECKING:
17
+ from lab_sync_driver_sdk.device import Device
18
+
19
+ ProgressFn = Callable[[float, str], Awaitable[None]]
20
+
21
+
22
+ async def run_command(
23
+ device: Device,
24
+ name: str,
25
+ params: dict[str, object] | None = None,
26
+ *,
27
+ progress: ProgressFn | None = None,
28
+ ) -> dict[str, object]:
29
+ specs = type(device).command_specs()
30
+ if name not in specs:
31
+ msg = f"no such command: {name}"
32
+ raise KeyError(msg)
33
+ method_name, meta = specs[name]
34
+ method = getattr(device, method_name)
35
+ kwargs: dict[str, object] = {}
36
+ kwargs.update(params or {})
37
+ if progress is not None and "progress" in inspect.signature(method).parameters:
38
+ kwargs["progress"] = progress
39
+ result: dict[str, object] = await method(**kwargs)
40
+ validate_outputs(meta, result)
41
+ return result
@@ -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,13 @@
1
+ lab_sync_driver_sdk/__init__.py,sha256=oarZ4mkv-INByECcozf68s3kJA4CgQ7JOkRZQ67sC2M,1433
2
+ lab_sync_driver_sdk/decorators.py,sha256=qNqxg0CpLHxuQFgOslozz9mOmA8CNd0hzd4MAVvQuUA,13410
3
+ lab_sync_driver_sdk/device.py,sha256=123GNc05IktIFaHLceGatm2nmhjKFCY-S5IbvXk45Gg,10536
4
+ lab_sync_driver_sdk/host.py,sha256=GLhki8CcH9EwsZQgjRNJd2AWRQiPG97ftfVjcp3DH5U,29922
5
+ lab_sync_driver_sdk/lab_host.py,sha256=dLBWF8ActlQQvi2g8_BuQYP5iKGO74qXCRxFLWQFqbg,22852
6
+ lab_sync_driver_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ lab_sync_driver_sdk/settings_binding.py,sha256=IZ77Qr3hMjjbPJ0CQXG2WN67rPkqTMTsQIQuWbXxWyA,7084
8
+ lab_sync_driver_sdk/testing.py,sha256=2dTkyz85IM1ZS-cOMczGWsAnPCwZMew0r3yo_KXjWZY,1208
9
+ lab_sync_driver_sdk-0.3.0.dist-info/licenses/LICENSE,sha256=jm-ZYaMIQMfZVTbmqsfK44orj0ZnKzzsKwwJwq21xOI,1065
10
+ lab_sync_driver_sdk-0.3.0.dist-info/METADATA,sha256=YR-oz5bWrHdkuU88-lq-M2GAqxnlF2xrX-BqWtz3c_w,5293
11
+ lab_sync_driver_sdk-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
+ lab_sync_driver_sdk-0.3.0.dist-info/top_level.txt,sha256=Q_-3AaXxeJ0cCC3dq3r_W5wB189zkmt0OvlyX5os1qg,20
13
+ lab_sync_driver_sdk-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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 @@
1
+ lab_sync_driver_sdk