Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 12 additions & 2 deletions docs/reference/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,11 @@ components:
additionalProperties: false
description: Task that will run a plan
properties:
args:
description: Positional arguments to plan
items: {}
title: Args
type: array
metadata:
additionalProperties: true
description: Any metadata to apply to all runs within this task
Expand All @@ -247,7 +252,7 @@ components:
type: string
params:
additionalProperties: true
description: Values for parameters to plan, if any
description: Keyword arguments to plan, if any
title: Params
type: object
required:
Expand Down Expand Up @@ -278,6 +283,11 @@ components:
additionalProperties: false
description: Request to run a task with related info
properties:
args:
description: Positional arguments to plan
items: {}
title: Args
type: array
instrument_session:
description: Instrument session associated with this task
title: Instrument Session
Expand Down Expand Up @@ -449,7 +459,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
38 changes: 37 additions & 1 deletion src/blueapi/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,26 @@ def convert(
return super().convert(value, param, ctx)


class PositionalArgType(ParamType):
"""CLI input parameter to accept a JSON value as a list of positional args"""

name = "PositionalArgs"

def convert(
self, value: Any | None, param: Parameter | None, ctx: Context | None
) -> Any:
if isinstance(value, str):
try:
args = json.loads(value)
if isinstance(args, list):
return args
self.fail("Positional arguments should be a list of arguments")
except json.JSONDecodeError as jde:
self.fail(f"Argument is not valid JSON: {jde}")
else:
return super().convert(value, param, ctx)


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

Expand Down Expand Up @@ -321,6 +341,21 @@ def on_event(
@controller.command(name="run")
@click.argument("name", type=str)
@click.argument("parameters", type=ParametersType(), default={}, required=False)
@click.option(
"-a",
"--args",
type=PositionalArgType(),
default=[],
required=False,
help=textwrap.dedent("""\
Arguments to be passed to the plan as positional arguments.

This should be passed as a single string representing a JSON array and
may need quoting in some shells, eg

--args '[1, 2, "foo"]'
"""),
)
@click.option(
"--ws/--stomp",
type=bool,
Expand Down Expand Up @@ -366,6 +401,7 @@ def run_plan(
foreground: bool,
ws: bool | None,
instrument_session: str,
args: list[Any],
parameters: TaskParameters,
) -> None:
"""Run a plan with parameters
Expand All @@ -377,7 +413,7 @@ def run_plan(

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

try:
Expand Down
58 changes: 11 additions & 47 deletions src/blueapi/client/plan_cache.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging
from typing import Any

from blueapi.client.rest import InvalidParametersError
from blueapi.service.model import PlanModel, TaskRequest
from blueapi.worker.event import TaskError, TaskResult, TaskStatus

Expand Down Expand Up @@ -76,14 +77,18 @@ def __call__(self, *args, **kwargs) -> Any:
"""
req = TaskRequest(
name=self.name,
params=self._build_args(*args, **kwargs),
args=list(args),
params=kwargs,
instrument_session=self._client.instrument_session,
)
match self._client.run_task(req):
case TaskStatus(result=TaskResult(result=res)):
return res
case TaskStatus(result=TaskError(type=typ, message=msg)):
raise PlanFailedError(typ, msg)
try:
match self._client.run_task(req):
case TaskStatus(result=TaskResult(result=res)):
return res
case TaskStatus(result=TaskError(type=typ, message=msg)):
raise PlanFailedError(typ, msg)
except InvalidParametersError as ipe:
raise TypeError(ipe.message()) from None

@property
def help_text(self) -> str:
Expand All @@ -97,47 +102,6 @@ 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.

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

Raises:
TypeError: If too many positional arguments, unexpected keyword
arguments, duplicate arguments, or required arguments are
supplied incorrectly.
"""
log.info(
"Building args for %s, using %s and %s",
"[" + ",".join(self.properties) + "]",
args,
kwargs,
)

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

# 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

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

def __repr__(self) -> str:
"""Return a signature-like representation of the plan.

Expand Down
4 changes: 3 additions & 1 deletion src/blueapi/client/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,12 @@ def __str__(self) -> str:
return f"Missing value for {self.field()!r}"
case "extra_forbidden":
return f"Unexpected field {self.field()!r}"
case _:
case _ if self.loc[2:]:
return (
f"Invalid value {self.input!r} for field {self.field()}: {self.msg}"
)
case _:
return self.msg or "Unknown error"


class InvalidParametersError(Exception):
Expand Down
2 changes: 1 addition & 1 deletion src/blueapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ class ApplicationConfig(BlueapiBaseModel):
"""

#: API version to publish in OpenAPI schema
REST_API_VERSION: ClassVar[str] = "1.5.0"
REST_API_VERSION: ClassVar[str] = "1.6.0"

LICENSE_INFO: ClassVar[dict[str, str]] = {
"name": "Apache 2.0",
Expand Down
21 changes: 18 additions & 3 deletions src/blueapi/core/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ def _type_spec_for_function(

Returns:
Mapping of {name: (type, default)} to be used by pydantic for deserialising
function arguments
function arguments
"""
args = signature(func).parameters
types = get_type_hints(func)
Expand All @@ -451,7 +451,16 @@ def _type_spec_for_function(
f"Type annotation is required for '{name}' in '{func.__name__}'"
)

no_default = para.default is Parameter.empty
match para.kind:
case Parameter.VAR_POSITIONAL:
arg_type = tuple[arg_type, ...]
case Parameter.VAR_KEYWORD:
arg_type = dict[str, arg_type]

no_default = para.default is Parameter.empty and para.kind not in (
Parameter.VAR_POSITIONAL,
Parameter.VAR_KEYWORD,
)
if (
isclass(arg_type)
and (issubclass(arg_type, BaseModel) or is_dataclass(arg_type))
Expand All @@ -462,9 +471,15 @@ def _type_spec_for_function(
info = FieldInfo(default_factory=default_factory)
else:
_type = self._convert_type(arg_type, no_default)

match para.default:
case Parameter.empty:
info = FieldInfo(default_factory=None)
if para.kind is Parameter.VAR_POSITIONAL:
info = FieldInfo(default_factory=tuple)
elif para.kind is Parameter.VAR_KEYWORD:
info = FieldInfo(default_factory=dict)
else:
info = FieldInfo(default_factory=None)
case None:
info = FieldInfo(default_factory=lambda: None)
case _:
Expand Down
1 change: 1 addition & 0 deletions src/blueapi/service/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ def submit_task(
]
task = Task(
name=task_request.name,
args=task_request.args,
params=task_request.params,
metadata=metadata,
)
Expand Down
15 changes: 10 additions & 5 deletions src/blueapi/service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,12 +361,17 @@ def submit_task(
}
for err in e.errors
]

LOGGER.info("Error submitting task: %s - %s", task_request, e)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=errors,
) from e
except Exception as e:
# Catch all for anything else that can go wrong
errors = [
{"loc": ["body", "params"], "msg": str(e), "type": "invalid", "input": None}
]
LOGGER.info("Error submitting task: %s - %s", task_request, e)
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=errors,
)


@secure_router_v1.delete(
Expand Down
5 changes: 5 additions & 0 deletions src/blueapi/service/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ class TaskRequest(BlueapiBaseModel):
"""

name: str = Field(description="Name of plan to run")
args: list[Any] = Field(
description="Positional arguments to plan",
default_factory=list,
exclude_if=lambda args: not args,
)
params: Mapping[str, Any] = Field(
description="Values for parameters to plan, if any", default_factory=dict
)
Comment thread
oliwenmandiamond marked this conversation as resolved.
Resolved
Expand Down
Loading
Loading