From 23996b666b68691ff64cc8e7c0e61aea3596ee97 Mon Sep 17 00:00:00 2001 From: Peter Holloway Date: Thu, 19 Jun 2025 17:28:26 +0100 Subject: [PATCH] Make calls to numtracker async --- pyproject.toml | 2 + src/blueapi/client/numtracker.py | 15 +-- src/blueapi/service/interface.py | 4 +- tests/conftest.py | 139 +-------------------- tests/unit_tests/client/test_numtracker.py | 87 +++++++++---- tests/unit_tests/service/test_interface.py | 21 +++- 6 files changed, 94 insertions(+), 174 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b5537fc0d..1edbc632a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "observability-utils>=0.1.4", "pyjwt[crypto]", "tomlkit", + "httpx>=0.28.1", ] dynamic = ["version"] license.file = "LICENSE" @@ -51,6 +52,7 @@ dev = [ "pyright", "pytest-cov", "pytest-asyncio", + "pytest-httpx>=0.35.0", "responses", "ruff", "semver", diff --git a/src/blueapi/client/numtracker.py b/src/blueapi/client/numtracker.py index 7490219f2..c8f207b9c 100644 --- a/src/blueapi/client/numtracker.py +++ b/src/blueapi/client/numtracker.py @@ -3,7 +3,7 @@ from pathlib import Path from textwrap import dedent -import requests +import httpx from pydantic import Field from blueapi.utils import BlueapiBaseModel @@ -60,7 +60,7 @@ def set_headers(self, headers: Mapping[str, str]) -> None: self._headers = headers - def create_scan( + async def create_scan( self, instrument_session: str, instrument: str ) -> NumtrackerScanMutationResponse: """ @@ -92,11 +92,12 @@ def create_scan( """) } - response = requests.post( - self._url, - headers=self._headers, - json=query, - ) + async with httpx.AsyncClient() as client: + response = await client.post( + self._url, + headers=self._headers, + json=query, + ) response.raise_for_status() json = response.json() diff --git a/src/blueapi/service/interface.py b/src/blueapi/service/interface.py index 91f64cf7a..3c0d23c73 100644 --- a/src/blueapi/service/interface.py +++ b/src/blueapi/service/interface.py @@ -110,10 +110,10 @@ def numtracker_client() -> NumtrackerClient | None: return None -def _update_scan_num(md: dict[str, Any]) -> int: +async def _update_scan_num(md: dict[str, Any]) -> int: numtracker = numtracker_client() if numtracker is not None: - scan = numtracker.create_scan(md["instrument_session"], md["instrument"]) + scan = await numtracker.create_scan(md["instrument_session"], md["instrument"]) md["data_session_directory"] = str(scan.scan.directory.path) return scan.scan.scan_number else: diff --git a/tests/conftest.py b/tests/conftest.py index 1772ab242..ed8ffbe13 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,6 @@ import asyncio import base64 import time -from collections.abc import Iterable from pathlib import Path from textwrap import dedent from typing import Any, cast @@ -20,7 +19,6 @@ from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.trace import get_tracer_provider -from responses.matchers import json_params_matcher from blueapi.config import ApplicationConfig, OIDCConfig from blueapi.service.model import Cache @@ -335,12 +333,9 @@ def mock_jwks_fetch(json_web_keyset: JWK): return patch("jwt.PyJWKClient.fetch_data", mock) -NOT_CONFIGURED_INSTRUMENT = "p100" - - -@pytest.fixture(scope="module") -def mock_numtracker_server() -> Iterable[responses.RequestsMock]: - query_working = { +@pytest.fixture +def nt_query() -> dict[str, str]: + return { "query": dedent(""" mutation{ scan( @@ -358,94 +353,11 @@ def mock_numtracker_server() -> Iterable[responses.RequestsMock]: } """) } - query_400 = { - "query": dedent(""" - mutation{ - scan( - instrument: "p47", - instrumentSession: "ab123" - ) { - directory{ - instrumentSession - instrument - path - } - scanFile - scanNumber - } - } - """) - } - query_500 = { - "query": dedent(""" - mutation{ - scan( - instrument: "p48", - instrumentSession: "ab123" - ) { - directory{ - instrumentSession - instrument - path - } - scanFile - scanNumber - } - } - """) - } - query_key_error = { - "query": dedent(""" - mutation{ - scan( - instrument: "p49", - instrumentSession: "ab123" - ) { - directory{ - instrumentSession - instrument - path - } - scanFile - scanNumber - } - } - """) - } - query_200_with_errors = { - "query": dedent(f""" - mutation{{ - scan( - instrument: "{NOT_CONFIGURED_INSTRUMENT}", - instrumentSession: "ab123" - ) {{ - directory{{ - instrumentSession - instrument - path - }} - scanFile - scanNumber - }} - }} - """) - } - response_with_errors = { - "data": None, - "errors": [ - { - "message": ( - "No configuration available for instrument " - f'"{NOT_CONFIGURED_INSTRUMENT}"' - ), - "locations": [{"line": 3, "column": 5}], - "path": ["scan"], - } - ], - } - working_response = { +@pytest.fixture +def nt_response() -> dict[str, Any]: + return { "data": { "scan": { "scanFile": "p46-11", @@ -458,42 +370,3 @@ def mock_numtracker_server() -> Iterable[responses.RequestsMock]: } } } - empty_response = {} - - with responses.RequestsMock(assert_all_requests_are_fired=False) as requests_mock: - requests_mock.add( - responses.POST, - url="https://numtracker-example.com/graphql", - match=[json_params_matcher(query_working)], - status=200, - json=working_response, - ) - requests_mock.add( - responses.POST, - url="https://numtracker-example.com/graphql", - match=[json_params_matcher(query_400)], - status=400, - json=empty_response, - ) - requests_mock.add( - responses.POST, - url="https://numtracker-example.com/graphql", - match=[json_params_matcher(query_500)], - status=500, - json=empty_response, - ) - requests_mock.add( - responses.POST, - url="https://numtracker-example.com/graphql", - match=[json_params_matcher(query_key_error)], - status=200, - json=empty_response, - ) - requests_mock.add( - responses.POST, - "https://numtracker-example.com/graphql", - match=[json_params_matcher(query_200_with_errors)], - status=200, - json=response_with_errors, - ) - yield requests_mock diff --git a/tests/unit_tests/client/test_numtracker.py b/tests/unit_tests/client/test_numtracker.py index cc883bea2..4eaa15fa7 100644 --- a/tests/unit_tests/client/test_numtracker.py +++ b/tests/unit_tests/client/test_numtracker.py @@ -1,9 +1,8 @@ from pathlib import Path +import httpx import pytest -import responses -from requests import HTTPError -from tests.conftest import NOT_CONFIGURED_INSTRUMENT +from pytest_httpx import HTTPXMock from blueapi.client.numtracker import ( DirectoryPath, @@ -18,11 +17,33 @@ def numtracker() -> NumtrackerClient: return NumtrackerClient("https://numtracker-example.com/graphql") -def test_create_scan( - numtracker: NumtrackerClient, - mock_numtracker_server: responses.RequestsMock, +URL = "https://numtracker-example.com/graphql" + +EMPTY = {} + +ERRORS = { + "data": None, + "errors": [ + { + "message": "No configuration available for instrument p46", + "locations": [{"line": 3, "column": 5}], + "path": ["scan"], + } + ], +} + + +async def test_create_scan( + numtracker: NumtrackerClient, httpx_mock: HTTPXMock, nt_query, nt_response ): - scan = numtracker.create_scan("ab123", "p46") + httpx_mock.add_response( + method="POST", + url=URL, + match_json=nt_query, + status_code=200, + json=nt_response, + ) + scan = await numtracker.create_scan("ab123", "p46") assert scan == NumtrackerScanMutationResponse( scan=ScanPaths( scanFile="p46-11", @@ -36,42 +57,54 @@ def test_create_scan( ) -def test_create_scan_raises_400_error( - numtracker: NumtrackerClient, - mock_numtracker_server: responses.RequestsMock, +async def test_create_scan_raises_400_error( + numtracker: NumtrackerClient, httpx_mock: HTTPXMock, nt_query ): + httpx_mock.add_response( + method="POST", url=URL, match_json=nt_query, status_code=400, json=EMPTY + ) with pytest.raises( - HTTPError, - match="400 Client Error: Bad Request for url: https://numtracker-example.com/graphql", + httpx.HTTPStatusError, + match="Client error '400 Bad Request' for url 'https://numtracker-example.com/graphql'", ): - numtracker.create_scan("ab123", "p47") + await numtracker.create_scan("ab123", "p46") -def test_create_scan_raises_500_error( - numtracker: NumtrackerClient, - mock_numtracker_server: responses.RequestsMock, +async def test_create_scan_raises_500_error( + numtracker: NumtrackerClient, httpx_mock: HTTPXMock, nt_query ): + httpx_mock.add_response( + method="POST", url=URL, match_json=nt_query, status_code=500, json=EMPTY + ) with pytest.raises( - HTTPError, - match="500 Server Error: Internal Server Error for url: https://numtracker-example.com/graphql", + httpx.HTTPStatusError, + match="Server error '500 Internal Server Error' for url 'https://numtracker-example.com/graphql'", ): - numtracker.create_scan("ab123", "p48") + await numtracker.create_scan("ab123", "p46") -def test_create_scan_raises_key_error_on_incorrectly_formatted_responses( - numtracker: NumtrackerClient, - mock_numtracker_server: responses.RequestsMock, +async def test_create_scan_raises_key_error_on_incorrectly_formatted_responses( + numtracker: NumtrackerClient, httpx_mock: HTTPXMock, nt_query ): + httpx_mock.add_response( + method="POST", url=URL, match_json=nt_query, status_code=200, json=EMPTY + ) with pytest.raises( KeyError, match="data", ): - numtracker.create_scan("ab123", "p49") + await numtracker.create_scan("ab123", "p46") -def test_create_scan_raises_runtime_error_on_graphql_error( - numtracker: NumtrackerClient, - mock_numtracker_server: responses.RequestsMock, +async def test_create_scan_raises_runtime_error_on_graphql_error( + numtracker: NumtrackerClient, httpx_mock: HTTPXMock, nt_query ): + httpx_mock.add_response( + method="POST", + url=URL, + match_json=nt_query, + status_code=200, + json=ERRORS, + ) with pytest.raises(RuntimeError, match="Numtracker error:"): - numtracker.create_scan("ab123", NOT_CONFIGURED_INSTRUMENT) + await numtracker.create_scan("ab123", "p46") diff --git a/tests/unit_tests/service/test_interface.py b/tests/unit_tests/service/test_interface.py index 07cdaaba3..e77c743ca 100644 --- a/tests/unit_tests/service/test_interface.py +++ b/tests/unit_tests/service/test_interface.py @@ -494,7 +494,9 @@ def test_setup_with_numtracker_raises_if_provider_is_defined_in_device_module(): @patch("blueapi.client.numtracker.NumtrackerClient.create_scan") -def test_numtracker_create_scan_called_with_arguments_from_metadata(mock_create_scan): +async def test_numtracker_create_scan_called_with_arguments_from_metadata( + mock_create_scan, +): conf = ApplicationConfig( numtracker=NumtrackerConfig(url="https://numtracker-example.com/graphql"), env=EnvironmentConfig( @@ -507,16 +509,25 @@ def test_numtracker_create_scan_called_with_arguments_from_metadata(mock_create_ headers = {"a": "b"} interface._try_configure_numtracker(headers) - interface._update_scan_num(ctx.run_engine.md) + await interface._update_scan_num(ctx.run_engine.md) mock_create_scan.assert_called_once_with("ab123", "p46") interface.teardown() -def test_update_scan_num_side_effect_sets_data_session_directory_in_re_md( - mock_numtracker_server, +async def test_update_scan_num_side_effect_sets_data_session_directory_in_re_md( + httpx_mock, + nt_query, + nt_response, ): + httpx_mock.add_response( + method="POST", + url="https://numtracker-example.com/graphql", + match_json=nt_query, + status_code=200, + json=nt_response, + ) conf = ApplicationConfig( env=EnvironmentConfig( metadata=MetadataConfig(instrument="p46", instrument_session="ab123") @@ -526,7 +537,7 @@ def test_update_scan_num_side_effect_sets_data_session_directory_in_re_md( interface.setup(conf) ctx = interface.context() - interface._update_scan_num(ctx.run_engine.md) + await interface._update_scan_num(ctx.run_engine.md) assert ( ctx.run_engine.md["data_session_directory"] == "/exports/mybeamline/data/2025"