Skip to content

Commit 11cec15

Browse files
committed
feat!: Remove non-cli logic from cli.run_plan
Argument parsing has been moved into a click option validator, and task creation has been moved into the client create_task methods. This is a breaking change in the BlueapiClient as task methods now accept plan name and parameters as separate arguments.
1 parent d9a0084 commit 11cec15

File tree

4 files changed

+69
-50
lines changed

4 files changed

+69
-50
lines changed

src/blueapi/cli/cli.py

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,21 @@
66
from functools import wraps
77
from pathlib import Path
88
from pprint import pprint
9+
from typing import Any
910

1011
import click
1112
from bluesky.callbacks.best_effort import BestEffortCallback
1213
from bluesky_stomp.messaging import MessageContext, StompClient
1314
from bluesky_stomp.models import Broker
15+
from click.core import Context, Parameter
1416
from click.exceptions import ClickException
17+
from click.types import ParamType
1518
from observability_utils.tracing import setup_tracing
16-
from pydantic import ValidationError
1719
from requests.exceptions import ConnectionError
1820

1921
from blueapi import __version__, config
2022
from blueapi.cli.format import OutputFormat
21-
from blueapi.client.client import BlueapiClient
23+
from blueapi.client.client import BlueapiClient, TaskParameters
2224
from blueapi.client.event_bus import AnyEvent, BlueskyStreamingError, EventBusClient
2325
from blueapi.client.rest import (
2426
BlueskyRemoteControlError,
@@ -34,12 +36,27 @@
3436
from blueapi.log import set_up_logging
3537
from blueapi.service.authentication import SessionCacheManager, SessionManager
3638
from blueapi.service.model import SourceInfo
37-
from blueapi.worker import ProgressEvent, Task, WorkerEvent
39+
from blueapi.worker import ProgressEvent, WorkerEvent
3840

3941
from .scratch import setup_scratch
4042
from .updates import CliEventRenderer
4143

4244

45+
class ParametersType(ParamType):
46+
name = "TaskParameters"
47+
48+
def convert(
49+
self, value: Any, param: Parameter | None, ctx: Context | None
50+
) -> TaskParameters:
51+
if isinstance(value, str):
52+
try:
53+
return json.loads(value)
54+
except json.JSONDecodeError as jde:
55+
self.fail(f"Parameters are not valid JSON: {jde}")
56+
else:
57+
return super().convert(value, param, ctx)
58+
59+
4360
@click.group(invoke_without_command=True)
4461
@click.version_option(version=__version__, prog_name="blueapi")
4562
@click.option(
@@ -218,7 +235,7 @@ def on_event(
218235

219236
@controller.command(name="run")
220237
@click.argument("name", type=str)
221-
@click.argument("parameters", type=str, required=False)
238+
@click.argument("parameters", type=ParametersType(), default={}, required=False)
222239
@click.option(
223240
"--foreground/--background", "--fg/--bg", type=bool, is_flag=True, default=True
224241
)
@@ -234,25 +251,13 @@ def on_event(
234251
def run_plan(
235252
obj: dict,
236253
name: str,
237-
parameters: str | None,
238254
timeout: float | None,
239255
foreground: bool,
256+
parameters: TaskParameters,
240257
) -> None:
241258
"""Run a plan with parameters"""
242259
client: BlueapiClient = obj["client"]
243260

244-
parameters = parameters or "{}"
245-
try:
246-
parsed_params = json.loads(parameters) if isinstance(parameters, str) else {}
247-
except json.JSONDecodeError as jde:
248-
raise ClickException(f"Parameters are not valid JSON: {jde}") from jde
249-
250-
try:
251-
task = Task(name=name, params=parsed_params)
252-
except ValidationError as ve:
253-
ip = InvalidParameters.from_validation_error(ve)
254-
raise ClickException(ip.message()) from ip
255-
256261
try:
257262
if foreground:
258263
progress_bar = CliEventRenderer()
@@ -264,12 +269,12 @@ def on_event(event: AnyEvent) -> None:
264269
elif isinstance(event, DataEvent):
265270
callback(event.name, event.doc)
266271

267-
resp = client.run_task(task, on_event=on_event)
272+
resp = client.run_task(name, parameters, on_event=on_event)
268273

269274
if resp.task_status is not None and not resp.task_status.task_failed:
270275
print("Plan Succeeded")
271276
else:
272-
server_task = client.create_and_start_task(task)
277+
server_task = client.create_and_start_task(name, parameters)
273278
click.echo(server_task.task_id)
274279
except config.MissingStompConfiguration as mse:
275280
raise ClickException(*mse.args) from mse

src/blueapi/client/client.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import time
22
from concurrent.futures import Future
3+
from typing import Any
34

45
from bluesky_stomp.messaging import MessageContext, StompClient
56
from bluesky_stomp.models import Broker
67
from observability_utils.tracing import (
78
get_tracer,
89
start_as_current_span,
910
)
11+
from pydantic import ValidationError
1012

1113
from blueapi.config import ApplicationConfig, MissingStompConfiguration
1214
from blueapi.core.bluesky_types import DataEvent
@@ -28,10 +30,12 @@
2830
from blueapi.worker.event import ProgressEvent, TaskStatus
2931

3032
from .event_bus import AnyEvent, BlueskyStreamingError, EventBusClient, OnAnyEvent
31-
from .rest import BlueapiRestClient, BlueskyRemoteControlError
33+
from .rest import BlueapiRestClient, BlueskyRemoteControlError, InvalidParameters
3234

3335
TRACER = get_tracer("client")
3436

37+
TaskParameters = dict[str, Any]
38+
3539

3640
class BlueapiClient:
3741
"""Unified client for controlling blueapi"""
@@ -194,10 +198,12 @@ def get_active_task(self) -> WorkerTask:
194198

195199
return self._rest.get_active_task()
196200

197-
@start_as_current_span(TRACER, "task", "timeout")
201+
@start_as_current_span(TRACER, "name", "parameters", "timeout")
198202
def run_task(
199203
self,
200-
task: Task,
204+
name: str,
205+
parameters: TaskParameters = {},
206+
*,
201207
on_event: OnAnyEvent | None = None,
202208
timeout: float | None = None,
203209
) -> WorkerEvent:
@@ -220,7 +226,7 @@ def run_task(
220226
"Stomp configuration required to run plans is missing or disabled"
221227
)
222228

223-
task_response = self.create_task(task)
229+
task_response = self.create_task(name, parameters)
224230
task_id = task_response.task_id
225231

226232
complete: Future[WorkerEvent] = Future()
@@ -257,8 +263,10 @@ def inner_on_event(event: AnyEvent, ctx: MessageContext) -> None:
257263
self.start_task(WorkerTask(task_id=task_id))
258264
return complete.result(timeout=timeout)
259265

260-
@start_as_current_span(TRACER, "task")
261-
def create_and_start_task(self, task: Task) -> TaskResponse:
266+
@start_as_current_span(TRACER, "name", "parameters")
267+
def create_and_start_task(
268+
self, name: str, parameters: TaskParameters = {}
269+
) -> TaskResponse:
262270
"""
263271
Create a new task and instruct the worker to start it
264272
immediately.
@@ -270,7 +278,7 @@ def create_and_start_task(self, task: Task) -> TaskResponse:
270278
TaskResponse: Acknowledgement of request
271279
"""
272280

273-
response = self.create_task(task)
281+
response = self.create_task(name, parameters)
274282
worker_response = self.start_task(WorkerTask(task_id=response.task_id))
275283
if worker_response.task_id == response.task_id:
276284
return response
@@ -280,8 +288,8 @@ def create_and_start_task(self, task: Task) -> TaskResponse:
280288
f"but {worker_response.task_id} was started instead"
281289
)
282290

283-
@start_as_current_span(TRACER, "task")
284-
def create_task(self, task: Task) -> TaskResponse:
291+
@start_as_current_span(TRACER, "name", "parameters")
292+
def create_task(self, name: str, parameters: TaskParameters = {}) -> TaskResponse:
285293
"""
286294
Create a new task, does not start execution
287295
@@ -292,6 +300,10 @@ def create_task(self, task: Task) -> TaskResponse:
292300
TaskResponse: Acknowledgement of request
293301
"""
294302

303+
try:
304+
task = Task(name=name, params=parameters)
305+
except ValidationError as ve:
306+
raise InvalidParameters.from_validation_error(ve)
295307
return self._rest.create_task(task)
296308

297309
@start_as_current_span(TRACER)

tests/unit_tests/client/test_client.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -171,15 +171,15 @@ def test_create_task(
171171
client: BlueapiClient,
172172
mock_rest: Mock,
173173
):
174-
client.create_task(task=Task(name="foo"))
174+
client.create_task(name="foo")
175175
mock_rest.create_task.assert_called_once_with(Task(name="foo"))
176176

177177

178178
def test_create_task_does_not_start_task(
179179
client: BlueapiClient,
180180
mock_rest: Mock,
181181
):
182-
client.create_task(task=Task(name="foo"))
182+
client.create_task(name="foo")
183183
mock_rest.update_worker_task.assert_not_called()
184184

185185

@@ -218,7 +218,7 @@ def test_create_and_start_task_calls_both_creating_and_starting_endpoints(
218218
):
219219
mock_rest.create_task.return_value = TaskResponse(task_id="baz")
220220
mock_rest.update_worker_task.return_value = TaskResponse(task_id="baz")
221-
client.create_and_start_task(Task(name="baz"))
221+
client.create_and_start_task(name="baz")
222222
mock_rest.create_task.assert_called_once_with(Task(name="baz"))
223223
mock_rest.update_worker_task.assert_called_once_with(WorkerTask(task_id="baz"))
224224

@@ -229,7 +229,7 @@ def test_create_and_start_task_fails_if_task_creation_fails(
229229
):
230230
mock_rest.create_task.side_effect = BlueskyRemoteControlError("No can do")
231231
with pytest.raises(BlueskyRemoteControlError):
232-
client.create_and_start_task(Task(name="baz"))
232+
client.create_and_start_task(name="baz")
233233

234234

235235
def test_create_and_start_task_fails_if_task_id_is_wrong(
@@ -239,7 +239,7 @@ def test_create_and_start_task_fails_if_task_id_is_wrong(
239239
mock_rest.create_task.return_value = TaskResponse(task_id="baz")
240240
mock_rest.update_worker_task.return_value = TaskResponse(task_id="bar")
241241
with pytest.raises(BlueskyRemoteControlError):
242-
client.create_and_start_task(Task(name="baz"))
242+
client.create_and_start_task(name="baz")
243243

244244

245245
def test_create_and_start_task_fails_if_task_start_fails(
@@ -249,7 +249,7 @@ def test_create_and_start_task_fails_if_task_start_fails(
249249
mock_rest.create_task.return_value = TaskResponse(task_id="baz")
250250
mock_rest.update_worker_task.side_effect = BlueskyRemoteControlError("No can do")
251251
with pytest.raises(BlueskyRemoteControlError):
252-
client.create_and_start_task(Task(name="baz"))
252+
client.create_and_start_task(name="baz")
253253

254254

255255
def test_get_environment(client: BlueapiClient):
@@ -384,7 +384,7 @@ def test_cannot_run_task_without_message_bus(client: BlueapiClient):
384384
MissingStompConfiguration,
385385
match="Stomp configuration required to run plans is missing or disabled",
386386
):
387-
client.run_task(Task(name="foo"))
387+
client.run_task(name="foo")
388388

389389

390390
def test_run_task_sets_up_control(
@@ -398,7 +398,7 @@ def test_run_task_sets_up_control(
398398
ctx.correlation_id = "foo"
399399
mock_events.subscribe_to_all_events = lambda on_event: on_event(COMPLETE_EVENT, ctx)
400400

401-
client_with_events.run_task(Task(name="foo"))
401+
client_with_events.run_task(name="foo")
402402
mock_rest.create_task.assert_called_once_with(Task(name="foo"))
403403
mock_rest.update_worker_task.assert_called_once_with(WorkerTask(task_id="foo"))
404404

@@ -417,7 +417,7 @@ def test_run_task_fails_on_failing_event(
417417

418418
on_event = Mock()
419419
with pytest.raises(BlueskyStreamingError):
420-
client_with_events.run_task(Task(name="foo"), on_event=on_event)
420+
client_with_events.run_task(name="foo", on_event=on_event)
421421

422422
on_event.assert_called_with(FAILED_EVENT)
423423

@@ -456,7 +456,7 @@ def callback(on_event: Callable[[AnyEvent, MessageContext], None]):
456456
mock_events.subscribe_to_all_events = callback # type: ignore
457457

458458
mock_on_event = Mock()
459-
client_with_events.run_task(Task(name="foo"), on_event=mock_on_event)
459+
client_with_events.run_task(name="foo", on_event=mock_on_event)
460460

461461
assert mock_on_event.mock_calls == [call(test_event), call(COMPLETE_EVENT)]
462462

@@ -495,7 +495,7 @@ def callback(on_event: Callable[[AnyEvent, MessageContext], None]):
495495
mock_events.subscribe_to_all_events = callback
496496

497497
mock_on_event = Mock()
498-
client_with_events.run_task(Task(name="foo"), on_event=mock_on_event)
498+
client_with_events.run_task(name="foo", on_event=mock_on_event)
499499

500500
mock_on_event.assert_called_once_with(COMPLETE_EVENT)
501501

@@ -543,8 +543,8 @@ def test_create_task_span_ok(
543543
client: BlueapiClient,
544544
mock_rest: Mock,
545545
):
546-
with asserting_span_exporter(exporter, "create_task", "task"):
547-
client.create_task(task=Task(name="foo"))
546+
with asserting_span_exporter(exporter, "create_task", "name", "parameters"):
547+
client.create_task(name="foo")
548548

549549

550550
def test_clear_task_span_ok(
@@ -579,8 +579,10 @@ def test_create_and_start_task_span_ok(
579579
):
580580
mock_rest.create_task.return_value = TaskResponse(task_id="baz")
581581
mock_rest.update_worker_task.return_value = TaskResponse(task_id="baz")
582-
with asserting_span_exporter(exporter, "create_and_start_task", "task"):
583-
client.create_and_start_task(Task(name="baz"))
582+
with asserting_span_exporter(
583+
exporter, "create_and_start_task", "name", "parameters"
584+
):
585+
client.create_and_start_task(name="baz")
584586

585587

586588
def test_get_environment_span_ok(
@@ -644,4 +646,4 @@ def test_cannot_run_task_span_ok(
644646
match="Stomp configuration required to run plans is missing or disabled",
645647
):
646648
with asserting_span_exporter(exporter, "grun_task"):
647-
client.run_task(Task(name="foo"))
649+
client.run_task(name="foo")

tests/unit_tests/test_cli.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -569,13 +569,13 @@ def test_error_handling(exception, error_message, runner: CliRunner):
569569

570570

571571
@pytest.mark.parametrize(
572-
"params, error",
572+
"params, error, code",
573573
[
574-
("{", "Parameters are not valid JSON"),
575-
("[]", ""),
574+
("{", "Invalid value for '[PARAMETERS]'", 2),
575+
("[]", "Incorrect parameters supplied", 1),
576576
],
577577
)
578-
def test_run_task_parsing_errors(params: str, error: str, runner: CliRunner):
578+
def test_run_task_parsing_errors(params: str, error: str, code: int, runner: CliRunner):
579579
result = runner.invoke(
580580
main,
581581
[
@@ -587,8 +587,8 @@ def test_run_task_parsing_errors(params: str, error: str, runner: CliRunner):
587587
params,
588588
],
589589
)
590-
assert result.stderr.startswith("Error: " + error)
591-
assert result.exit_code == 1
590+
assert ("Error: " + error) in result.stderr
591+
assert result.exit_code == code
592592

593593

594594
def test_device_output_formatting():

0 commit comments

Comments
 (0)