Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
ff91bf5
Support *args in plan args
oliwenmandiamond Sep 9, 2026
b130973
Merge branch 'main' into support_star_args
oliwenmandiamond Sep 9, 2026
fc09b96
Add tests to cover *args case
oliwenmandiamond Sep 9, 2026
d638884
Simplify logic
oliwenmandiamond Sep 9, 2026
d3d45e1
Fix test
oliwenmandiamond Sep 9, 2026
44ce2fa
Use default_factory for args
oliwenmandiamond Sep 9, 2026
32cc60e
Propergate change
oliwenmandiamond Sep 9, 2026
42bad03
Fix lint
oliwenmandiamond Sep 9, 2026
29b05e4
fix SUBMIT_REQUEST
oliwenmandiamond Sep 9, 2026
adfa659
Correct doc string
oliwenmandiamond Sep 9, 2026
5983d91
Merge branch 'main' into support_star_args
oliwenmandiamond Sep 21, 2026
e26f0b0
Add backwards compatability
oliwenmandiamond Oct 12, 2026
23e09b6
Update task parameter test
oliwenmandiamond Oct 12, 2026
9b11a11
Merge branch 'main' into support_star_args
oliwenmandiamond Oct 12, 2026
461272d
Clean up
oliwenmandiamond Oct 12, 2026
9d13e6e
Merge branch 'support_star_args' of github.com:DiamondLightSource/blu…
oliwenmandiamond Oct 12, 2026
8d638c4
Merge branch 'main' into support_star_args
OliWenman Oct 13, 2026
f882f08
Fix typing
OliWenman Oct 13, 2026
a3e46ef
Update openapi
oliwenmandiamond Oct 14, 2026
88a1b48
Fix lint
oliwenmandiamond Oct 14, 2026
bcbd630
Add rest tak request v1 and v2
oliwenmandiamond Oct 14, 2026
2e9d6f1
Fix test
oliwenmandiamond Oct 14, 2026
6484e38
update all rest tests to use v1 and v2
oliwenmandiamond Oct 14, 2026
b430aa9
Add additional tests
oliwenmandiamond Oct 14, 2026
4fffc33
Minor corrections
oliwenmandiamond Oct 14, 2026
9f214f1
Update _build_args doc string
oliwenmandiamond Oct 15, 2026
8f5fa12
Improve _type_spec_for_function logic
oliwenmandiamond Oct 15, 2026
970f95c
Improve test to use args and kwargs
oliwenmandiamond Oct 16, 2026
05b4cfd
Update tests for protocol and rest_api to be stronger, cover v1 and v2
oliwenmandiamond Oct 16, 2026
38fb396
Add more combinations for system tests
oliwenmandiamond Oct 16, 2026
38c4835
Fix system tests, discovered blueapi bug where it freezes when plan g…
oliwenmandiamond Oct 16, 2026
48f0703
Fix annotation for main submit_task
oliwenmandiamond Oct 16, 2026
ab1a9a4
Update openapi.yaml
oliwenmandiamond Oct 16, 2026
7e368aa
Implement import feedback.
oliwenmandiamond Oct 16, 2026
e517e73
Add feedback, correctly raise ValidationError in do_task and add test
oliwenmandiamond Oct 16, 2026
5ff57de
Add comment why test is needed
oliwenmandiamond Oct 16, 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
67 changes: 54 additions & 13 deletions docs/reference/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -246,10 +246,8 @@ components:
title: Name
type: string
params:
additionalProperties: true
$ref: '#/components/schemas/TaskParametersV2'
description: Values for parameters to plan, if any
title: Params
type: object
required:
- name
title: Task
Expand All @@ -274,9 +272,22 @@ components:
- message
title: TaskError
type: object
TaskRequest:
TaskParametersV2:
Comment thread
oliwenmandiamond marked this conversation as resolved.
additionalProperties: false
description: Request to run a task with related info
properties:
args:
items: {}
title: Args
type: array
kwargs:
additionalProperties: true
title: Kwargs
type: object
title: TaskParametersV2
type: object
TaskRequestV1:
additionalProperties: false
description: Request to run a task with related info. Supports **kwargs only.
properties:
instrument_session:
description: Instrument session associated with this task
Expand All @@ -294,7 +305,27 @@ components:
required:
- name
- instrument_session
title: TaskRequest
title: TaskRequestV1
type: object
TaskRequestV2:
additionalProperties: false
description: Request to run a task with related info. Supports *args and **kwargs.
properties:
instrument_session:
description: Instrument session associated with this task
title: Instrument Session
type: string
name:
description: Name of plan to run
title: Name
type: string
params:
$ref: '#/components/schemas/TaskParametersV2'
description: Values for parameters to plan, if any
required:
- name
- instrument_session
title: TaskRequestV2
type: object
TaskResponse:
additionalProperties: false
Expand Down Expand Up @@ -449,7 +480,7 @@ info:
name: Apache 2.0
url: https://www.apache.org/licenses/LICENSE-2.0.html
title: BlueAPI Control
version: 1.5.0
version: 1.6.0
openapi: 3.1.0
paths:
/api/v1/devices:
Expand Down Expand Up @@ -641,13 +672,18 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/TaskRequest'
anyOf:
- $ref: '#/components/schemas/TaskRequestV2'
- $ref: '#/components/schemas/TaskRequestV1'
examples:
- instrument_session: cm12345-1
name: count
params:
detectors:
- x
args: []
kwargs:
detectors:
- x
title: Task Request
required: true
responses:
'201':
Expand Down Expand Up @@ -1042,13 +1078,18 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/TaskRequest'
anyOf:
- $ref: '#/components/schemas/TaskRequestV2'
- $ref: '#/components/schemas/TaskRequestV1'
examples:
- instrument_session: cm12345-1
name: count
params:
detectors:
- x
args: []
kwargs:
detectors:
- x
title: Task Request
required: true
responses:
'201':
Expand Down
55 changes: 36 additions & 19 deletions src/blueapi/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import stat
import sys
import textwrap
from collections.abc import Callable
from collections.abc import Callable, Mapping
from functools import wraps
from pathlib import Path
from pprint import pprint
Expand Down Expand Up @@ -38,17 +38,20 @@
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 DeviceResponse, PlanResponse, SourceInfo, TaskRequest
from blueapi.worker import ProgressEvent, WorkerEvent
from blueapi.service.model import (
Comment thread
oliwenmandiamond marked this conversation as resolved.
DeviceResponse,
PlanResponse,
SourceInfo,
TaskRequestV2,
)
from blueapi.worker import ProgressEvent, TaskParametersV2, WorkerEvent
from blueapi.worker.event import TaskError, TaskResult

