Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
96d7f5c
WIP scruffy first pass at making a user client
tpoliaw Dec 17, 2025
aa12a9f
Cache all devices when first is accessed
tpoliaw Dec 18, 2025
8f05ed6
More client refactoring
tpoliaw Dec 18, 2025
6db67e1
Update CLI to use update client
tpoliaw Dec 18, 2025
27cbf71
Use "BlueapiClient" instead of Self for classmethods
tpoliaw Dec 18, 2025
9264fbf
Remove single dispatch stuff
tpoliaw Dec 19, 2025
83be7e4
Make name a public attribute of Plan
tpoliaw Dec 19, 2025
7010566
Add spans to plans and devices properties
tpoliaw Dec 19, 2025
367012a
Remove dead comments
tpoliaw Dec 19, 2025
ad59c5d
Make optional parameters clear in Plan repr
tpoliaw Dec 19, 2025
87a3262
Update client and system tests to use new client changes
tpoliaw Dec 19, 2025
ed2448d
Get oidc config via property
tpoliaw Jan 5, 2026
7d0ec71
Add getitem support for plans
tpoliaw Jan 5, 2026
7576e63
Correct mocking in cli test
tpoliaw Jan 6, 2026
570fb1e
Up the coverage
tpoliaw Jan 8, 2026
a609794
Update system tests
tpoliaw Jan 8, 2026
9ce37d9
Add repr for caches
tpoliaw Jan 9, 2026
e1498cf
Add ServiceUnavailableError to wrap requests errors
tpoliaw Jan 9, 2026
2cd93a5
Create re-usable session for rest calls
tpoliaw Jan 9, 2026
742d62b
Change mock in tests
tpoliaw Jan 13, 2026
2f01427
Catch correct exception in system tests
tpoliaw Jan 13, 2026
390ab9c
Up the coverage
tpoliaw Jan 13, 2026
05662ed
Add callback support
tpoliaw Jan 14, 2026
c0bfcd8
Test fluent instrument_session setter
tpoliaw Jan 14, 2026
4e40c93
Replace removed BlueapiClient methods
tpoliaw Feb 5, 2026
9b1e0f0
Add login method to BlueapiClient
tpoliaw Feb 5, 2026
33685b7
Add test for deprecated decorator
tpoliaw Feb 6, 2026
082c591
Add tests back for the deprecated methods
tpoliaw Feb 6, 2026
cb9ed3e
Replace scripting-plans docs
tpoliaw Feb 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions docs/tutorials/scripting-plans.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# Scripting Plans

While the CLI can be used to query devices and run plans, it can be useful to
combine multiple plans within a better interface than bash/shell scripting.

For this, `blueapi` can be used as a library providing a `BlueapiClient`
wrapping interactions with the server.

## Login to blueapi

The following steps require the user to have logged in blueapi. This can be done
via the `blueapi login` command.

## Create an instance of the client

```python
from blueapi.client.client import BlueapiClient

# A client can be created from either a config instance or the path to a config
# file. The minimal configuration required # is:
# api:
# url: https://address.of.blueapi:1234
# stomp:
# enabled: true
# url: tcp://address.of.rabbitmq:61613
bc = BlueapiClient.from_config_file("/path/to/config.yaml")
```

Plans and devices are available via the `plans` and `devices` attributes of the
client. It can be useful to alias these locally to reduce the boilerplate in
scripts.

```python
plans = bc.plans
devices = bc.devices
```

## Query devices

The devices available on the server are accessible via the `devices` attribute
of the client.

```python
for device in bc.devices:
print(device)
```

Individual devices can be accessed as attributes on the `devices` field. It can
also be useful to alias these locally.

```python
det = bc.devices.det
stage = bc.devices.stage
```

Child devices can be accessed via their parent devices

```python
stage_x = stage.x
```

Trying to access a child device that does not exist will raise an
`AttributeError`

## Run a plan

Plans are accessible via the `plans` attribute of the client instance. They can,
for the most part, be treated as if they were local functions.

```python
bc.plans.count([bc.devices.det], num=3, delay=4.2)
```

Running a plan in this way will block until the plan is complete. If the script
is interrupted (eg via Ctrl-C) while a plan is running it will be aborted before
the script exits.

Where parameters to a plan are optional, they can be omitted from the method
call. Where parameters are required, they can be passed either as positional or
named arguments.

## Run multiple plans

Plans can then be co-ordinated using standard python constructs, eg to run a
plan multiple times

