Skip to content

Commit f8a9f27

Browse files
author
Ware, Joseph (DLSLtd,RAL,LSCI)
committed
feat: Add depth parameter to get_devices
1 parent 39c7371 commit f8a9f27

10 files changed

Lines changed: 163 additions & 32 deletions

File tree

docs/reference/openapi.yaml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ components:
340340
type: object
341341
info:
342342
title: BlueAPI Control
343-
version: 0.0.10
343+
version: 0.0.11
344344
openapi: 3.1.0
345345
paths:
346346
/config/oidc:
@@ -361,13 +361,32 @@ paths:
361361
get:
362362
description: Retrieve information about all available devices.
363363
operationId: get_devices_devices_get
364+
parameters:
365+
- description: Maximum depth of children to return
366+
in: query
367+
name: depth
368+
required: false
369+
schema:
370+
anyOf:
371+
- type: integer
372+
- const: all
373+
type: string
374+
default: 0
375+
ge: 0
376+
title: Depth
364377
responses:
365378
'200':
366379
content:
367380
application/json:
368381
schema:
369382
$ref: '#/components/schemas/DeviceResponse'
370383
description: Successful Response
384+
'422':
385+
content:
386+
application/json:
387+
schema:
388+
$ref: '#/components/schemas/HTTPValidationError'
389+
description: Validation Error
371390
summary: Get Devices
372391
/devices/{name}:
373392
get:

src/blueapi/cli/cli.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from functools import wraps
77
from pathlib import Path
88
from pprint import pprint
9+
from typing import Literal
910

1011
import click
1112
from bluesky.callbacks.best_effort import BestEffortCallback
@@ -165,11 +166,18 @@ def get_plans(obj: dict) -> None:
165166

166167
@controller.command(name="devices")
167168
@check_connection
169+
@click.option(
170+
"-d",
171+
"--depth",
172+
type=click.IntRange(min=0) | Literal["all"],
173+
required=False,
174+
help="Maximum depth of children to return",
175+
)
168176
@click.pass_obj
169-
def get_devices(obj: dict) -> None:
177+
def get_devices(obj: dict, depth: int | Literal["all"] = 0) -> None:
170178
"""Get a list of devices available for the worker to use"""
171179
client: BlueapiClient = obj["client"]
172-
obj["fmt"].display(client.get_devices())
180+
obj["fmt"].display(client.get_devices(depth))
173181

174182

175183
@controller.command(name="listen")

src/blueapi/client/client.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import time
22
from concurrent.futures import Future
3+
from typing import Literal
34

45
from bluesky_stomp.messaging import MessageContext, StompClient
56
from bluesky_stomp.models import Broker
@@ -91,15 +92,15 @@ def get_plan(self, name: str) -> PlanModel:
9192
return self._rest.get_plan(name)
9293

9394
@start_as_current_span(TRACER)
94-
def get_devices(self) -> DeviceResponse:
95+
def get_devices(self, depth: int | Literal["all"]) -> DeviceResponse:
9596
"""
9697
List devices available
9798
9899
Returns:
99100
DeviceResponse: Devices that can be used in plans
100101
"""
101102

102-
return self._rest.get_devices()
103+
return self._rest.get_devices(depth)
103104

104105
@start_as_current_span(TRACER, "name")
105106
def get_device(self, name: str) -> DeviceModel:

src/blueapi/client/rest.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,10 @@ def get_plans(self) -> PlanResponse:
7171
def get_plan(self, name: str) -> PlanModel:
7272
return self._request_and_deserialize(f"/plans/{name}", PlanModel)
7373

74-
def get_devices(self) -> DeviceResponse:
75-
return self._request_and_deserialize("/devices", DeviceResponse)
74+
def get_devices(self, depth: int | Literal["all"]) -> DeviceResponse:
75+
return self._request_and_deserialize(
76+
"/devices", DeviceResponse, params={"depth": depth}
77+
)
7678

7779
def get_device(self, name: str) -> DeviceModel:
7880
return self._request_and_deserialize(f"/devices/{name}", DeviceModel)

