daq-queuing-service 0.1.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. daq_queuing_service/__init__.py +11 -0
  2. daq_queuing_service/__main__.py +34 -0
  3. daq_queuing_service/_version.py +24 -0
  4. daq_queuing_service/api/__init__.py +0 -0
  5. daq_queuing_service/api/api.py +180 -0
  6. daq_queuing_service/api/errors.py +71 -0
  7. daq_queuing_service/app/__init__.py +0 -0
  8. daq_queuing_service/app/_config.py +36 -0
  9. daq_queuing_service/app/app.py +109 -0
  10. daq_queuing_service/app/authentication.py +103 -0
  11. daq_queuing_service/app/authorisation.py +31 -0
  12. daq_queuing_service/blueapi_interaction/__init__.py +0 -0
  13. daq_queuing_service/blueapi_interaction/blueapi_adapter.py +70 -0
  14. daq_queuing_service/blueapi_interaction/blueapi_call.py +120 -0
  15. daq_queuing_service/blueapi_interaction/get_client.py +32 -0
  16. daq_queuing_service/blueapi_interaction/token_retriever.py +47 -0
  17. daq_queuing_service/broadcaster.py +62 -0
  18. daq_queuing_service/log.py +21 -0
  19. daq_queuing_service/plugins/converter.py +119 -0
  20. daq_queuing_service/plugins/i15_1/backgrounds.py +24 -0
  21. daq_queuing_service/plugins/i15_1/i15_1_converter.py +190 -0
  22. daq_queuing_service/plugins/i15_1/tiled_interaction.py +58 -0
  23. daq_queuing_service/task_queue/__init__.py +0 -0
  24. daq_queuing_service/task_queue/queue.py +650 -0
  25. daq_queuing_service/task_queue/queue_utils.py +26 -0
  26. daq_queuing_service/task_queue/task.py +106 -0
  27. daq_queuing_service/worker/__init__.py +0 -0
  28. daq_queuing_service/worker/worker.py +131 -0
  29. daq_queuing_service-0.1.13.dist-info/METADATA +265 -0
  30. daq_queuing_service-0.1.13.dist-info/RECORD +34 -0
  31. daq_queuing_service-0.1.13.dist-info/WHEEL +5 -0
  32. daq_queuing_service-0.1.13.dist-info/entry_points.txt +2 -0
  33. daq_queuing_service-0.1.13.dist-info/licenses/LICENSE +201 -0
  34. daq_queuing_service-0.1.13.dist-info/top_level.txt +1 -0
