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,543 @@
1
+ """Host a ``lab-device-sdk`` Device over the stdio driver protocol.
2
+
3
+ A ``lab-device-sdk`` driver is written to be called in-process. This adapter
4
+ wraps such a Device instance and presents the SDK host's structural
5
+ contract -- translating lab command/param metadata and wire type names -- so
6
+ the unchanged ``lab_sync_driver_sdk.host`` serves it out-of-process, with no rewrite of
7
+ the driver. Install the ``lab`` extra (``pip install "lab-sync-driver-sdk[lab]"``).
8
+
9
+ This host serves exactly one device, so it has to be told which instance it is.
10
+ A manifest declares that with the supervisor's ``{device_id}`` token rather than
11
+ a hardcoded id -- that is what lets a site run several devices of one type (the
12
+ supervisor appends ``--http-port <n>`` when the driver serves routes)::
13
+
14
+ python -m lab_sync_driver_sdk.lab_host --device my_pkg.my_device:MyDevice \
15
+ --device-id {device_id}
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import asyncio
22
+ import base64
23
+ import contextlib
24
+ import functools
25
+ import importlib
26
+ import inspect
27
+ import json
28
+ import logging
29
+ import os
30
+ from contextlib import asynccontextmanager
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import TYPE_CHECKING
34
+ from typing import cast
35
+
36
+ from lab_sync_driver_protocol import CONFIGURATION_ENV
37
+
38
+ from lab_sync_driver_sdk.decorators import TYPE_STRING
39
+ from lab_sync_driver_sdk.decorators import CommandMeta
40
+ from lab_sync_driver_sdk.decorators import Param
41
+ from lab_sync_driver_sdk.decorators import TelemetryMeta
42
+ from lab_sync_driver_sdk.decorators import get_telemetry_meta
43
+ from lab_sync_driver_sdk.host import Hostable
44
+ from lab_sync_driver_sdk.host import claim_stdout
45
+ from lab_sync_driver_sdk.host import serve
46
+ from lab_sync_driver_sdk.host import valid_duration
47
+
48
+ if TYPE_CHECKING:
49
+ from collections.abc import AsyncGenerator
50
+ from collections.abc import Awaitable
51
+ from collections.abc import Callable
52
+
53
+ from fastapi import APIRouter
54
+ from fastapi import FastAPI
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+ # lab-device-sdk emits these wire type names; the driver protocol expects the short forms.
59
+ _LAB_TYPE_MAP = {
60
+ "integer": "int",
61
+ "floating_point": "float",
62
+ "boolean": "bool",
63
+ "string": "string",
64
+ }
65
+
66
+
67
+ def _wire_type(lab_type: str) -> str:
68
+ if not lab_type:
69
+ return TYPE_STRING
70
+ mapped = _LAB_TYPE_MAP.get(lab_type)
71
+ if mapped is None:
72
+ logger.warning("lab driver param type %r has no wire mapping; defaulting to string", lab_type)
73
+ return TYPE_STRING
74
+ return mapped
75
+
76
+
77
+ _IS_COMMAND = "_is_command"
78
+
79
+
80
+ def _params(raw: object) -> list[Param]:
81
+ return [
82
+ Param(
83
+ name=str(entry.get("name", "")),
84
+ description=str(entry.get("description", "")),
85
+ type=_wire_type(str(entry.get("type", ""))),
86
+ options=_options(entry.get("options")),
87
+ dynamic_options=bool(entry.get("dynamic_options", False)),
88
+ maps_to=str(entry["maps_to"]) if entry.get("maps_to") is not None else None,
89
+ default=entry.get("default"),
90
+ min_value=cast("float | None", entry["min_value"]) if entry.get("min_value") is not None else None,
91
+ max_value=cast("float | None", entry["max_value"]) if entry.get("max_value") is not None else None,
92
+ string_type=str(entry["string_type"]) if entry.get("string_type") is not None else None,
93
+ type_details=str(entry["type_details"]) if entry.get("type_details") is not None else None,
94
+ allow_empty=bool(entry.get("allow_empty", False)),
95
+ group=str(entry.get("group", "")),
96
+ )
97
+ for entry in cast("list[dict[str, object]]", raw or [])
98
+ ]
99
+
100
+
101
+ def _options(raw: object) -> list[str] | None:
102
+ if not raw:
103
+ return None
104
+ return [str(option) for option in cast("list[object]", raw)]
105
+
106
+
107
+ def _marshal(result: object, out_names: list[str]) -> dict[str, object]:
108
+ """Shape a lab command's raw return into the SDK's dict-output contract.
109
+
110
+ lab commands return scalars/strings/None or tuples; the SDK host expects a dict keyed by the declared output params and validates against
111
+ them.
112
+ """
113
+ if isinstance(result, dict):
114
+ return cast("dict[str, object]", result)
115
+ if not out_names:
116
+ return {}
117
+ if len(out_names) == 1:
118
+ return {out_names[0]: result}
119
+ if isinstance(result, (list, tuple)):
120
+ items: list[object] = list(cast("list[object]", result))
121
+ if len(items) == len(out_names):
122
+ return {name: items[i] for i, name in enumerate(out_names)}
123
+ return {out_names[0]: result}
124
+
125
+
126
+ _SIM_DEFAULT: dict[str, object] = {"int": 0, "float": 0.0, "bool": False}
127
+ _SIM_PROGRESS_STEPS = 4
128
+
129
+
130
+ def _sim_default(param: Param) -> object:
131
+ return _SIM_DEFAULT.get(param.type, "")
132
+
133
+
134
+ async def _sim_run(seconds: float, progress: object, outputs: list[Param]) -> dict[str, object]:
135
+ """Fake a command: pace out default_sim_sec (streaming progress), return typed defaults."""
136
+ reporter = cast("Callable[[float, str], Awaitable[None]] | None", progress if callable(progress) else None)
137
+ for i in range(_SIM_PROGRESS_STEPS):
138
+ await asyncio.sleep(seconds / _SIM_PROGRESS_STEPS if seconds > 0 else 0)
139
+ if reporter is not None:
140
+ await reporter((i + 1) / _SIM_PROGRESS_STEPS, "simulating")
141
+ return {p.name: _sim_default(p) for p in outputs}
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class _InputSpec:
146
+ declared: str # the name on the wire (the describe entry's `name`)
147
+ kwarg: str # the method's actual parameter (lab `maps_to`)
148
+ default: object
149
+ has_default: bool
150
+
151
+
152
+ def _input_specs(raw: object) -> list[_InputSpec]:
153
+ specs: list[_InputSpec] = []
154
+ for entry in cast("list[dict[str, object]]", raw or []):
155
+ name = str(entry.get("name", ""))
156
+ maps_to = entry.get("maps_to")
157
+ specs.append(
158
+ _InputSpec(
159
+ declared=name,
160
+ kwarg=str(maps_to) if maps_to else name,
161
+ default=entry.get("default"),
162
+ has_default="default" in entry,
163
+ )
164
+ )
165
+ return specs
166
+
167
+
168
+ def _resolve_inputs(inputs: list[_InputSpec], provided: dict[str, object]) -> list[tuple[_InputSpec, object]]:
169
+ """Each declared input's value for one call, its declared default when omitted.
170
+
171
+ The SDK host has already remapped declared names to `maps_to` by the
172
+ time a wrapper runs, while a direct caller passes declared names, so
173
+ either spelling is accepted (declared first).
174
+ """
175
+ values: list[tuple[_InputSpec, object]] = []
176
+ for spec in inputs:
177
+ if spec.declared in provided:
178
+ values.append((spec, provided[spec.declared]))
179
+ elif spec.kwarg in provided:
180
+ values.append((spec, provided[spec.kwarg]))
181
+ elif spec.has_default:
182
+ values.append((spec, spec.default))
183
+ return values
184
+
185
+
186
+ async def _sim_seconds(
187
+ sim_time: Callable[[dict[str, object]], object], declared: dict[str, object], fallback: float
188
+ ) -> float:
189
+ """How long to pace a simulated call: the device's own estimate, else `fallback`."""
190
+ try:
191
+ answer = sim_time(declared)
192
+ if inspect.isawaitable(answer):
193
+ answer = await answer
194
+ except Exception: # an estimate must never fail the simulated command
195
+ logger.exception("simulation time hook failed; using default_sim_sec")
196
+ return fallback
197
+ seconds = valid_duration(answer)
198
+ return fallback if seconds is None else seconds
199
+
200
+
201
+ def _wrap_command(
202
+ method: Callable[..., Awaitable[object]],
203
+ inputs: list[_InputSpec],
204
+ outputs: list[Param],
205
+ is_simulated: Callable[[], bool],
206
+ sim_time: Callable[[dict[str, object]], object] | None = None,
207
+ ) -> Callable[..., Awaitable[dict[str, object]]]:
208
+ """Bridge the SDK's call convention to a lab command.
209
+
210
+ Remap declared input names to the method's kwargs (lab `maps_to`), apply
211
+ declared defaults, then marshal the raw return into the studio dict-output
212
+ contract. When simulated, skip the hardware call and return typed defaults
213
+ unless the command opts into `handles_simulation` -- pacing out the
214
+ device's `get_dynamic_simulation_time_sec` for these inputs when `sim_time`
215
+ is given, as lab-device-sdk's own host did, else `default_sim_sec`.
216
+ """
217
+ out_names = [p.name for p in outputs]
218
+ handles_sim = bool(getattr(method, "_cmd_handles_simulation", False))
219
+ sim_sec = float(getattr(method, "_cmd_default_sim_sec", 3.0))
220
+
221
+ @functools.wraps(method)
222
+ async def wrapper(**provided: object) -> dict[str, object]:
223
+ values = _resolve_inputs(inputs, provided)
224
+ if is_simulated() and not handles_sim:
225
+ # The hook is keyed by declared (display) name, as `estimate_time` asks it.
226
+ declared = {spec.declared: value for spec, value in values}
227
+ seconds = sim_sec if sim_time is None else await _sim_seconds(sim_time, declared, sim_sec)
228
+ return await _sim_run(seconds, provided.get("progress"), outputs)
229
+ kwargs: dict[str, object] = {spec.kwarg: value for spec, value in values}
230
+ if "progress" in provided:
231
+ kwargs["progress"] = provided["progress"]
232
+ return _marshal(await method(**kwargs), out_names)
233
+
234
+ return wrapper
235
+
236
+
237
+ def _telemetry_specs(device: object) -> dict[str, tuple[str, TelemetryMeta]]:
238
+ """Build SDK telemetry specs from a lab Device's decorated methods.
239
+
240
+ The mirror of `_command_specs`, and it is what makes `@telemetry` usable
241
+ from a driver written against lab-device-sdk rather than lab_sync_driver_sdk. The
242
+ decorator is lab_sync_driver_sdk's either way; only the base class differs.
243
+ """
244
+ specs: dict[str, tuple[str, TelemetryMeta]] = {}
245
+ for attr in dir(device):
246
+ fn = getattr(device, attr, None)
247
+ meta = get_telemetry_meta(fn) if fn is not None else None
248
+ if meta is None or not meta.channel:
249
+ continue
250
+ specs[meta.channel] = (attr, meta)
251
+ return specs
252
+
253
+
254
+ def _command_specs(device: object) -> dict[str, tuple[str, CommandMeta]]:
255
+ """Build SDK command specs from a lab Device's decorated methods."""
256
+ specs: dict[str, tuple[str, CommandMeta]] = {}
257
+ for attr in dir(device):
258
+ fn = getattr(device, attr, None)
259
+ if fn is None or not getattr(fn, _IS_COMMAND, False):
260
+ continue
261
+ name = str(getattr(fn, "_cmd_name", "") or attr)
262
+ raw_sim_sec = getattr(fn, "_cmd_default_sim_sec", None)
263
+ meta = CommandMeta(
264
+ name=name,
265
+ description=str(getattr(fn, "_cmd_desc", "")),
266
+ inputs=_params(getattr(fn, "_params_in", None)),
267
+ outputs=_params(getattr(fn, "_params_out", None)),
268
+ category=str(getattr(fn, "_cmd_category", "") or ""),
269
+ operator_visible=bool(getattr(fn, "_cmd_operator_visible", True)),
270
+ default_sim_sec=float(cast("float", raw_sim_sec)) if raw_sim_sec is not None else None,
271
+ # lab-device-sdk 0.1.12 stamps `_cmd_operation_phase`; older releases and
272
+ # hand-rolled metadata used the bare name. Read both so neither is dropped.
273
+ operation_phase=str(getattr(fn, "_cmd_operation_phase", None) or getattr(fn, "operation_phase", "") or ""),
274
+ handles_simulation=bool(getattr(fn, "_cmd_handles_simulation", False)),
275
+ # `@command(..., exclusive=False)` marks a command that may run beside an
276
+ # exclusive one (a light toggled while motion commands run, say).
277
+ # Dropping the flag would have the supervisor serialise them behind every move.
278
+ exclusive=bool(getattr(fn, "_cmd_exclusive", True)),
279
+ concurrent=bool(getattr(fn, "_cmd_concurrent", False)),
280
+ )
281
+ specs[name] = (attr, meta)
282
+ return specs
283
+
284
+
285
+ class LabDeviceAdapter:
286
+ """Wrap a lab-device-sdk Device so the SDK host can serve it.
287
+
288
+ Attribute access (command methods, lifecycle) delegates to the wrapped device.
289
+ """
290
+
291
+ def __init__(self, device: object, config: dict[str, object] | None = None) -> None:
292
+ super().__init__()
293
+ self._device = device
294
+ self._config = config or {}
295
+ self._is_simulated = False
296
+ cls = type(device)
297
+ self.device_type_id = str(getattr(cls, "device_type_id", ""))
298
+ self.manufacturer = str(getattr(cls, "manufacturer", ""))
299
+ self.model = str(getattr(cls, "model", ""))
300
+ self._specs = _command_specs(device)
301
+ # Bind a bridging wrapper for each command so the host's
302
+ # getattr(device, method)(**declared) maps to the lab method + dict output.
303
+ hook = getattr(device, "get_dynamic_simulation_time_sec", None)
304
+ for attr, meta in self._specs.values():
305
+ method = cast("Callable[..., Awaitable[object]]", getattr(device, attr))
306
+ inputs = _input_specs(getattr(getattr(device, attr), "_params_in", None))
307
+ sim_time = functools.partial(hook, meta.name) if callable(hook) else None
308
+ setattr(
309
+ self,
310
+ attr,
311
+ _wrap_command(method, inputs, meta.outputs, lambda: self._is_simulated, sim_time),
312
+ )
313
+
314
+ def command_specs(self) -> dict[str, tuple[str, CommandMeta]]:
315
+ return self._specs
316
+
317
+ def telemetry_specs(self) -> dict[str, tuple[str, TelemetryMeta]]:
318
+ """The demand-gated samplers the wrapped device declares, if any."""
319
+ return _telemetry_specs(self._device)
320
+
321
+ def settings_specs(self) -> list[dict[str, object]]:
322
+ """Wire descriptors for the device's settings (its @settings connect params)."""
323
+ connect = getattr(self._device, "connect", None)
324
+ raw = getattr(connect, "_params_in", None) if connect is not None else None
325
+ specs: list[dict[str, object]] = []
326
+ for entry in cast("list[dict[str, object]]", raw or []):
327
+ spec: dict[str, object] = {
328
+ "name": str(entry.get("name", "")),
329
+ "description": str(entry.get("description", "")),
330
+ "type": _wire_type(str(entry.get("type", ""))),
331
+ "dynamic_options": bool(entry.get("dynamic_options", False)),
332
+ }
333
+ options = _options(entry.get("options"))
334
+ if options is not None:
335
+ spec["options"] = options
336
+ if entry.get("maps_to") is not None:
337
+ spec["maps_to"] = str(entry["maps_to"])
338
+ if "default" in entry:
339
+ spec["default"] = entry["default"]
340
+ for key in ("min_value", "max_value", "string_type", "type_details", "allow_empty", "group"):
341
+ if entry.get(key) is not None:
342
+ spec[key] = entry[key]
343
+ specs.append(spec)
344
+ return specs
345
+
346
+ def picture(self) -> str | None:
347
+ """Return the device image as base64, resolved from the driver's picture_url."""
348
+ # The instance's, not the class's: a driver with configurations picks its
349
+ # picture per variant in `__init__`.
350
+ url = str(getattr(self._device, "picture_url", "") or "")
351
+ if not url:
352
+ return None
353
+ module = importlib.import_module(type(self._device).__module__)
354
+ module_file = getattr(module, "__file__", None)
355
+ if module_file is None:
356
+ return None
357
+ path = (Path(module_file).parent / url).resolve()
358
+ if not path.is_file():
359
+ return None
360
+ return base64.b64encode(path.read_bytes()).decode("ascii")
361
+
362
+ async def connect(self, settings: dict[str, object] | None = None) -> None:
363
+ # The protocol's connect carries one settings dict; lab connect is an @settings method
364
+ # with explicit params. Bind them from configuration, then apply any
365
+ # per-instance settings the user saved (keyed by the declared name -> maps_to).
366
+ if self._is_simulated:
367
+ return
368
+ connect = getattr(self._device, "connect", None)
369
+ if connect is None:
370
+ return
371
+ params = inspect.signature(connect).parameters
372
+ raw = cast("list[dict[str, object]]", getattr(connect, "_params_in", None) or [])
373
+ kwarg_by_name = {str(e.get("name", "")): str(e.get("maps_to") or e.get("name", "")) for e in raw}
374
+
375
+ # Start from the values the driver declares on its own @settings params.
376
+ # A lab connect takes them as *required* positional arguments, so any the
377
+ # operator has not overridden must still be supplied -- otherwise a device
378
+ # whose settings were never edited cannot connect at all.
379
+ kwargs: dict[str, object] = {}
380
+ for entry in raw:
381
+ kwarg = kwarg_by_name.get(str(entry.get("name", "")), "")
382
+ if kwarg in params and entry.get("default") is not None:
383
+ kwargs[kwarg] = entry["default"]
384
+
385
+ kwargs.update({name: self._config[name] for name in params if name in self._config})
386
+ for declared, value in (settings or {}).items():
387
+ kwarg = kwarg_by_name.get(declared, declared)
388
+ if kwarg in params:
389
+ kwargs[kwarg] = value
390
+ await connect(**kwargs)
391
+
392
+ async def set_simulation(self, *, enabled: bool) -> None:
393
+ """Gate hardware at the adapter; also poke the wrapped device if it tracks sim itself."""
394
+ self._is_simulated = enabled
395
+ setter = getattr(self._device, "set_simulation_mode", None)
396
+ if callable(setter):
397
+ with contextlib.suppress(TypeError):
398
+ _ = setter(enabled=enabled)
399
+ else:
400
+ with contextlib.suppress(AttributeError):
401
+ setattr(self._device, "is_simulated", enabled)
402
+
403
+ def __getattr__(self, name: str) -> object:
404
+ """Delegate lifecycle methods to the wrapped device."""
405
+ if name.startswith("_"):
406
+ raise AttributeError(name)
407
+ return getattr(object.__getattribute__(self, "_device"), name)
408
+
409
+
410
+ def _load_device(target: str, device_id: str, configuration: str | None) -> object:
411
+ module_name, _, class_name = target.partition(":")
412
+ if not class_name:
413
+ msg = f"--device must be 'module:Class', got {target!r}"
414
+ raise SystemExit(msg)
415
+ module = importlib.import_module(module_name)
416
+ cls = getattr(module, class_name)
417
+ return cls(device_id, configuration)
418
+
419
+
420
+ class _LocalHub:
421
+ """Hub for the driver's own routes to resolve through.
422
+
423
+ Out-of-process, the driver process hosts exactly one device, so every lookup returns it -- the same `device_hub.get_device(id)` contract an
424
+ in-process hub provides.
425
+ """
426
+
427
+ def __init__(self, device: object) -> None:
428
+ super().__init__()
429
+ self._device = device
430
+
431
+ def get_device(self, device_id: str) -> object:
432
+ del device_id # single-device process: every lookup returns the hosted device
433
+ return self._device
434
+
435
+
436
+ def _http_app(device: object, routes_target: str) -> FastAPI | None:
437
+ """Build the driver's FastAPI app from its `get_routers(hub, state)` factory.
438
+
439
+ Inject a local hub. Returns None if the driver declares no routes.
440
+ """
441
+ module_name, _, factory_name = routes_target.partition(":")
442
+ try:
443
+ routes_module = importlib.import_module(module_name)
444
+ except ModuleNotFoundError:
445
+ return None
446
+ get_routers = getattr(routes_module, factory_name or "get_routers", None)
447
+ if get_routers is None:
448
+ return None
449
+
450
+ from fastapi import FastAPI # noqa: PLC0415
451
+
452
+ get_hooks = getattr(routes_module, "get_lifespan_hooks", None)
453
+ hooks = cast("dict[str, Callable[[], Awaitable[None]]]", get_hooks()) if get_hooks else {}
454
+ startup = hooks.get("startup")
455
+ # An in-process host runs a driver's `shutdown` hook when the driver is
456
+ # replaced or the host stops; here the process ends when the supervisor
457
+ # closes stdin, but a driver holding sockets or a session open to hardware
458
+ # still wants to say goodbye rather than be dropped.
459
+ shutdown = hooks.get("shutdown")
460
+
461
+ @asynccontextmanager
462
+ async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
463
+ if startup is not None:
464
+ await startup()
465
+ try:
466
+ yield
467
+ finally:
468
+ if shutdown is not None:
469
+ await shutdown()
470
+
471
+ app = FastAPI(lifespan=lifespan)
472
+ routers = cast("list[APIRouter]", get_routers(_LocalHub(device), {}))
473
+ for router in routers:
474
+ app.include_router(router)
475
+ return app
476
+
477
+
478
+ async def _serve_all(
479
+ device: Hostable,
480
+ *,
481
+ http_app: FastAPI | None,
482
+ http_port: int,
483
+ host: str,
484
+ driver_version: str,
485
+ ) -> None:
486
+ # Claim fd 1 for the protocol before uvicorn starts so its access logs (which
487
+ # default to stdout) cannot corrupt frames; serve() then reuses that stream.
488
+ out = claim_stdout()
489
+ if http_app is None:
490
+ await serve(device, driver_version=driver_version, out=out)
491
+ return
492
+ import uvicorn # noqa: PLC0415
493
+
494
+ config = uvicorn.Config(http_app, host=host, port=http_port, log_level="warning", log_config=None)
495
+ server = uvicorn.Server(config)
496
+
497
+ async def _run_stdio() -> None:
498
+ try:
499
+ await serve(device, driver_version=driver_version, out=out)
500
+ finally:
501
+ server.should_exit = True # stdin EOF ends the protocol -> stop the HTTP server so the process exits
502
+
503
+ _ = await asyncio.gather(_run_stdio(), server.serve())
504
+
505
+
506
+ def main() -> None:
507
+ parser = argparse.ArgumentParser(prog="lab_sync_driver_sdk.lab_host")
508
+ _ = parser.add_argument("--device", required=True, help="import path 'module:Class'")
509
+ _ = parser.add_argument("--device-id", required=True)
510
+ _ = parser.add_argument("--config", default=None, help="JSON: connect settings to bind")
511
+ _ = parser.add_argument(
512
+ "--configuration",
513
+ default=os.environ.get(CONFIGURATION_ENV) or None,
514
+ help=f"hardware variant to construct the device as (default: ${CONFIGURATION_ENV})",
515
+ )
516
+ _ = parser.add_argument("--http-port", type=int, default=None, help="serve driver routes here")
517
+ _ = parser.add_argument("--routes", default=None, help="routes factory 'module:get_routers' (default <pkg>.routes)")
518
+ _ = parser.add_argument("--host", default="127.0.0.1", help="bind host for the HTTP routes")
519
+ _ = parser.add_argument("--driver-version", default="0.0.0")
520
+ args = parser.parse_args()
521
+
522
+ config = cast("dict[str, object]", json.loads(args.config)) if args.config else {}
523
+ device = _load_device(args.device, args.device_id, args.configuration)
524
+ adapter = LabDeviceAdapter(device, config)
525
+
526
+ http_app = None
527
+ if args.http_port is not None:
528
+ top_pkg = args.device.split(":", 1)[0].split(".", 1)[0]
529
+ http_app = _http_app(device, args.routes or f"{top_pkg}.routes:get_routers")
530
+
531
+ asyncio.run(
532
+ _serve_all(
533
+ adapter,
534
+ http_app=http_app,
535
+ http_port=args.http_port or 0,
536
+ host=args.host,
537
+ driver_version=args.driver_version,
538
+ )
539
+ )
540
+
541
+
542
+ if __name__ == "__main__":
543
+ main()
File without changes