Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
68 commits
Select commit Hold shift + click to select a range
6d47f0d
wip
abbiemery Feb 24, 2026
d2f54f4
client wip
abbiemery Feb 24, 2026
bdc5bc0
use normal iter
abbiemery Feb 24, 2026
2e00f71
close ws
abbiemery Feb 24, 2026
0b42499
add some trys
abbiemery Feb 24, 2026
b7dc0ac
unpipe
abbiemery Feb 24, 2026
a28baa2
Move websocket handling into BlueapiRestClient
tpoliaw Mar 3, 2026
96b782d
Send all events through websocket
tpoliaw Mar 3, 2026
2b0791a
Split pipe subscribe handles
tpoliaw Mar 4, 2026
e07c2dd
Re-use run subcommand for websockets
tpoliaw Mar 4, 2026
76fe648
Raise for connection closing pre plan completed
abbiemery Mar 4, 2026
30bf299
Remove run blocking from cli
abbiemery Mar 4, 2026
4ec72f4
Catch plan key error in run_plan
abbiemery Mar 4, 2026
00cdc7a
Refactor event pipe handling into context manager and iterable
tpoliaw Mar 6, 2026
8bf126a
Testing auth tokens
tpoliaw Mar 12, 2026
95989d0
Re-use existing auth dependency for websocket endpoint
tpoliaw Mar 12, 2026
5f38e90
Add user auth token in websocket client
tpoliaw Mar 12, 2026
1f383bb
Read authorization from cookie as well as header
tpoliaw Mar 12, 2026
d0121cc
Add user agent to websocket request
tpoliaw Mar 12, 2026
03ea0d8
Add user agent to all requests
tpoliaw Apr 7, 2026
fdee627
Use new fedid dependency for user name
tpoliaw Jun 29, 2026
880d390
Test auth from cookie
tpoliaw Jun 29, 2026
d961beb
Fix CLI event handler test
tpoliaw Jun 29, 2026
0859bfb
Reinstate _valid_return check
tpoliaw Jun 29, 2026
9166052
Use versioned api for websockets
tpoliaw Jun 29, 2026
5735eb4
Add type annotation to unpipe
tpoliaw Jun 30, 2026
b0ad38e
Move ws endpoint to v2 api
tpoliaw Jun 30, 2026
caf9539
Use Depends for header and cookie
tpoliaw Mar 12, 2026
1ae199b
Add sub-protocol to ws communication
tpoliaw Apr 17, 2026
7159b39
Used configured host for websockets
tpoliaw Apr 17, 2026
1731350
Add debug logging of all websocket traffic
tpoliaw Apr 20, 2026
0780f2d
Include connection info in logging
tpoliaw Apr 20, 2026
1bc5c3d
Split receive logging by type
tpoliaw Apr 20, 2026
21311d2
Correct typing in rest run_blocking
tpoliaw Apr 29, 2026
c1b5659
Use rstrip instead of removesuffix to remove multiple trailing slashes
tpoliaw Jul 1, 2026
08e8e09
Redact auth tokens in websocket logging
tpoliaw Jul 1, 2026
9824738
Use send_text instead of send json
tpoliaw Jul 1, 2026
8f8c88c
Improve error handling
tpoliaw Jul 1, 2026
6015755
Check for active task before running task
tpoliaw Jul 2, 2026
3a1ebf9
Filter events to only relevant ones
tpoliaw Jul 3, 2026
5316994
Logging adjustments
tpoliaw Jul 3, 2026
b58b04c
Exclude all private methods from blueapi_rest_client_get_methods
tpoliaw Jul 3, 2026
7fcd509
Move ws_address method to config and deal with empty paths
tpoliaw Jul 3, 2026
32eed07
Add tests for websocket tracing
tpoliaw Jul 3, 2026
e20ef8c
Handle pydantic's messing up of URL paths
tpoliaw Jul 21, 2026
cbc5f47
Extract cookies/headers from connection manually
tpoliaw Jul 21, 2026
ff285d9
WorkerEvent property tests
tpoliaw Jul 23, 2026
7242b4d
More coverage hunting
tpoliaw Jul 24, 2026
0ad020c
Add interface pipe tests
tpoliaw Jul 27, 2026
cc992bf
Fix runner tests
tpoliaw Jul 27, 2026
10e6bff
More tests
tpoliaw Jul 27, 2026
0b5eb13
Start to test main method
tpoliaw Jul 27, 2026
71b5bf4
Add rest client run_blocking tests
tpoliaw Jul 29, 2026
7dfe60a
test rest ws auth
tpoliaw Jul 29, 2026
f87c553
Add tests for main run_plan handler
tpoliaw Jul 29, 2026
cf9ba7d
Pin uvicorn to 0.49 until websockets are fixed
tpoliaw Jul 31, 2026
4542852
Add system test to run a plan via websocket
tpoliaw Jul 31, 2026
dd7f843
Add authz to ws
tpoliaw Jul 31, 2026
22aca25
Handle server being missing
tpoliaw Jul 31, 2026
2c2b71f
Add websocket auth tests
tpoliaw Jul 31, 2026
8557770
Add unit test for ws auth check
tpoliaw Aug 3, 2026
92cbc0d
Update uvicorn post ws fix
tpoliaw Aug 3, 2026
bb529d5
Add service unavailable ws test
tpoliaw Aug 3, 2026
f80e72a
Add Origin checks for websockets
tpoliaw Aug 7, 2026
e816988
Update _session_manager to session_manager
tpoliaw Aug 18, 2026
bf34ff7
Add passthrough headers to WS plans
tpoliaw Aug 18, 2026
05e5004
Add websocket origin check tests
tpoliaw Aug 18, 2026
f304709
Code review response
tpoliaw Aug 18, 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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ dependencies = [
"PyYAML>=6.0.2",
"click>=8.2.0",
"fastapi>=0.112.0",
"uvicorn",
"uvicorn>=0.52.1",
"requests",
"GitPython",
"event-model==1.23.1", # https://github.com/DiamondLightSource/blueapi/issues/684
Expand Down
26 changes: 23 additions & 3 deletions src/blueapi/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,19 @@ def on_event(
@controller.command(name="run")
@click.argument("name", type=str)
@click.argument("parameters", type=ParametersType(), default={}, required=False)
@click.option(
"--ws",
type=bool,
is_flag=True,
default=False,
help=textwrap.dedent("""
Run the plan in the foreground using the (experimental) websocket connection
to monitor progress. Allows plans to be run without a message bus and associated
configuration.

Has no effect if --bg is also passed as the plan will not be monitored.
"""),
)
@click.option(
"--foreground/--background", "--fg/--bg", type=bool, is_flag=True, default=True
)
Expand Down Expand Up @@ -348,14 +361,15 @@ def run_plan(
name: str,
timeout: float | None,
foreground: bool,
ws: bool,
instrument_session: str,
parameters: TaskParameters,
) -> None:
"""Run a plan with parameters

To run in the foreground and block until it is complete, stomp
configuration is required. Without stomp configuration, '--bg' can be used
to start a plan in the background.
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"])
Expand All @@ -374,7 +388,13 @@ def on_event(event: AnyEvent) -> None:
elif isinstance(event, DataEvent):
callback(event.name, event.doc)

resp = client.run_task(task, on_event=on_event)
client.add_callback(on_event)

if ws:
resp = client.run_blocking(task)
else:
resp = client.run_task(task)

match resp.result:
case TaskResult(result=None, type="NoneType"):
print("Plan succeeded")
Expand Down
21 changes: 21 additions & 0 deletions src/blueapi/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,27 @@ def get_active_task(self) -> WorkerTask:

return self.active_task

@start_as_current_span(TRACER, "request")
def run_blocking(
self, request: TaskRequest, on_event: OnAnyEvent | None = None
) -> TaskStatus:
for event in self._rest.run_blocking(request):
if on_event is not None:
on_event(event)
for cb in self._callbacks.values():
try:
cb(event)
except Exception as e:
log.error(f"Callback ({cb}) failed for event: {event}", exc_info=e)
if isinstance(event, WorkerEvent) and event.is_complete():
# task_status will always be present if event is complete
if event.task_status is None: # pragma: no cover
raise BlueskyRemoteControlError(
"Server completed without task status"
)
return event.task_status
raise BlueskyRemoteControlError("Connection closed before plan completed.")

@start_as_current_span(TRACER, "task", "timeout")
def run_task(
self,
Expand Down
72 changes: 67 additions & 5 deletions src/blueapi/client/rest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import json
import logging
from collections.abc import Callable, Mapping
from collections.abc import Callable, Iterable, Mapping
from typing import Any, Literal, TypeVar

import requests
Expand All @@ -12,10 +12,13 @@
)
from pydantic import BaseModel, TypeAdapter, ValidationError
from pydantic_core import PydanticSerializationError
from websockets.exceptions import InvalidStatus
from websockets.sync.client import connect

from blueapi import __version__
from blueapi.client import client
from blueapi.config import RestConfig
from blueapi.core.bluesky_types import DataEvent
from blueapi.service.authentication import JWTAuth, SessionManager
from blueapi.service.model import (
DeviceModel,
Expand All @@ -31,14 +34,26 @@
TasksListResponse,
WorkerTask,
)
from blueapi.service.protocol import (
ControlResponse,
InvalidArgs,
PlanNotFound,
ServerBusy,
Submit,
Unauthorized,
Update,
)
from blueapi.worker import TrackableTask, WorkerState
from blueapi.worker.event import ProgressEvent, WorkerEvent

T = TypeVar("T")

TRACER = get_tracer("rest")

LOGGER = logging.getLogger(__name__)

USER_AGENT = f"blueapi cli {__version__}"


class BlueskyRequestError(Exception):
"""An error response from the blueapi server."""
Expand Down Expand Up @@ -86,8 +101,8 @@ def __init__(self, target_type: type) -> None:

class ParameterError(BaseModel):
loc: list[str | int]
msg: str
type: str
msg: str | None
type: str | None
input: Any

def field(self):
Expand Down Expand Up @@ -307,14 +322,15 @@ def _request_and_deserialize(
) -> T:
url = self._config.url.unicode_string().removesuffix("/") + suffix
# Get the trace context to propagate to the REST API
carr = get_context_propagator()
headers = get_context_propagator()
headers["User-Agent"] = USER_AGENT
try:
response = self._pool.request(
method,
url,
json=data,
params=params,
headers=carr,
headers=headers,
auth=JWTAuth(self.session_manager),
)
except requests.exceptions.ConnectionError as ce:
Expand All @@ -340,6 +356,52 @@ def _request_and_deserialize(
)
return deserialized

def run_blocking(
self, req: TaskRequest
) -> Iterable[DataEvent | WorkerEvent | ProgressEvent]:
url = self._config.ws_address.unicode_string().rstrip("/") + "/api/v2/run_plan"
headers = get_context_propagator()
if self.session_manager:
auth = self.session_manager.get_valid_access_token()
headers["Authorization"] = f"Bearer {auth}"
try:
with connect(
url,
additional_headers=headers,
user_agent_header=USER_AGENT,
) as ws:
ws.send(Submit(task=req).model_dump_json())
for message in ws:
event = ControlResponse.validate_json(message)
match event:
case Update(data=data):
yield data
case InvalidArgs(errors=errors):
raise InvalidParametersError(
[
ParameterError(
loc=e.loc, msg=e.msg, type=e.type, input=e.input
)
for e in errors
]
)
case PlanNotFound(plan_name=name):
raise UnknownPlanError(message=name)
case ServerBusy():
raise BlueskyRemoteControlError(409, "Server is busy")
case Unauthorized():
raise UnauthorisedAccessError(
403, "Not authorized to submit task"
)
except InvalidStatus as istat:
match istat.response.status_code:
case 401 | 403:
raise UnauthorisedAccessError() from None
case _:
raise BlueskyRemoteControlError() from istat
except ConnectionRefusedError as cre:
raise ServiceUnavailableError() from cre


# https://github.com/DiamondLightSource/blueapi/issues/1256 - remove before 2.0
def __getattr__(name: str):
Expand Down
17 changes: 17 additions & 0 deletions src/blueapi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
TypeAdapter,
UrlConstraints,
ValidationError,
WebsocketUrl,
field_validator,
model_validator,
)
Expand Down Expand Up @@ -170,6 +171,22 @@ class RestConfig(BlueapiBaseModel):
url: HttpUrl = HttpUrl("http://localhost:8000")
cors: CORSConfig | None = None

@property
def ws_address(self) -> WebsocketUrl:
api = self.url
if api.host is None:
# type hints say it could be None but not possible to construct
# HttpUrl without host
raise ValueError("No host configured") # pragma: no cover
scheme = "ws" if api.scheme == "http" else "wss"

# HttpUrl adds "/" to the start of paths, even if none was specified so
# remove existing leading '/' to prevent duplication
path = (api.path or "").removeprefix("/")
return WebsocketUrl.build(
scheme=scheme, host=api.host, port=api.port, path=path
)


class ScratchRepository(BlueapiBaseModel):
name: str = Field(
Expand Down
16 changes: 10 additions & 6 deletions src/blueapi/service/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
import httpx
import jwt
import requests
from fastapi import Depends, HTTPException, Request
from fastapi import Depends, HTTPException
from fastapi.requests import HTTPConnection
from fastapi.security.utils import get_authorization_scheme_param
from pydantic import TypeAdapter
from requests.auth import AuthBase
Expand Down Expand Up @@ -278,14 +279,17 @@ def sync_auth_flow(self, request):
yield request


def unchecked_bearer_token(req: Request) -> str | None:
def unchecked_bearer_token(req: HTTPConnection) -> str | None:
"""Get bearer token value from authorization header"""

auth_header = req.headers.get("Authorization")
auth_cookie = req.cookies.get("Authorization")

# This is an abridged version of the same feature of
# OAuth2AuthorizationCodeBearer from fastapi. Replicating here prevents
# passing unused configuration and means the schema does not include auth
# details for servers that do not support it.
auth = req.headers.get("Authorization")
scheme, param = get_authorization_scheme_param(auth)
scheme, param = get_authorization_scheme_param(auth_header or auth_cookie)
if scheme.casefold() != "bearer":
return None
return param.strip()
Expand All @@ -303,7 +307,7 @@ def build_access_token_check(config: OIDCConfig):
"""
jwkclient = jwt.PyJWKClient(config.jwks_uri)

def validate_bearer_token(request: Request, token: UncheckedBearerToken):
def validate_bearer_token(request: HTTPConnection, token: UncheckedBearerToken):
"""Check that a bearer token is valid and inject into request state"""
if not token:
raise HTTPException(
Expand All @@ -326,7 +330,7 @@ def validate_bearer_token(request: Request, token: UncheckedBearerToken):
return validate_bearer_token


def access_token(request: Request) -> Mapping[str, Any] | None:
def access_token(request: HTTPConnection) -> Mapping[str, Any] | None:
"""Get the decoded and verified access token of the user making the request"""
return getattr(request.state, "decoded_access_token", None)

Expand Down
5 changes: 3 additions & 2 deletions src/blueapi/service/authorization.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
from typing import Annotated, Any, Self, cast

from aiohttp import ClientSession
from fastapi import Depends, HTTPException, Request
from fastapi import Depends, HTTPException
from fastapi.requests import HTTPConnection
from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN

from blueapi.config import OIDCConfig, OpaConfig, ServiceAccount
Expand Down Expand Up @@ -114,7 +115,7 @@ async def validate_tiled_config(


async def opa(
request: Request, token: str | None = Depends(unchecked_bearer_token)
request: HTTPConnection, token: str | None = Depends(unchecked_bearer_token)
) -> OpaUserClient | None:

if opa := cast(OpaClient | None, getattr(request.app.state, "authz", None)):
Expand Down
43 changes: 40 additions & 3 deletions src/blueapi/service/interface.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import logging
from collections.abc import Mapping
from dataclasses import dataclass
from functools import cache
from multiprocessing.connection import Connection
from typing import Any

from bluesky.callbacks.tiled_writer import TiledWriter
Expand All @@ -9,6 +12,7 @@

from blueapi.cli.scratch import get_python_environment
from blueapi.config import ApplicationConfig, OIDCConfig, ServiceAccount, StompConfig
from blueapi.core.bluesky_types import DataEvent
from blueapi.core.context import BlueskyContext
from blueapi.core.event import EventStream
from blueapi.log import set_up_logging
Expand All @@ -22,14 +26,14 @@
WorkerTask,
)
from blueapi.utils.serialization import access_blob
from blueapi.worker.event import TaskStatusEnum, WorkerEvent, WorkerState
from blueapi.worker.event import ProgressEvent, TaskStatusEnum, WorkerEvent, WorkerState
from blueapi.worker.task import Task
from blueapi.worker.task_worker import TaskWorker, TrackableTask

"""This module provides interface between web application and underlying Bluesky
context and worker"""


LOGGER = logging.getLogger(__name__)
_CONFIG: ApplicationConfig = ApplicationConfig()


Expand Down Expand Up @@ -226,7 +230,7 @@ def remove_callback_when_task_finished(
if task.task_id is not None:
try:
active_worker.begin_task(task.task_id)
except KeyError:
except:
for channel, token in subscribers:
channel.unsubscribe(token)
raise
Expand Down Expand Up @@ -281,3 +285,36 @@ def get_python_env(
"""Retrieve information about the Python environment"""
scratch = config().scratch
return get_python_environment(config=scratch, name=name, source=source)


@dataclass
class SubHandles:
worker: int
progress: int
data: int


def pipe_events(sender: Connection) -> SubHandles:
tw = worker()

def handler(
worker_event: WorkerEvent | DataEvent | ProgressEvent,
_cor_id: str | None,
) -> None:

try:
sender.send(worker_event)
except BrokenPipeError:
LOGGER.warning("Sending event to broken pipe")

w = tw.worker_events.subscribe(handler)
d = tw.data_events.subscribe(handler)
p = tw.progress_events.subscribe(handler)
return SubHandles(worker=w, data=d, progress=p)


def unpipe_events(handles: SubHandles) -> None:
tw = worker()
tw.worker_events.unsubscribe(handles.worker)
tw.data_events.unsubscribe(handles.data)
tw.progress_events.unsubscribe(handles.progress)
Loading