from .scratch import setup_scratch
from .updates import CliEventRenderer

LOGGER = logging.getLogger(__name__)

TaskParameters = dict[str, Any]


class ParametersType(ParamType):
"""CLI input parameter to accept a JSON object as an argument"""
Expand All @@ -57,23 +60,36 @@ class ParametersType(ParamType):

def convert(
self,
value: str | dict[str, Any] | None,
value: str | Mapping[str, Any] | None,
param: Parameter | None,
ctx: Context | None,
) -> TaskParameters:
) -> TaskParametersV2:
Comment thread
oliwenmandiamond marked this conversation as resolved.
if value is None:
return TaskParametersV2()

if isinstance(value, str):
try:
params = json.loads(value)
if is_str_dict(params):
return params
self.fail("Parameters must be a JSON object with string keys")
value = json.loads(value)
except json.JSONDecodeError as jde:
self.fail(f"Parameters are not valid JSON: {jde}")
else:
return super().convert(value, param, ctx)
self.fail(f"Parameters are not valid JSON: {jde}", param, ctx)

if not isinstance(value, dict) or not all(
isinstance(key, str) for key in value
):
self.fail(
"Parameters must be a JSON object with string keys",
param,
ctx,
)
# V2 parameters have the shape {"args": [...], "kwargs": {...}}.
if value and set(value).issubset({"args", "kwargs"}):
return TaskParametersV2.model_validate(value)

# Otherwise, interpret the object as V1 keyword arguments.
return TaskParametersV2.from_v1(value)


def is_str_dict(val: Any) -> TypeGuard[TaskParameters]:
def is_str_dict(val: Any) -> TypeGuard[TaskParametersV2]:
return isinstance(val, dict) and all(isinstance(k, str) for k in val)


Expand Down Expand Up @@ -363,18 +379,19 @@ def run_plan(
foreground: bool,
ws: bool,
instrument_session: str,
parameters: TaskParameters,
parameters: TaskParametersV2,
) -> None:
"""Run a plan with parameters

To run in the foreground and block until it is complete, stomp
configuration or the experimental --ws flag is required. Without stomp
configuration, '--bg' can be used to start a plan in the background.
"""