@@ -0,0 +1,11 @@
1
+ """Top level API.
2
+
3
+ .. data:: __version__
4
+ :type: str
5
+
6
+ Version number as calculated by https://github.com/pypa/setuptools_scm
7
+ """
8
+
9
+ from ._version import __version__
10
+
11
+ __all__ = ["__version__"]
@@ -0,0 +1,34 @@
1
+ """Interface for ``python -m daq_queuing_service``."""
2
+
3
+ from argparse import ArgumentParser
4
+ from collections.abc import Sequence
5
+ from pathlib import Path
6
+
7
+ import uvicorn
8
+
9
+ from daq_queuing_service.app._config import get_default_config_path
10
+
11
+ from . import __version__
12
+
13
+ __all__ = ["main"]
14
+
15
+
16
+ def main(args: Sequence[str] | None = None) -> None:
17
+ """Argument parser for the CLI."""
18
+ parser = ArgumentParser()
19
+ parser.add_argument("-v", "--version", action="version", version=__version__)
20
+ parser.add_argument("-p", "--port", type=int, default=8000)
21
+ parser.add_argument("--dev", action="store_true", default=False)
22
+ parser.add_argument("--config", type=Path, default=get_default_config_path())
23
+
24
+ parsed_args = parser.parse_args(args)
25
+
26
+ from daq_queuing_service.app.app import create_app
27
+
28
+ app = create_app(config_path=parsed_args.config, dev=parsed_args.dev)
29
+
30
+ uvicorn.run(app, host="0.0.0.0", port=parsed_args.port, workers=1)
31
+
32
+
33
+ if __name__ == "__main__":
34
+ main()
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.13'
22
+ __version_tuple__ = version_tuple = (0, 1, 13)
23
+
24
+ __commit_id__ = commit_id = None
File without changes
@@ -0,0 +1,180 @@
1
+ import asyncio
2
+ import json
3
+ from collections.abc import AsyncGenerator
4
+ from typing import Annotated
5
+
6
+ from blueapi.service.model import TaskRequest
7
+ from fastapi import APIRouter, Depends, Request, Response
8
+ from fastapi.responses import EventSourceResponse
9
+ from pydantic import BaseModel
10
+
11
+ from daq_queuing_service.app._config import AppConfig
12
+ from daq_queuing_service.app.authentication import User
13
+ from daq_queuing_service.blueapi_interaction.blueapi_call import BlueapiCallResponse
14
+ from daq_queuing_service.broadcaster import Broadcaster
15
+ from daq_queuing_service.plugins.converter import Converter, ValidateError
16
+ from daq_queuing_service.task_queue.queue import (
17
+ QUEUE_EVENTS,
18
+ PauseReason,
19
+ QueueState,
20
+ TaskQueue,
21
+ TaskWithPosition,
22
+ )
23
+ from daq_queuing_service.task_queue.task import Experiment, Status, Task
24
+
25
+ # pyright: reportUnusedFunction=false
26
+
27
+
28
+ def get_current_user(request: Request) -> User | None:
29
+ if hasattr(request.state, "user"):
30
+ return request.state.user
31
+
32
+
33
+ CurrentUser = Annotated[User | None, Depends(get_current_user)]
34
+
35
+
36
+ class QueueStateUpdate(BaseModel):
37
+ paused: bool
38
+
39
+
40
+ class TaskCancelRequest(BaseModel):
41
+ task_ids: list[str]
42
+
43
+
44
+ def _filter_by_status(
45
+ tasks: list[TaskWithPosition], status: Status | None
46
+ ) -> list[TaskWithPosition]:
47
+ if status is None:
48
+ return tasks
49
+ return [task for task in tasks if task.status == status]
50
+
51
+
52
+ def public_routes(queue: TaskQueue) -> APIRouter:
53
+ """No authentication is required to access these endpoints."""
54
+ router = APIRouter()
55
+
56
+ @router.get("/")
57
+ def read_root(request: Request):
58
+ base_url = str(request.base_url)
59
+ return (
60
+ f"Welcome to the daq queuing service. Visit {base_url}docs for Uvicorn API."
61
+ )
62
+
63
+ @router.get("/healthz")
64
+ async def healthz():
65
+ return Response()
66
+
67
+ @router.get("/queue/state")
68
+ def get_queue_state() -> QueueState:
69
+ return queue.state
70
+
71
+ return router
72
+
73
+
74
+ def protected_routes(
75
+ queue: TaskQueue,
76
+ broadcaster: Broadcaster[QUEUE_EVENTS],
77
+ config: AppConfig,
78
+ converter: Converter,
79
+ ) -> APIRouter:
80
+ """Authentication is required to access these endpoints (if turned on in config).
81
+ Additionally, for endpoints that depend on whitelist_check, you must be in the
82
+ whitelist of authorised fedIDs to access them.
83
+ """
84
+ router = APIRouter()
85
+
86
+ @router.get("/config")
87
+ def get_config() -> AppConfig:
88
+ return config
89
+
90
+ @router.patch("/queue/state")
91
+ async def update_queue_state(payload: QueueStateUpdate) -> QueueState:
92
+ if payload.paused:
93
+ return await queue.pause_queue(PauseReason.USER_REQUESTED)
94
+ else:
95
+ return await queue.resume_queue()
96
+
97
+ @router.get("/queue")
98
+ async def get_queued_tasks(status: Status | None = None) -> list[TaskWithPosition]:
99
+ return _filter_by_status(await queue.get_queue(), status)
100
+
101
+ @router.post("/queue")
102
+ async def add_tasks_to_queue(
103
+ experiments: list[TaskRequest | Experiment],
104
+ user: CurrentUser,
105
+ position: int | None = None,
106
+ ) -> list[str]:
107
+ try:
108
+ converter.validate(experiments)
109
+ except Exception as e:
110
+ raise ValidateError(*e.args) from e
111
+
112
+ tasks = [Task(experiment=experiment, user=user) for experiment in experiments]
113
+ task_ids = [task.id for task in tasks]
114
+ await queue.add_tasks(tasks, position)
115
+ return task_ids
116
+
117
+ @router.delete("/queue")
118
+ async def cancel_all_tasks() -> list[TaskWithPosition]:
119
+ return await queue.cancel_all_tasks()
120
+
121
+ @router.post("/queue/move")
122
+ async def move_task(task_id: str, new_position: int) -> int:
123
+ return await queue.move_task(task_id, new_position)
124
+
125
+ @router.delete("/queue/tasks")
126
+ async def cancel_tasks(payload: TaskCancelRequest) -> list[TaskWithPosition]:
127
+ return await queue.cancel_tasks(payload.task_ids)
128
+
129
+ @router.get("/queue/{position}")
130
+ async def get_task_by_position(position: int) -> TaskWithPosition | None:
131
+ return await queue.get_task_by_position(position)
132
+
133
+ @router.get("/tasks")
134
+ async def get_all_tasks(status: Status | None = None) -> list[TaskWithPosition]:
135
+ return _filter_by_status(await queue.get_tasks(), status)
136
+
137
+ @router.get("/tasks/{task_id}")
138
+ async def get_task_by_id(task_id: str) -> TaskWithPosition:
139
+ return await queue.get_task_by_id(task_id)
140
+
141
+ @router.get("/history")
142
+ async def get_completed_tasks(
143
+ status: Status | None = None,
144
+ ) -> list[TaskWithPosition]:
145
+ return _filter_by_status(await queue.get_history(), status)
146
+
147
+ @router.delete("/history")
148
+ async def clear_history():
149
+ return await queue.clear_history()
150
+
151
+ @router.get("/call_queue")
152
+ async def get_call_queue() -> list[BlueapiCallResponse]:
153
+ return await queue.get_call_queue()
154
+
155
+ @router.get("/call_history")
156
+ async def get_call_history() -> list[BlueapiCallResponse]:
157
+ return await queue.get_call_history()
158
+
159
+ @router.get("/events")
160
+ async def stream_events() -> EventSourceResponse:
161
+ subscriber = broadcaster.subscribe()
162
+
163
+ async def event_generator() -> AsyncGenerator[str, None]:
164
+ try:
165
+ while True:
166
+ event = await subscriber.get()
167
+ event_str = (
168
+ f"event: {event['type']}\ndata: {json.dumps(event['data'])}\n\n"
169
+ )
170
+ yield event_str
171
+
172
+ except asyncio.CancelledError:
173
+ # Client disconnected
174
+ raise
175
+ finally:
176
+ broadcaster.unsubscribe(subscriber)
177
+
178
+ return EventSourceResponse(event_generator())
179
+
180
+ return router
@@ -0,0 +1,71 @@
1
+ from collections.abc import Awaitable, Callable
2
+ from typing import TypeVar
3
+
4
+ from fastapi import FastAPI, Request
5
+ from fastapi.responses import JSONResponse
6
+
7
+ from daq_queuing_service.log import LOGGER
8
+ from daq_queuing_service.plugins.converter import ConverterError, ValidateError
9
+ from daq_queuing_service.task_queue.queue_utils import (
10
+ NegativePositionError,
11
+ QueueError,
12
+ TaskInProgressError,
13
+ TaskNotFoundError,
14
+ TaskNotInQueueError,
15
+ )
16
+
17
+ # pyright: reportUnusedFunction=false
18
+
19
+ E = TypeVar("E", bound=Exception)
20
+
21
+ Handler = Callable[[Request, E], Awaitable[JSONResponse]]
22
+
23
+
24
+ def make_exception_handler(
25
+ status_code: int, error_code: str
26
+ ) -> Callable[[Request, Exception], Awaitable[JSONResponse]]:
27
+ async def handler(request: Request, exception: Exception):
28
+ LOGGER.exception("Error while handling request: %s", request)
29
+ return JSONResponse(
30
+ status_code=status_code,
31
+ content={"error": error_code, "message": str(exception)},
32
+ )
33
+
34
+ return handler
35
+
36
+
37
+ def register_exception_handlers(app: FastAPI):
38
+ app.add_exception_handler(
39
+ TaskInProgressError,
40
+ make_exception_handler(409, "task_in_progress"),
41
+ )
42
+
43
+ app.add_exception_handler(
44
+ TaskNotFoundError,
45
+ make_exception_handler(404, "task_not_found"),
46
+ )
47
+
48
+ app.add_exception_handler(
49
+ TaskNotInQueueError,
50
+ make_exception_handler(409, "task_not_in_queue"),
51
+ )
52
+
53
+ app.add_exception_handler(
54
+ NegativePositionError,
55
+ make_exception_handler(400, "negative_position"),
56
+ )
57
+
58
+ app.add_exception_handler(
59
+ QueueError,
60
+ make_exception_handler(409, "queue_error"),
61
+ )
62
+
63
+ app.add_exception_handler(
64
+ ValidateError,
65
+ make_exception_handler(422, "validation_error"),
66
+ )
67
+
68
+ app.add_exception_handler(
69
+ ConverterError,
70
+ make_exception_handler(422, "converter_error"),
71
+ )
File without changes
@@ -0,0 +1,36 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ import yaml
5
+ from blueapi.config import OIDCConfig, RestConfig, StompConfig
6
+ from pydantic import BaseModel, Field
7
+
8
+ CONFIG_PATH = "/etc/config/config.yaml"
9
+ TEST_CONFIG_PATH = "tests/test_data/test_config.yaml"
10
+
11
+
12
+ class ConverterConfig(BaseModel):
13
+ path: str
14
+ name: str
15
+
16
+
17
+ class BlueapiConfig(BaseModel):
18
+ stomp: StompConfig = Field(default_factory=StompConfig)
19
+ api: RestConfig = Field(default_factory=RestConfig)
20
+
21
+
22
+ class AppConfig(BaseModel):
23
+ blueapi: BlueapiConfig
24
+ converter: ConverterConfig
25
+ oidc: OIDCConfig | None = None
26
+ authorisation_whitelist: list[str] | None = None
27
+
28
+
29
+ def get_default_config_path() -> str:
30
+ return CONFIG_PATH if os.path.isfile(CONFIG_PATH) else TEST_CONFIG_PATH
31
+
32
+
33
+ def load_config(config_path: Path) -> AppConfig:
34
+ with open(config_path) as f:
35
+ data = yaml.safe_load(f)
36
+ return AppConfig(**data)
@@ -0,0 +1,109 @@
1
+ import asyncio
2
+ import logging
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+ from typing import NoReturn
6
+
7
+ from fastapi import FastAPI
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.param_functions import Depends
10
+ from fastapi.params import Depends as DependsType
11
+
12
+ from daq_queuing_service.api.api import protected_routes, public_routes
13
+ from daq_queuing_service.api.errors import register_exception_handlers
14
+ from daq_queuing_service.app.authentication import (
15
+ build_access_token_check,
16
+ build_get_current_user,
17
+ )
18
+ from daq_queuing_service.app.authorisation import (
19
+ build_ensure_current_user_is_in_whitelist,
20
+ )
21
+ from daq_queuing_service.blueapi_interaction.blueapi_adapter import BlueapiClientAdapter
22
+ from daq_queuing_service.blueapi_interaction.get_client import get_blueapi_client
23
+ from daq_queuing_service.broadcaster import Broadcaster
24
+ from daq_queuing_service.plugins.converter import get_converter
25
+ from daq_queuing_service.task_queue.queue import QUEUE_EVENTS, TaskQueue
26
+ from daq_queuing_service.worker.worker import QueueWorker
27
+
28
+ from ._config import load_config
29
+
30
+ logging.basicConfig(
31
+ level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s"
32
+ )
33
+
34
+
35
+ def create_app(config_path: Path, dev: bool = False) -> FastAPI:
36
+ @asynccontextmanager
37
+ async def lifespan(app: FastAPI):
38
+ worker_task = asyncio.create_task(app.state.worker.run_loop())
39
+ app.state.worker_task = worker_task
40
+
41
+ def log_task_exception(task: asyncio.Task[NoReturn]):
42
+ try:
43
+ exc = task.exception()
44
+ if exc:
45
+ logging.error("Worker crashed", exc_info=exc)
46
+ except asyncio.CancelledError:
47
+ pass
48
+
49
+ worker_task.add_done_callback(log_task_exception)
50
+ try:
51
+ yield
52
+ finally:
53
+ worker_task.cancel()
54
+ await asyncio.gather(worker_task, return_exceptions=True)
55
+
56
+ config = load_config(config_path)
57
+
58
+ broadcaster: Broadcaster[QUEUE_EVENTS] = Broadcaster()
59
+
60
+ converter_path = config.converter.path
61
+ converter_name = config.converter.name
62
+ converter = get_converter(converter_path, converter_name)
63
+
64
+ app = FastAPI(lifespan=lifespan)
65
+
66
+ dependencies: list[DependsType] = []
67
+ whitelist_check = None
68
+ if config.oidc:
69
+ validate_token = build_access_token_check(config.oidc)
70
+ get_current_user = build_get_current_user(validate_token)
71
+
72
+ app.swagger_ui_init_oauth = {
73
+ "clientId": "NOT_SUPPORTED",
74
+ }
75
+
76
+ whitelist_check = build_ensure_current_user_is_in_whitelist(
77
+ config.authorisation_whitelist, get_current_user
78
+ )
79
+
80
+ dependencies.append(Depends(get_current_user))
81
+ dependencies.append(Depends(whitelist_check))
82
+
83
+ if dev: # Allows local client/UI through CORS
84
+ app.add_middleware(
85
+ CORSMiddleware,
86
+ allow_origin_regex=r"http://(localhost|127\.0\.0\.1):\d+",
87
+ allow_credentials=True,
88
+ allow_methods=["*"],
89
+ allow_headers=["*"],
90
+ )
91
+
92
+ app.state.queue = TaskQueue(converter, broadcaster)
93
+
94
+ blueapi_client = get_blueapi_client(config.blueapi)
95
+ blueapi_client_adapter = BlueapiClientAdapter(blueapi_client)
96
+
97
+ app.state.worker = QueueWorker(
98
+ queue=app.state.queue,
99
+ blueapi_client=blueapi_client_adapter,
100
+ )
101
+
102
+ register_exception_handlers(app)
103
+ app.include_router(public_routes(app.state.queue))
104
+ app.include_router(
105
+ protected_routes(app.state.queue, broadcaster, config, converter),
106
+ dependencies=dependencies,
107
+ )
108
+
109
+ return app
@@ -0,0 +1,103 @@
1
+ from collections.abc import Callable
2
+ from typing import Annotated, Any
3
+
4
+ import jwt
5
+ from blueapi.config import OIDCConfig
6
+ from fastapi import Depends, HTTPException, Request
7
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
8
+ from jwt.exceptions import DecodeError, ExpiredSignatureError
9
+ from pydantic import BaseModel, ValidationError
10
+ from starlette.status import HTTP_401_UNAUTHORIZED
11
+
12
+ from daq_queuing_service.log import LOGGER
13
+
14
+
15
+ class User(BaseModel):
16
+ fedid: str
17
+ email: str | None = None
18
+ name: str | None = None
19
+
20
+
21
+ # Some of the following code was copied from blueapi
22
+ # See https://github.com/DiamondLightSource/blueapi/blob/2108ee0c89b4399d961106f7f23082a58d48a564/src/blueapi/service/authentication.py#L281-L340
23
+
24
+ bearer_scheme = HTTPBearer(auto_error=False)
25
+
26
+
27
+ def unchecked_bearer_token(
28
+ credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)],
29
+ ) -> str | None:
30
+ if credentials is None:
31
+ return None
32
+ return credentials.credentials
33
+
34
+
35
+ UncheckedBearerToken = Annotated[str | None, Depends(unchecked_bearer_token)]
36
+
37
+
38
+ def build_access_token_check(
39
+ config: OIDCConfig,
40
+ ) -> Callable[[UncheckedBearerToken], dict[str, Any]]:
41
+ """
42
+ Create a function to validate the bearer token of requests
43
+
44
+ The returned function should be used via fastAPI's 'Depends' mechanism to
45
+ ensure users are authenticated
46
+ """
47
+ LOGGER.info(f"JWKS URI: {config.jwks_uri}")
48
+ jwkclient = jwt.PyJWKClient(config.jwks_uri)
49
+
50
+ def validate_bearer_token(token: UncheckedBearerToken):
51
+ """Check that a bearer token is valid and inject into request state"""
52
+ if not token:
53
+ raise HTTPException(
54
+ status_code=HTTP_401_UNAUTHORIZED,
55
+ detail="Not authenticated",
56
+ headers={"WWW-Authenticate": "Bearer"},
57
+ )
58
+
59
+ try:
60
+ signing_key = jwkclient.get_signing_key_from_jwt(token)
61
+ except DecodeError as e:
62
+ raise HTTPException(
63
+ status_code=HTTP_401_UNAUTHORIZED,
64
+ detail="Cannot decode token",
65
+ ) from e
66
+ except ExpiredSignatureError as e:
67
+ raise HTTPException(
68
+ status_code=HTTP_401_UNAUTHORIZED,
69
+ detail="Token expired",
70
+ ) from e
71
+
72
+ decoded: dict[str, Any] = jwt.decode(
73
+ token,
74
+ signing_key.key,
75
+ algorithms=config.id_token_signing_alg_values_supported,
76
+ verify=True,
77
+ audience=config.client_audience,
78
+ issuer=config.issuer,
79
+ )
80
+ LOGGER.debug(f"Decoded valid token: {decoded}")
81
+ return decoded
82
+
83
+ return validate_bearer_token
84
+
85
+
86
+ def build_get_current_user(
87
+ validate_token: Callable[..., dict[str, Any]],
88
+ ) -> Callable[[Request, dict[str, Any]], User]:
89
+ def get_current_user(
90
+ request: Request,
91
+ decoded: Annotated[dict[str, Any], Depends(validate_token)],
92
+ ) -> User:
93
+ try:
94
+ user = User.model_validate(decoded)
95
+ except ValidationError as e:
96
+ raise HTTPException(
97
+ status_code=HTTP_401_UNAUTHORIZED,
98
+ detail="Invalid token claims",
99
+ ) from e
100
+ request.state.user = user
101
+ return user
102
+
103
+ return get_current_user
@@ -0,0 +1,31 @@
1
+ from collections.abc import Callable
2
+ from typing import Annotated
3
+
4
+ from fastapi import Depends, HTTPException
5
+ from starlette.status import HTTP_403_FORBIDDEN
6
+
7
+ from daq_queuing_service.app.authentication import User
8
+ from daq_queuing_service.worker.worker import LOGGER
9
+
10
+
11
+ def build_ensure_current_user_is_in_whitelist(
12
+ whitelist: list[str] | None, get_current_user: Callable[..., User]
13
+ ) -> Callable[[User], User]:
14
+ def ensure_current_user_is_in_whitelist(
15
+ current_user: Annotated[User, Depends(get_current_user)],
16
+ ) -> User:
17
+ LOGGER.debug(f"Got user: {current_user}")
18
+ if whitelist is None:
19
+ LOGGER.debug("No user whitelist. All authenticated users are authorised.")
20
+ return current_user
21
+ elif current_user.fedid in whitelist:
22
+ LOGGER.debug(
23
+ f"FedID {current_user.fedid} found in whitelist, user authorised."
24
+ )
25
+ return current_user
26
+ raise HTTPException(
27
+ status_code=HTTP_403_FORBIDDEN,
28
+ detail="Not authorised. You are not in the whitelist of authorised FedIDs",
29
+ )
30
+
31
+ return ensure_current_user_is_in_whitelist
File without changes
@@ -0,0 +1,70 @@
1
+ import asyncio
2
+ from dataclasses import dataclass
3
+ from typing import Generic, TypeVar
4
+
5
+ from blueapi.client import BlueapiClient
6
+ from blueapi.client.event_bus import OnAnyEvent
7
+ from blueapi.client.rest import (
8
+ BlueskyRemoteControlError,
9
+ InvalidParametersError,
10
+ ServiceUnavailableError,
11
+ UnknownPlanError,
12
+ )
13
+ from blueapi.service.model import TaskRequest
14
+ from blueapi.worker import TaskStatus, WorkerState
15
+
16
+ from daq_queuing_service.log import LOGGER
17
+
18
+ T = TypeVar("T")
19
+ E = TypeVar("E", bound=Exception)
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class BlueapiResult(Generic[T, E]):
24
+ value: T | None = None
25
+ error: E | None = None
26
+
27
+ def __post_init__(self):
28
+ if (self.value is None) == (self.error is None):
29
+ raise ValueError("Exactly one of value or error must be set")
30
+
31
+
32
+ class BlueapiClientAdapter:
33
+ def __init__(
34
+ self,
35
+ client: BlueapiClient,
36
+ ):
37
+ self._client = client
38
+
39
+ async def get_state(self) -> BlueapiResult[WorkerState, ServiceUnavailableError]:
40
+ try:
41
+ state = await asyncio.to_thread(self._client.get_state)
42
+ return BlueapiResult(value=state)
43
+ except ServiceUnavailableError as e:
44
+ LOGGER.error(f"Lost connection to blueapi: {e}")
45
+ return BlueapiResult(error=e)
46
+
47
+ async def run_task(
48
+ self,
49
+ task_request: TaskRequest,
50
+ on_event: OnAnyEvent | None = None,
51
+ ) -> BlueapiResult[
52
+ TaskStatus,
53
+ BlueskyRemoteControlError
54
+ | InvalidParametersError
55
+ | UnknownPlanError
56
+ | ServiceUnavailableError,
57
+ ]:
58
+ try:
59
+ task_status = await asyncio.to_thread(
60
+ self._client.run_task, task_request, on_event=on_event
61
+ )
62
+ return BlueapiResult(value=task_status)
63
+ except (
64
+ BlueskyRemoteControlError,
65
+ InvalidParametersError,
66
+ UnknownPlanError,
67
+ ServiceUnavailableError,
68
+ ) as e:
69
+ LOGGER.exception(e)
70
+ return BlueapiResult(error=e)