```python
for temp in range(1, 5):
plans.set_absolute({devices.temp: temp})
plans.count([devices.det])
```

## Passing more complex arguments

Anything passed to a plan function will be serialized into JSON before being
sent to the server. For many types you can pass the instance directly and the
serialization should handle the conversion for you.

```python
from scanspec.specs import Line

bc.plans.spec_scan(detectors=[det], spec=Line(bc.devices.stage.x, 0, 10, 11))
```

if a type does not serialize correctly, passing the JSON equivalent should be
possible instead. For instance the above is equivalent to

```python
bc.plans.spec_scan(detectors=[det], spec={
"axis": "stage.x",
"start": 0.0,
"stop": 10.0,
"num": 11,
"type": "Line"})
```

## Add callbacks

By default there is no indication of progress while a scan is running however it
is possible to subscribe to events so that updates can be provided.

A callback should accept a single parameter which will be the event from server.
This will be one of `WorkerEvent`, `ProgressEvent` or `DataEvent`.

An example that prints data for each point could be something like

```python
def feedback(evt):
match evt:
case DataEvent(name="start"):
print("Run started")
case DataEvent(name="stop", doc={"exit_status": status}):
print("Run complete: ", status)
case DataEvent(name="event", doc={"seq_num": point, "data": data}):
print(f" Point {point}: {data}")

bc.add_callback(feedback)

bc.plans.spec_scan([bc.devices.det], Line(bc.devices.stage.x, 0, 1, 11))
```

The above prints the following as the scan progresses

```
Run started
Point 1: {'stage-x': 0.0}
Point 2: {'stage-x': 0.1}
Point 3: {'stage-x': 0.2}
Point 4: {'stage-x': 0.3}
Point 5: {'stage-x': 0.4}
Point 6: {'stage-x': 0.5}
Point 7: {'stage-x': 0.6}
Point 8: {'stage-x': 0.7000000000000001}
Point 9: {'stage-x': 0.8}
Point 10: {'stage-x': 0.9}
Point 11: {'stage-x': 1.0}
Run complete: success
```

The `add_callback` method returns an ID that can be used to remove the callback

```python
# Add the callback and record the handle
hnd = bc.add_callback(callback_function)

# remove the callback using the returned handle
bc.remove_callback(hnd)
```
27 changes: 13 additions & 14 deletions src/blueapi/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
from click.exceptions import ClickException
from observability_utils.tracing import setup_tracing
from pydantic import ValidationError
from requests.exceptions import ConnectionError

from blueapi import __version__, config
from blueapi.cli.format import OutputFormat
Expand All @@ -26,6 +25,7 @@
from blueapi.client.rest import (
BlueskyRemoteControlError,
InvalidParametersError,
ServiceUnavailableError,
UnauthorisedAccessError,
UnknownPlanError,
)
Expand All @@ -36,7 +36,7 @@
from blueapi.core import OTLP_EXPORT_ENABLED, DataEvent
from blueapi.log import set_up_logging
from blueapi.service.authentication import SessionCacheManager, SessionManager
from blueapi.service.model import SourceInfo, TaskRequest
from blueapi.service.model import DeviceResponse, PlanResponse, SourceInfo, TaskRequest
from blueapi.worker import ProgressEvent, WorkerEvent

from .scratch import setup_scratch
Expand Down Expand Up @@ -185,7 +185,7 @@ def check_connection(func: Callable[P, T]) -> Callable[P, T]:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
try:
return func(*args, **kwargs)
except ConnectionError as ce:
except ServiceUnavailableError as ce:
raise ClickException(
"Failed to establish connection to blueapi server."
) from ce
Expand All @@ -206,7 +206,7 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
def get_plans(obj: dict) -> None:
"""Get a list of plans available for the worker to use"""
client: BlueapiClient = obj["client"]
obj["fmt"].display(client.get_plans())
obj["fmt"].display(PlanResponse(plans=[p.model for p in client.plans]))


@controller.command(name="devices")
Expand All @@ -215,7 +215,7 @@ def get_plans(obj: dict) -> None:
def get_devices(obj: dict) -> None:
"""Get a list of devices available for the worker to use"""
client: BlueapiClient = obj["client"]
obj["fmt"].display(client.get_devices())
obj["fmt"].display(DeviceResponse(devices=[dev.model for dev in client.devices]))


@controller.command(name="listen")
Expand Down Expand Up @@ -347,7 +347,7 @@ def get_state(obj: dict) -> None:
"""Print the current state of the worker"""