client = cast(BlueapiClient, obj["client"])
task = TaskRequest(
name=name, params=parameters, instrument_session=instrument_session
task = TaskRequestV2(
name=name,
params=parameters,
instrument_session=instrument_session,
)

try:
Expand Down
10 changes: 5 additions & 5 deletions src/blueapi/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
PlanResponse,
PythonEnvironmentResponse,
SourceInfo,
TaskRequest,
TaskRequestV2,
TaskResponse,
TasksListResponse,
WorkerTask,
Expand Down Expand Up @@ -313,7 +313,7 @@ def get_active_task(self) -> WorkerTask:

@start_as_current_span(TRACER, "request")
def run_blocking(
self, request: TaskRequest, on_event: OnAnyEvent | None = None
self, request: TaskRequestV2, on_event: OnAnyEvent | None = None
) -> TaskStatus:
for event in self._rest.run_blocking(request):
if on_event is not None:
Expand All @@ -335,7 +335,7 @@ def run_blocking(
@start_as_current_span(TRACER, "task", "timeout")
def run_task(
self,
task: TaskRequest,
task: TaskRequestV2,
on_event: OnAnyEvent | None = None,
timeout: float | None = None,
) -> TaskStatus:
Expand Down Expand Up @@ -403,7 +403,7 @@ def inner_on_event(event: AnyEvent, ctx: MessageContext) -> None:
return complete.result(timeout=timeout)

@start_as_current_span(TRACER, "task")
def create_and_start_task(self, task: TaskRequest) -> TaskResponse:
def create_and_start_task(self, task: TaskRequestV2) -> TaskResponse:
"""
Create a new task and instruct the worker to start it
immediately.
Expand All @@ -429,7 +429,7 @@ def create_and_start_task(self, task: TaskRequest) -> TaskResponse:

@start_as_current_span(TRACER, "task")
@deprecated("rest client")
def create_task(self, task: TaskRequest) -> TaskResponse:
def create_task(self, task: TaskRequestV2) -> TaskResponse:
"""
Create a new task, does not start execution

Expand Down
48 changes: 20 additions & 28 deletions src/blueapi/client/plan_cache.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import logging
from typing import Any

from blueapi.service.model import PlanModel, TaskRequest
from blueapi.service.model import PlanModel, TaskParametersV2, TaskRequestV2
from blueapi.worker.event import TaskError, TaskResult, TaskStatus

from .protocols import ClientProtocol
Expand All @@ -26,8 +26,6 @@ def __init__(self, typ: str, message: str):


class PlanCache:
"""Collection of plans that can be accessed by name or attribute."""

def __init__(self, client: ClientProtocol, plans: list[PlanModel]):
self._cache = {
model.name: Plan(name=model.name, model=model, client=client)
Expand All @@ -54,7 +52,7 @@ def __repr__(self) -> str:
class Plan:
"""Callable client-side reference to a registered BlueAPI plan."""

def __init__(self, name: str, model: PlanModel, client: ClientProtocol):
def __init__(self, name, model: PlanModel, client: ClientProtocol):
self.name = name
self.model = model
self._client = client
Expand All @@ -63,9 +61,6 @@ def __init__(self, name: str, model: PlanModel, client: ClientProtocol):
def __call__(self, *args, **kwargs) -> Any:
"""Execute the plan with the supplied arguments.

Positional arguments are mapped to parameters in the order defined by
the plan's parameter schema. Keyword arguments are passed by name.

Returns:
The result returned by the plan.

Expand All @@ -74,7 +69,7 @@ def __call__(self, *args, **kwargs) -> Any:
TypeError: If the supplied arguments do not match the plan
parameter schema.
"""
req = TaskRequest(
req = TaskRequestV2(
name=self.name,
params=self._build_args(*args, **kwargs),
instrument_session=self._client.instrument_session,
Expand All @@ -97,12 +92,11 @@ def properties(self) -> dict[str, Any]:
def required(self) -> list[str]:
return self.model.parameter_schema.get("required", [])

def _build_args(self, *args, **kwargs) -> dict[str, Any]:
"""Build a parameter mapping from positional and keyword arguments.
def _build_args(self, *args: Any, **kwargs: Any) -> TaskParametersV2:
"""Check parameters against schema and build TaskParametersV2.

Positional arguments are assigned to parameters according to their
order in the plan's parameter schema. Keyword arguments are then
added by name.
Returns:
TaskParametersV2

Raises:
TypeError: If too many positional arguments, unexpected keyword
Expand All @@ -115,28 +109,26 @@ def _build_args(self, *args, **kwargs) -> dict[str, Any]:
args,
kwargs,
)
properties = list(self.properties)

if len(args) > len(self.properties):
if len(args) > len(properties):
raise TypeError(f"{self.name} got too many arguments")
if extra := {k for k in kwargs if k not in self.properties}:

if extra := {k for k in kwargs if k not in properties}:
raise TypeError(f"{self.name} got unexpected arguments: {extra}")

params = {}
# Initially fill parameters using positional args assuming the order
# from the parameter_schema
for req, arg in zip(self.properties, args, strict=False):
params[req] = arg
positional_names = properties[: len(args)]

if duplicate := set(positional_names) & kwargs.keys():
name = next(iter(duplicate))
raise TypeError(f"{self.name} got multiple values for {name}")

# Then append any values given via kwargs
for key, value in kwargs.items():
# If we've already assumed a positional arg was this value, bail out
if key in params:
raise TypeError(f"{self.name} got multiple values for {key}")
params[key] = value
supplied = set(positional_names) | kwargs.keys()

if missing := {k for k in self.required if k not in params}:
if missing := set(self.required) - supplied:
raise TypeError(f"Missing argument(s) for {missing}")
return params

return TaskParametersV2(args=args, kwargs=kwargs)

def __repr__(self) -> str:
"""Return a signature-like representation of the plan.
Expand Down
4 changes: 2 additions & 2 deletions src/blueapi/client/protocols.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Protocol

from blueapi.service.model import DeviceModel, DeviceResponse, TaskRequest
from blueapi.service.model import DeviceModel, DeviceResponse, TaskRequestV2
from blueapi.worker.event import TaskStatus

from .event_bus import OnAnyEvent
Expand All @@ -12,7 +12,7 @@
class ClientProtocol(Protocol):
def run_task(
self,
task: TaskRequest,
task: TaskRequestV2,
on_event: OnAnyEvent | None = None,
timeout: float | None = None,
) -> TaskStatus: ...
Expand Down
Loading
Loading