Skip to content
Open
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
19 changes: 9 additions & 10 deletions src/blueapi/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,13 +237,12 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
raise ClickException(
"Failed to establish connection to blueapi server."
) from ce
except UnauthorisedAccessError as e:
raise ClickException(
"Access denied. Please check your login status and try again."
) from e
except BlueskyRemoteControlError as e:
if str(e) == "<Response [401]>":
raise ClickException(
"Access denied. Please check your login status and try again."
) from e
else:
raise e
raise e

return wrapper

Expand Down Expand Up @@ -372,12 +371,12 @@ def on_event(event: AnyEvent) -> None:
raise ClickException(*mse.args) from mse
except UnknownPlanError as up:
raise ClickException(f"Plan '{name}' was not recognised") from up
except UnauthorisedAccessError as ua:
raise ClickException("Unauthorised request") from ua
except InvalidParametersError as ip:
raise ClickException(ip.message()) from ip
except (BlueskyRemoteControlError, BlueskyStreamingError) as e:
raise ClickException(f"server error with this message: {e}") from e
except BlueskyStreamingError as se:
raise ClickException(f"Streaming error: {se}") from se
except BlueskyRemoteControlError as e:
raise ClickException(f"Remote control error: {e.args[0]}") from e
except ValueError as ve:
raise ClickException(f"task could not run: {ve}") from ve

Expand Down
4 changes: 2 additions & 2 deletions src/blueapi/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
from blueapi.worker.task_worker import TrackableTask

from .event_bus import AnyEvent, EventBusClient, OnAnyEvent
from .rest import BlueapiRestClient, BlueskyRemoteControlError
from .rest import BlueapiRestClient, BlueskyRemoteControlError, NotFoundError

TRACER = get_tracer("client")

Expand Down Expand Up @@ -99,7 +99,7 @@ def __getitem__(self, name: str) -> "DeviceRef":
self._cache[name] = device
setattr(self, model.name, device)
return device
except KeyError:
except NotFoundError:
pass
raise AttributeError(f"No device named '{name}' available")

Expand Down
50 changes: 38 additions & 12 deletions src/blueapi/client/rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,42 @@
LOGGER = logging.getLogger(__name__)


class BlueskyRequestError(Exception):
"""Error response from the blueapi server.(422,450,500)"""

def __init__(self, code: int | None = None, message: str = "") -> None:
super().__init__(code, message)


class UnauthorisedAccessError(Exception):
pass
"""Request was rejected due to missing or invalid credentials (401/403)."""

def __init__(self, code: int | None = None, message: str = "") -> None:
super().__init__(code, message)


class BlueskyRemoteControlError(Exception):
"""Failure communicating with the blueapi server (e.g. connection refused)."""

pass


class NonJsonResponseError(Exception):
"""Server returned a response that could not be parsed as JSON."""

pass


class BlueskyRequestError(Exception):
def __init__(self, code: int, message: str) -> None:
super().__init__(message, code)
class NotFoundError(BlueskyRequestError):
"""Requested something that couldn't be found (404)."""

pass


class UnknownPlanError(BlueskyRequestError):
""" "Plan '{name}' was not recognised" """

pass


class NoContentError(Exception):
Expand Down Expand Up @@ -105,28 +126,33 @@ def from_validation_error(cls, ve: ValidationError):
)


class UnknownPlanError(Exception):
pass


def _exception(response: requests.Response) -> Exception | None:
code = response.status_code
if code < 400:
return None
elif code in (401, 403):
return UnauthorisedAccessError(code, response.text)
elif code == 404:
return KeyError(str(_response_json(response)))
return NotFoundError(code, response.text)
else:
return BlueskyRemoteControlError(code, str(response))
try:
body = _response_json(response)
message = (body.get("detail") if isinstance(body, dict) else None) or (
response.text
)
except NonJsonResponseError:
message = response.text
return BlueskyRemoteControlError(code, message)


def _create_task_exceptions(response: requests.Response) -> Exception | None:
code = response.status_code
if code < 400:
return None
elif code == 401 or code == 403:
return UnauthorisedAccessError()
return UnauthorisedAccessError(code, response.text)
elif code == 404:
return UnknownPlanError()
return UnknownPlanError(code, response.text)
elif code == 422:
try:
content = _response_json(response)
Expand Down
13 changes: 9 additions & 4 deletions src/blueapi/service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -498,11 +498,16 @@ def set_state(
state_change_request.new_state is WorkerState.ABORTING,
state_change_request.reason,
)
except TransitionError:
response.status_code = status.HTTP_400_BAD_REQUEST
except TransitionError as e:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=(f"Cannot transition from {current_state} to {new_state}"),
) from e
else:
response.status_code = status.HTTP_400_BAD_REQUEST

raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(f"Cannot transition from {current_state} to {new_state}"),
)
return runner.run(interface.get_worker_state)


Expand Down
18 changes: 10 additions & 8 deletions tests/system_tests/test_blueapi_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
BlueapiRestClient,
BlueskyRemoteControlError,
BlueskyRequestError,
NotFoundError,
ServiceUnavailableError,
UnauthorisedAccessError,
)
from blueapi.config import (
ApplicationConfig,
Expand Down Expand Up @@ -217,7 +219,7 @@ def test_cannot_access_endpoints(
"get_oidc_config"
) # get_oidc_config can be accessed without auth
for get_method in blueapi_rest_client_get_methods:
with pytest.raises(BlueskyRemoteControlError, match=r"<Response \[401\]>"):
with pytest.raises(UnauthorisedAccessError, match=r"Not authenticated"):
getattr(client_without_auth._rest, get_method)()


Expand All @@ -243,7 +245,7 @@ def test_get_plans_by_name(client: BlueapiClient, expected_plans: PlanResponse):


def test_get_non_existent_plan(rest_client: BlueapiRestClient):
with pytest.raises(KeyError, match="{'detail': 'Item not found'}"):
with pytest.raises(NotFoundError, match=r"Item not found"):
rest_client.get_plan("Not exists")


Expand All @@ -268,7 +270,7 @@ def test_get_device_by_name(


def test_get_non_existent_device(rest_client: BlueapiRestClient):
with pytest.raises(KeyError, match="{'detail': 'Item not found'}"):
with pytest.raises(NotFoundError, match=r"Item not found"):
rest_client.get_device("Not exists")


Expand Down Expand Up @@ -336,12 +338,12 @@ def test_get_task_by_id(rest_client: BlueapiRestClient):


def test_get_non_existent_task(rest_client: BlueapiRestClient):
with pytest.raises(KeyError, match="{'detail': 'Item not found'}"):
with pytest.raises(NotFoundError, match=r"Item not found"):
rest_client.get_task("Not-exists")


def test_delete_non_existent_task(rest_client: BlueapiRestClient):
with pytest.raises(KeyError, match="{'detail': 'Item not found'}"):
with pytest.raises(NotFoundError, match=r"Item not found"):
rest_client.clear_task("Not-exists")


Expand All @@ -363,7 +365,7 @@ def test_put_worker_task_fails_if_not_idle(rest_client: BlueapiRestClient):

with pytest.raises(BlueskyRemoteControlError) as exception:
rest_client.update_worker_task(WorkerTask(task_id=small_task.task_id))
assert "<Response [409]>" in str(exception)
assert exception.value.args[0] == 409
rest_client.cancel_current_task(WorkerState.ABORTING)
rest_client.clear_task(small_task.task_id)
rest_client.clear_task(long_task.task_id)
Expand All @@ -376,10 +378,10 @@ def test_get_worker_state(client: BlueapiClient):
def test_set_state_transition_error(client: BlueapiClient):
with pytest.raises(BlueskyRemoteControlError) as exception:
client.resume()
assert "<Response [400]>" in str(exception)
assert "Cannot transition from IDLE to RUNNING" in exception.value.args[1]
with pytest.raises(BlueskyRemoteControlError) as exception:
client.pause()
assert "<Response [400]>" in str(exception)
assert "Cannot transition from IDLE to PAUSED" in exception.value.args[1]


def test_get_task_by_status(rest_client: BlueapiRestClient):
Expand Down
18 changes: 12 additions & 6 deletions tests/unit_tests/cli/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,8 @@ def test_connection_error_caught_by_wrapper_func(
def test_authentication_error_caught_by_wrapper_func(
mock_requests: Mock, runner: CliRunner
):
mock_requests.side_effect = BlueskyRemoteControlError("<Response [401]>")
mock_requests.side_effect = UnauthorisedAccessError(message="<Response [401]>")
result = runner.invoke(main, ["controller", "plans"])

assert (
result.output
== "Error: Access denied. Please check your login status and try again.\n"
Expand All @@ -130,7 +129,6 @@ def test_authentication_error_caught_by_wrapper_func(
@patch("blueapi.client.rest.requests.Session.request")
def test_remote_error_raised_by_wrapper_func(mock_requests: Mock, runner: CliRunner):
mock_requests.side_effect = BlueskyRemoteControlError("Response [450]")

result = runner.invoke(main, ["controller", "plans"])
assert (
isinstance(result.exception, BlueskyRemoteControlError)
Expand Down Expand Up @@ -701,7 +699,10 @@ def test_env_reload_server_side_error(runner: CliRunner):
"exception, error_message",
[
(UnknownPlanError(), "Error: Plan 'sleep' was not recognised\n"),
(UnauthorisedAccessError(), "Error: Unauthorised request\n"),
(
UnauthorisedAccessError(),
"Error: Access denied. Please check your login status and try again.\n",
),
(
InvalidParametersError(
errors=[
Expand All @@ -717,19 +718,24 @@ def test_env_reload_server_side_error(runner: CliRunner):
),
(
BlueskyRemoteControlError("Server error"),
"Error: server error with this message: Server error\n",
"Error: Remote control error: Server error\n",
),
(
ValueError("Error parsing parameters"),
"Error: task could not run: Error parsing parameters\n",
),
(
BlueskyStreamingError("streaming failed"),
"Error: Streaming error: streaming failed\n",
),
],
ids=[
"unknown_plan",
"unauthorised_access",
"invalid_parameters",
"remote_control",
"value_error",
"streaming_error",
],
)
def test_error_handling(exception, error_message, runner: CliRunner):
Expand Down Expand Up @@ -1148,7 +1154,7 @@ def test_invalid_json(
responses.GET,
"http://localhost:8000/config/oidc",
body="blah blah",
status=404,
status=200,
)

result = runner.invoke(main, ["-c", config_with_auth, "login"])
Expand Down
15 changes: 13 additions & 2 deletions tests/unit_tests/client/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
PlanCache,
)
from blueapi.client.event_bus import AnyEvent, EventBusClient
from blueapi.client.rest import BlueapiRestClient, BlueskyRemoteControlError
from blueapi.client.rest import (
BlueapiRestClient,
BlueskyRemoteControlError,
NotFoundError,
)
from blueapi.config import MissingStompConfigurationError
from blueapi.core import DataEvent
from blueapi.service.model import (
Expand Down Expand Up @@ -98,7 +102,14 @@ def mock_rest() -> BlueapiRestClient:
mock.get_plans.return_value = PLANS
mock.get_plan.side_effect = lambda n: {p.name: p for p in PLANS.plans}[n]
mock.get_devices.return_value = DEVICES
mock.get_device.side_effect = lambda n: {d.name: d for d in DEVICES.devices}[n]
device_map = {d.name: d for d in DEVICES.devices}

def get_device(n: str):
if n not in device_map:
raise NotFoundError(404, "<Response [404]>")
return device_map[n]

mock.get_device.side_effect = get_device
mock.get_state.return_value = WorkerState.IDLE
mock.get_task.return_value = TASK
mock.get_all_tasks.return_value = TASKS
Expand Down
26 changes: 19 additions & 7 deletions tests/unit_tests/client/test_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
BlueskyRemoteControlError,
BlueskyRequestError,
InvalidParametersError,
NotFoundError,
ParameterError,
UnauthorisedAccessError,
UnknownPlanError,
Expand Down Expand Up @@ -41,8 +42,9 @@ def rest_with_auth(oidc_config: OIDCConfig, tmp_path) -> BlueapiRestClient:
@pytest.mark.parametrize(
"code,expected_exception",
[
(404, KeyError),
(401, BlueskyRemoteControlError),
(404, NotFoundError),
(401, UnauthorisedAccessError),
(403, UnauthorisedAccessError),
(450, BlueskyRemoteControlError),
(500, BlueskyRemoteControlError),
],
Expand All @@ -65,9 +67,9 @@ def test_rest_error_code(
"code,content,expected_exception",
[
(200, None, None),
(401, None, UnauthorisedAccessError()),
(403, None, UnauthorisedAccessError()),
(404, None, UnknownPlanError()),
(401, "", UnauthorisedAccessError(401, "")),
(403, "", UnauthorisedAccessError(403, "")),
(404, "", UnknownPlanError(404, "")),
(
422,
"""{
Expand All @@ -89,6 +91,11 @@ def test_rest_error_code(
]
),
),
(
422,
'{"detail": "not a list"}',
BlueskyRequestError(422, ""),
),
(450, "non-standard", BlueskyRequestError(450, "non-standard")),
(500, "internal_error", BlueskyRequestError(500, "internal_error")),
],
Expand All @@ -104,8 +111,13 @@ def test_create_task_exceptions(
response.json.side_effect = lambda: json.loads(content) if content else None
err = _create_task_exceptions(response)
assert isinstance(err, type(expected_exception))
if expected_exception is not None:
assert err.args == expected_exception.args
if isinstance(expected_exception, InvalidParametersError):
assert isinstance(err, InvalidParametersError)
assert err.errors == expected_exception.errors
elif expected_exception is not None:
assert err.args[0] == code
if content is not None:
assert err.args[1] == content


def test_auth_request_functionality(
Expand Down
Loading
Loading