src/blueapi/service/interface.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
from collections.abc import Mapping
33
from functools import cache
4-
from typing import Any
4+
from typing import Any, Literal
55

66
from bluesky_stomp.messaging import StompClient
77
from bluesky_stomp.models import Broker, DestinationBase, MessageTopic
@@ -176,9 +176,14 @@ def get_plan(name: str) -> PlanModel:
176176
return PlanModel.from_plan(context().plans[name])
177177

178178

179-
def get_devices() -> list[DeviceModel]:
179+
def get_devices(depth: int | Literal["all"]) -> list[DeviceModel]:
180180
"""Get all available devices in the BlueskyContext"""
181-
return [DeviceModel.from_device(device) for device in context().devices.values()]
181+
return [
182+
model
183+
for device in context().devices.values()
184+
for model in DeviceModel.from_device_tree(device, depth)
185+
if model.protocols
186+
]
182187

183188

184189
def get_device(name: str) -> DeviceModel:

src/blueapi/service/main.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from collections.abc import Awaitable, Callable
22
from contextlib import asynccontextmanager
3-
from typing import Annotated
3+
from typing import Annotated, Literal
44

55
import jwt
66
from fastapi import (
@@ -10,6 +10,7 @@
1010
Depends,
1111
FastAPI,
1212
HTTPException,
13+
Query,
1314
Request,
1415
Response,
1516
status,
@@ -53,7 +54,7 @@
5354
from .runner import WorkerDispatcher
5455

5556
#: API version to publish in OpenAPI schema
56-
REST_API_VERSION = "0.0.10"
57+
REST_API_VERSION = "0.0.11"
5758

5859
RUNNER: WorkerDispatcher | None = None
5960

@@ -228,9 +229,18 @@ def get_plan_by_name(
228229
@start_as_current_span(TRACER)
229230
def get_devices(
230231
runner: Annotated[WorkerDispatcher, Depends(_runner)],
232+
depth: Annotated[
233+
int | Literal["all"],
234+
Query(
235+
description="Maximum depth of children to return",
236+
ge=0,
237+
# https://github.com/fastapi/fastapi/discussions/13473
238+
json_schema_extra={"description": None},
239+
),
240+
] = 0,
231241
) -> DeviceResponse:
232242
"""Retrieve information about all available devices."""
233-
devices = runner.run(interface.get_devices)
243+
devices = runner.run(interface.get_devices, depth)
234244
return DeviceResponse(devices=devices)
235245

236246

src/blueapi/service/model.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import uuid
22
from collections.abc import Iterable
33
from enum import Enum
4-
from typing import Annotated, Any
4+
from typing import Annotated, Any, Literal
55

66
from bluesky.protocols import HasName
7+
from ophyd import Device as SyncDevice
8+
from ophyd_async.core import Device as AsyncDevice
79
from pydantic import Field
810
from pydantic.json_schema import SkipJsonSchema
911

@@ -40,10 +42,37 @@ def from_device(cls, device: Device) -> "DeviceModel":
4042
name = device.name if isinstance(device, HasName) else _UNKNOWN_NAME
4143
return cls(name=name, protocols=list(_protocol_info(device)))
4244

45+
@classmethod
46+
def from_device_tree(
47+
cls, root: Device, max_depth: int | Literal["all"]
48+
) -> list["DeviceModel"]:
49+
if max_depth == 0:
50+
return [cls.from_device(root)]
51+
if isinstance(root, AsyncDevice):
52+
# Breadth-first iteration through child devices, stopping at max_depth
53+
async_devices: list[AsyncDevice] = [root]
54+
branches: list[AsyncDevice] = [child[1] for child in root.children()]
55+
depth = 0
56+
while (max_depth == "all" or depth < max_depth) and branches:
57+
async_devices += branches
58+
branches = [
59+
child[1] for branch in branches for child in branch.children()
60+
]
61+
depth += 1
62+
return [cls.from_device(device) for device in async_devices]
63+
elif isinstance(root, SyncDevice):
64+
sync_devices: list[SyncDevice] = [root]
65+
# Depth-first iteration through components, keeping any below max_depth
66+
for component in root.walk_components():
67+
if max_depth == "all" or len(component.ancestors) <= max_depth:
68+
sync_devices.append(component.item)
69+
return [cls.from_device(device) for device in sync_devices]
70+
return [cls.from_device(root)]
71+
4372

4473
def _protocol_info(device: Device) -> Iterable[ProtocolInfo]:
4574
for protocol in BLUESKY_PROTOCOLS:
46-
if isinstance(device, protocol):
75+
if isinstance(device, protocol) and protocol is not AsyncDevice:
4776
yield ProtocolInfo(
4877
name=protocol.__name__,
4978
types=[arg.__name__ for arg in generic_bounds(device, protocol)],

tests/system_tests/test_blueapi_system.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ def test_get_non_existent_plan(client: BlueapiClient):
162162

163163

164164
def test_get_devices(client: BlueapiClient, expected_devices: DeviceResponse):
165-
retrieved_devices = client.get_devices()
165+
retrieved_devices = client.get_devices(depth=0)
166166
retrieved_devices.devices.sort(key=lambda x: x.name)
167167
expected_devices.devices.sort(key=lambda x: x.name)
168168

tests/unit_tests/client/test_client.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import uuid
22
from collections.abc import Callable
3+
from typing import Literal
34
from unittest.mock import MagicMock, Mock, call, patch
45

56
import pytest
@@ -39,6 +40,21 @@
3940
DeviceModel(name="bar", protocols=[]),
4041
]
4142
)
43+
DEVICES_AND_CHILDREN = DeviceResponse(
44+
devices=[
45+
DeviceModel(name="foo", protocols=[]),
46+
DeviceModel(name="bar", protocols=[]),
47+
DeviceModel(name="foo-bar", protocols=[]),
48+
]
49+
)
50+
DEVICES_AND_ALL_DESCENDENTS = DeviceResponse(
51+
devices=[
52+
DeviceModel(name="foo", protocols=[]),
53+
DeviceModel(name="bar", protocols=[]),
54+
DeviceModel(name="foo-bar", protocols=[]),
55+
DeviceModel(name="foo-bar-baz", protocols=[]),
56+
]
57+
)
4258
DEVICE = DeviceModel(name="foo", protocols=[])
4359
TASK = TrackableTask(task_id="foo", task=Task(name="bar", params={}))
4460
TASKS = TasksListResponse(tasks=[TASK])
@@ -67,11 +83,18 @@
6783

6884
@pytest.fixture
6985
def mock_rest() -> BlueapiRestClient:
86+
def get_devices(depth: int | Literal["all"]) -> DeviceResponse:
87+
if depth == "all" or depth > 1:
88+
return DEVICES_AND_ALL_DESCENDENTS
89+
if depth == 1:
90+
return DEVICES_AND_CHILDREN
91+
return DEVICES
92+
7093
mock = Mock(spec=BlueapiRestClient)
7194

7295
mock.get_plans.return_value = PLANS
7396
mock.get_plan.return_value = PLAN
74-
mock.get_devices.return_value = DEVICES
97+
mock.get_devices.side_effect = get_devices
7598
mock.get_device.return_value = DEVICE
7699
mock.get_state.return_value = WorkerState.IDLE
77100
mock.get_task.return_value = TASK
@@ -121,7 +144,7 @@ def test_get_nonexistant_plan(
121144

122145

123146
def test_get_devices(client: BlueapiClient):
124-
assert client.get_devices() == DEVICES
147+
assert client.get_devices(depth=0) == DEVICES
125148

126149

127150
def test_get_device(client: BlueapiClient):
@@ -511,7 +534,7 @@ def test_get_plan_span_ok(exporter: JsonObjectSpanExporter, client: BlueapiClien
511534

512535
def test_get_devices_span_ok(exporter: JsonObjectSpanExporter, client: BlueapiClient):
513536
with asserting_span_exporter(exporter, "get_devices"):
514-
client.get_devices()
537+
client.get_devices(depth=0)
515538

516539

517540
def test_get_device_span_ok(exporter: JsonObjectSpanExporter, client: BlueapiClient):

0 commit comments

Comments
 (0)