client: BlueapiClient = obj["client"]
print(client.get_state().name)
print(client.state.name)


@controller.command(name="pause")
Expand Down Expand Up @@ -430,7 +430,7 @@ def env(
status = client.reload_environment(timeout=timeout)
print("Environment is initialized")
else:
status = client.get_environment()
status = client.environment
print(status)


Expand Down Expand Up @@ -472,14 +472,13 @@ def login(obj: dict) -> None:
print("Logged in")
except Exception:
client = BlueapiClient.from_config(config)
oidc_config = client.get_oidc_config()
if oidc_config is None:
if oidc := client.oidc_config:
auth = SessionManager(
oidc, cache_manager=SessionCacheManager(config.auth_token_path)
)
auth.start_device_flow()
else:
print("Server is not configured to use authentication!")
return
auth = SessionManager(
oidc_config, cache_manager=SessionCacheManager(config.auth_token_path)
)
auth.start_device_flow()


@main.command(name="logout")
Expand Down
68 changes: 40 additions & 28 deletions src/blueapi/cli/format.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@

from blueapi.core.bluesky_types import DataEvent
from blueapi.service.model import (
DeviceModel,
DeviceResponse,
PlanModel,
PlanResponse,
PythonEnvironmentResponse,
SourceInfo,
Expand Down Expand Up @@ -54,17 +56,21 @@ def display_full(obj: Any, stream: Stream):
match obj:
case PlanResponse(plans=plans):
for plan in plans:
print(plan.name)
if desc := plan.description:
print(indent(dedent(desc).strip(), " "))
if schema := plan.parameter_schema:
print(" Schema")
print(indent(json.dumps(schema, indent=2), " "))
display_full(plan, stream)
case PlanModel(name=name, description=desc, parameter_schema=schema):
print(name)
if desc:
print(indent(dedent(desc).strip(), " "))
if schema:
print(" Schema")
print(indent(json.dumps(schema, indent=2), " "))
case DeviceResponse(devices=devices):
for dev in devices:
print(dev.name)
for proto in dev.protocols:
print(f" {proto}")
display_full(dev, stream)
case DeviceModel(name=name, protocols=protocols):
print(name)
for proto in protocols:
print(f" {proto}")
case DataEvent(name=name, doc=doc):
print(f"{name.title()}:{fmt_dict(doc)}")
case WorkerEvent(state=st, task_status=task):
Expand Down Expand Up @@ -98,11 +104,13 @@ def display_json(obj: Any, stream: Stream):
print = partial(builtins.print, file=stream)
match obj:
case PlanResponse(plans=plans):
print(json.dumps([p.model_dump() for p in plans], indent=2))
display_json(plans, stream)
case DeviceResponse(devices=devices):
print(json.dumps([d.model_dump() for d in devices], indent=2))
display_json(devices, stream)
case BaseModel():
print(json.dumps(obj.model_dump()))
case list():
print(json.dumps([it.model_dump() for it in obj], indent=2))
case _:
print(json.dumps(obj))

Expand All @@ -112,26 +120,30 @@ def display_compact(obj: Any, stream: Stream):
match obj:
case PlanResponse(plans=plans):
for plan in plans:
print(plan.name)
if desc := plan.description:
print(indent(dedent(desc.split("\n\n")[0].strip("\n")), " "))
if schema := plan.parameter_schema:
print(" Args")
for arg, spec in schema.get("properties", {}).items():
req = arg in schema.get("required", {})
print(f" {arg}={_describe_type(spec, req)}")
display_compact(plan, stream)
case PlanModel(name=name, description=desc, parameter_schema=schema):
print(name)
if desc:
print(indent(dedent(desc.split("\n\n")[0].strip("\n")), " "))
if schema:
print(" Args")
for arg, spec in schema.get("properties", {}).items():
req = arg in schema.get("required", {})
print(f" {arg}={_describe_type(spec, req)}")
case DeviceResponse(devices=devices):
for dev in devices:
print(dev.name)
print(
indent(
textwrap.fill(
", ".join(str(proto) for proto in dev.protocols),
80,
),
" ",
)
display_compact(dev, stream)
case DeviceModel(name=name, protocols=protocols):
print(name)
print(
indent(
textwrap.fill(
", ".join(str(proto) for proto in protocols),
80,
),
" ",
)
)
case DataEvent(name=name):
print(f"Data Event: {name}")
case WorkerEvent(state=state):
Expand Down
Loading