Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b09a72e
Use find_device from device endpoint
tpoliaw Dec 18, 2025
db238f9
WIP scruffy first pass at making a user client
tpoliaw Dec 17, 2025
94d925d
Cache all devices when first is accessed
tpoliaw Dec 18, 2025
ec06341
More client refactoring
tpoliaw Dec 18, 2025
fd7c3c6
Update CLI to use update client
tpoliaw Dec 18, 2025
404d903
Use "BlueapiClient" instead of Self for classmethods
tpoliaw Dec 18, 2025
764db05
Remove single dispatch stuff
tpoliaw Dec 19, 2025
8b5859f
Make name a public attribute of Plan
tpoliaw Dec 19, 2025
8733ac5
Add spans to plans and devices properties
tpoliaw Dec 19, 2025
557bd55
Remove dead comments
tpoliaw Dec 19, 2025
883e088
Make optional parameters clear in Plan repr
tpoliaw Dec 19, 2025
3717bcc
Update client and system tests to use new client changes
tpoliaw Dec 19, 2025
7797cec
Get oidc config via property
tpoliaw Jan 5, 2026
26b7149
Add getitem support for plans
tpoliaw Jan 5, 2026
8434f56
Correct mocking in cli test
tpoliaw Jan 6, 2026
02f6281
Up the coverage
tpoliaw Jan 8, 2026
97e42d5
Update system tests
tpoliaw Jan 8, 2026
85dba5e
Add repr for caches
tpoliaw Jan 9, 2026
09dfe61
Add ServiceUnavailableError to wrap requests errors
tpoliaw Jan 9, 2026
2585543
Create re-usable session for rest calls
tpoliaw Jan 9, 2026
bd03700
Change mock in tests
tpoliaw Jan 13, 2026
cde3970
Catch correct exception in system tests
tpoliaw Jan 13, 2026
453a8fe
Up the coverage
tpoliaw Jan 13, 2026
18efce3
Pass individual parameters to run command
tpoliaw Jan 12, 2026
1189e0a
Use hand-rolled arg parsing for plan parameters
tpoliaw Jan 12, 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
77 changes: 46 additions & 31 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 @@ -183,7 +183,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 @@ -204,7 +204,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 @@ -213,7 +213,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 @@ -254,19 +254,11 @@ def on_event(
input()


@controller.command(name="run")
@controller.command(name="run", context_settings={"ignore_unknown_options": True})
@click.argument("name", type=str)
@click.argument("parameters", type=str, required=False)
@click.option(
"--foreground/--background", "--fg/--bg", type=bool, is_flag=True, default=True
)
@click.option(
"-t",
"--timeout",
type=float,
help="Timeout for the plan in seconds. None hangs forever",
default=None,
)
@click.option(
"-i",
"--instrument-session",
Expand All @@ -277,29 +269,53 @@ def on_event(
the session must be valid and active and you must be a member of it."""),
required=True,
)
@click.argument("parameters", type=str, required=False, nargs=-1)
@click.pass_obj
@check_connection
def run_plan(
obj: dict,
name: str,
parameters: str | None,
timeout: float | None,
foreground: bool,
instrument_session: str,
parameters: list[str],
) -> None:
"""Run a plan with parameters"""

client: BlueapiClient = obj["client"]
client.instrument_session = instrument_session

plan = client.plans[name]

args = []
kwargs = {}
cur = None
for arg in parameters:
if arg.startswith("--"):
if cur:
kwargs[cur] = True
cur = arg[2:]
elif arg.startswith("-"):
if cur:
kwargs[cur] = True
cur = None
if len(arg) > 2:
kwargs[arg[1]] = arg[2:]
else:
cur = arg[1]
else:
if cur:
kwargs[cur] = json.loads(arg)
cur = None
else:
args.append(json.loads(arg))

parameters = parameters or "{}"
try:
parsed_params = json.loads(parameters) if isinstance(parameters, str) else {}
except json.JSONDecodeError as jde:
raise ClickException(f"Parameters are not valid JSON: {jde}") from jde
if cur:
kwargs[cur] = True

try:
task = TaskRequest(
name=name,
params=parsed_params,
params=plan._build_args(*args, **kwargs),
instrument_session=instrument_session,
)
except ValidationError as ve:
Expand Down Expand Up @@ -345,7 +361,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 @@ -428,7 +444,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 @@ -470,14 +486,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 @@ -100,11 +106,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 @@ -114,26 +122,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
Loading