From 0790c7368de50ce5e22e2b640d98bdb51eccd27e Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Wed, 30 Oct 2024 16:28:20 +0000 Subject: [PATCH 1/9] First attempt rest api --- pyproject.toml | 2 + src/fastcs/backends/rest/__init__.py | 0 src/fastcs/backends/rest/backend.py | 14 +++ src/fastcs/backends/rest/rest.py | 147 +++++++++++++++++++++++++++ tests/backends/rest/test_rest.py | 90 ++++++++++++++++ tests/conftest.py | 67 +++++++++++- 6 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 src/fastcs/backends/rest/__init__.py create mode 100644 src/fastcs/backends/rest/backend.py create mode 100644 src/fastcs/backends/rest/rest.py create mode 100644 tests/backends/rest/test_rest.py diff --git a/pyproject.toml b/pyproject.toml index ff870e073..489c469fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ classifiers = [ description = "Control system agnostic framework for building Device support in Python that will work for both EPICS and Tango" dependencies = [ "aioserial", + "fastapi[standard]", "numpy", "pydantic", "pvi~=0.10.0", @@ -43,6 +44,7 @@ dev = [ "types-mock", "aioca", "p4p", + "httpx", ] [project.scripts] diff --git a/src/fastcs/backends/rest/__init__.py b/src/fastcs/backends/rest/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fastcs/backends/rest/backend.py b/src/fastcs/backends/rest/backend.py new file mode 100644 index 000000000..97b9d2322 --- /dev/null +++ b/src/fastcs/backends/rest/backend.py @@ -0,0 +1,14 @@ +from fastcs.backend import Backend +from fastcs.controller import Controller + +from .rest import RestServer + + +class RestBackend(Backend): + def __init__(self, controller: Controller): + super().__init__(controller) + + self._server = RestServer(self._mapping) + + def _run(self): + self._server.run() diff --git a/src/fastcs/backends/rest/rest.py b/src/fastcs/backends/rest/rest.py new file mode 100644 index 000000000..1503920fa --- /dev/null +++ b/src/fastcs/backends/rest/rest.py @@ -0,0 +1,147 @@ +from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass +from types import MethodType +from typing import Any + +import uvicorn +from fastapi import FastAPI +from pydantic import create_model + +from fastcs.attributes import AttrR, AttrRW, AttrW, T +from fastcs.controller import BaseController +from fastcs.mapping import Mapping + + +@dataclass +class RestServerOptions: + host: str = "localhost" + port: int = 8080 + log_level: str = "info" + + +class RestServer: + def __init__(self, mapping: Mapping): + self._mapping = mapping + self._app = self._create_app() + + def _create_app(self): + app = FastAPI() + _add_dev_attributes(app, self._mapping) + _add_dev_commands(app, self._mapping) + + return app + + def run(self, options: RestServerOptions | None = None) -> None: + if options is None: + options = RestServerOptions() + + uvicorn.run( + self._app, + host=options.host, + port=options.port, + log_level=options.log_level, + ) + + +def _put_request_body(attribute: AttrW[T]): + return create_model( + f"Put{str(attribute.datatype.dtype)}Value", + **{"value": (attribute.datatype.dtype, ...)}, # type: ignore + ) + + +def _wrap_attr_put( + attribute: AttrW[T], +) -> Callable[[T], Coroutine[Any, Any, None]]: + async def attr_set(request): + await attribute.process(request.value) + + # Fast api uses type annotations for validation, schema, conversions + attr_set.__annotations__["request"] = _put_request_body(attribute) + + return attr_set + + +def _get_response_body(attribute: AttrR[T]): + return create_model( + f"Get{str(attribute.datatype.dtype)}Value", + **{"value": (attribute.datatype.dtype, ...)}, # type: ignore + ) + + +def _wrap_attr_get( + attribute: AttrR[T], +) -> Callable[[], Coroutine[Any, Any, Any]]: + async def attr_get() -> Any: # Must be any as response_model is set + value = attribute.get() # type: ignore + return {"value": value} + + return attr_get + + +def _add_dev_attributes(app: FastAPI, mapping: Mapping) -> None: + for single_mapping in mapping.get_controller_mappings(): + path = single_mapping.controller.path + + for attr_name, attribute in single_mapping.attributes.items(): + attr_name = attr_name.title().replace("_", "") + d_attr_name = f"{'/'.join(path)}/{attr_name}" if path else attr_name + + match attribute: + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods + case AttrRW(): + app.add_api_route( + f"/{d_attr_name}", + _wrap_attr_get(attribute), + methods=["GET"], # Idemponent and safe data retrieval, + status_code=200, # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET + response_model=_get_response_body(attribute), + ) + app.add_api_route( + f"/{d_attr_name}", + _wrap_attr_put(attribute), + methods=["PUT"], # Idempotent state change + status_code=204, # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT + ) + case AttrR(): + app.add_api_route( + f"/{d_attr_name}", + _wrap_attr_get(attribute), + methods=["GET"], + status_code=200, + response_model=_get_response_body(attribute), + ) + case AttrW(): + app.add_api_route( + f"/{d_attr_name}", + _wrap_attr_put(attribute), + methods=["PUT"], + status_code=204, + ) + + +def _wrap_command( + method: Callable, controller: BaseController +) -> Callable[..., Awaitable[None]]: + async def command() -> None: + await getattr(controller, method.__name__)() + + return command + + +def _add_dev_commands(app: FastAPI, mapping: Mapping) -> None: + for single_mapping in mapping.get_controller_mappings(): + path = single_mapping.controller.path + + for name, method in single_mapping.command_methods.items(): + cmd_name = name.title().replace("_", "") + d_cmd_name = f"{'/'.join(path)}/{cmd_name}" if path else cmd_name + app.add_api_route( + f"/{d_cmd_name}", + _wrap_command( + method.fn, + single_mapping.controller, + ), + methods=["PUT"], + status_code=204, + ) diff --git a/tests/backends/rest/test_rest.py b/tests/backends/rest/test_rest.py new file mode 100644 index 000000000..ae333845c --- /dev/null +++ b/tests/backends/rest/test_rest.py @@ -0,0 +1,90 @@ +import copy +import re +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from fastcs.attributes import AttrR +from fastcs.backends.rest.backend import RestBackend +from fastcs.datatypes import Bool, Float, Int + + +def pascal_2_snake(input: list[str]) -> list[str]: + snake_list = copy.deepcopy(input) + snake_list[-1] = re.sub(r"(? None: + super().__init__() + + self._sub_controllers: list[TestSubController] = [] + for index in range(1, 3): + controller = TestSubController() + self._sub_controllers.append(controller) + self.register_sub_controller(f"SubController{index:02d}", controller) + read_int: AttrR = AttrR(Int(), handler=TestUpdater()) read_write_int: AttrRW = AttrRW(Int(), handler=TestHandler()) read_write_float: AttrRW = AttrRW(Float()) @@ -80,11 +96,58 @@ async def counter(self): self.count += 1 +class AssertableController(TestController): + def __init__(self, mocker: MockerFixture) -> None: + super().__init__() + self.mocker = mocker + + @contextmanager + def assertPerformed( + self, path: list[str], action: Literal["READ", "WRITE", "EXECUTE"] + ): + queue = copy.deepcopy(path) + match action: + case "READ": + method = "get" + case "WRITE": + method = "process" + case "EXECUTE": + method = "" + + # Navigate to subcontroller + controller = self + item_name = queue.pop(-1) + for item in queue: + controllers = controller.get_sub_controllers() + controller = controllers[item] + + # create probe + if method: + attr = getattr(controller, item_name) + spy = self.mocker.spy(attr, method) + else: + spy = self.mocker.spy(controller, item_name) + initial = spy.call_count + try: + yield # Enter context + finally: # Exit context + final = spy.call_count + assert final == initial + 1, ( + f"Expected {'.'.join(path + [method] if method else path)} " + f"to be called once, but it was called {final - initial} times." + ) + + @pytest.fixture def controller(): return TestController() +@pytest.fixture(scope="class") +def assertable_controller(class_mocker: MockerFixture): + return AssertableController(class_mocker) + + @pytest.fixture def mapping(controller): return Mapping(controller) From 669310ec61e5d318baf20f23cd9f2342a59c295c Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Fri, 15 Nov 2024 15:04:59 +0000 Subject: [PATCH 2/9] Process review comments --- src/fastcs/backends/rest/rest.py | 34 +++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/fastcs/backends/rest/rest.py b/src/fastcs/backends/rest/rest.py index 1503920fa..08197b2e5 100644 --- a/src/fastcs/backends/rest/rest.py +++ b/src/fastcs/backends/rest/rest.py @@ -26,8 +26,8 @@ def __init__(self, mapping: Mapping): def _create_app(self): app = FastAPI() - _add_dev_attributes(app, self._mapping) - _add_dev_commands(app, self._mapping) + _add_attribute_api_routes(app, self._mapping) + _add_command_api_routes(app, self._mapping) return app @@ -44,6 +44,11 @@ def run(self, options: RestServerOptions | None = None) -> None: def _put_request_body(attribute: AttrW[T]): + """ + Creates a pydantic model for each datatype which defines the schema + of the PUT request body + """ + # key=(type, ...) to declare a field without default value return create_model( f"Put{str(attribute.datatype.dtype)}Value", **{"value": (attribute.datatype.dtype, ...)}, # type: ignore @@ -63,6 +68,11 @@ async def attr_set(request): def _get_response_body(attribute: AttrR[T]): + """ + Creates a pydantic model for each datatype which defines the schema + of the GET request body + """ + # key=(type, ...) to declare a field without default value return create_model( f"Get{str(attribute.datatype.dtype)}Value", **{"value": (attribute.datatype.dtype, ...)}, # type: ignore @@ -79,33 +89,33 @@ async def attr_get() -> Any: # Must be any as response_model is set return attr_get -def _add_dev_attributes(app: FastAPI, mapping: Mapping) -> None: +def _add_attribute_api_routes(app: FastAPI, mapping: Mapping) -> None: for single_mapping in mapping.get_controller_mappings(): path = single_mapping.controller.path for attr_name, attribute in single_mapping.attributes.items(): attr_name = attr_name.title().replace("_", "") - d_attr_name = f"{'/'.join(path)}/{attr_name}" if path else attr_name + route = f"{'/'.join(path)}/{attr_name}" if path else attr_name match attribute: # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods case AttrRW(): app.add_api_route( - f"/{d_attr_name}", + f"/{route}", _wrap_attr_get(attribute), - methods=["GET"], # Idemponent and safe data retrieval, + methods=["GET"], # Idempotent and safe data retrieval, status_code=200, # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET response_model=_get_response_body(attribute), ) app.add_api_route( - f"/{d_attr_name}", + f"/{route}", _wrap_attr_put(attribute), methods=["PUT"], # Idempotent state change status_code=204, # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT ) case AttrR(): app.add_api_route( - f"/{d_attr_name}", + f"/{route}", _wrap_attr_get(attribute), methods=["GET"], status_code=200, @@ -113,7 +123,7 @@ def _add_dev_attributes(app: FastAPI, mapping: Mapping) -> None: ) case AttrW(): app.add_api_route( - f"/{d_attr_name}", + f"/{route}", _wrap_attr_put(attribute), methods=["PUT"], status_code=204, @@ -129,15 +139,15 @@ async def command() -> None: return command -def _add_dev_commands(app: FastAPI, mapping: Mapping) -> None: +def _add_command_api_routes(app: FastAPI, mapping: Mapping) -> None: for single_mapping in mapping.get_controller_mappings(): path = single_mapping.controller.path for name, method in single_mapping.command_methods.items(): cmd_name = name.title().replace("_", "") - d_cmd_name = f"{'/'.join(path)}/{cmd_name}" if path else cmd_name + route = f"/{'/'.join(path)}/{cmd_name}" if path else cmd_name app.add_api_route( - f"/{d_cmd_name}", + f"/{route}", _wrap_command( method.fn, single_mapping.controller, From bd0f50583110e770e253780f7e546838d5d70dd3 Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Fri, 15 Nov 2024 15:06:29 +0000 Subject: [PATCH 3/9] Fix other tests --- src/fastcs/backends/rest/rest.py | 1 - tests/backends/epics/test_gui.py | 24 ++++++++++++++++++++++++ tests/backends/tango/test_dsr.py | 2 ++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/fastcs/backends/rest/rest.py b/src/fastcs/backends/rest/rest.py index 08197b2e5..6a90c3dd1 100644 --- a/src/fastcs/backends/rest/rest.py +++ b/src/fastcs/backends/rest/rest.py @@ -1,6 +1,5 @@ from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass -from types import MethodType from typing import Any import uvicorn diff --git a/tests/backends/epics/test_gui.py b/tests/backends/epics/test_gui.py index 0ecabaf86..c4d9b0626 100644 --- a/tests/backends/epics/test_gui.py +++ b/tests/backends/epics/test_gui.py @@ -2,10 +2,12 @@ LED, ButtonPanel, ComboBox, + Group, SignalR, SignalRW, SignalW, SignalX, + SubScreen, TextFormat, TextRead, TextWrite, @@ -30,6 +32,28 @@ def test_get_components(mapping): components = gui.extract_mapping_components(mapping.get_controller_mappings()[0]) assert components == [ + Group( + name="SubController01", + layout=SubScreen(labelled=True), + children=[ + SignalR( + name="ReadInt", + read_pv="DEVICE:SubController01:ReadInt", + read_widget=TextRead(), + ) + ], + ), + Group( + name="SubController02", + layout=SubScreen(labelled=True), + children=[ + SignalR( + name="ReadInt", + read_pv="DEVICE:SubController01:ReadInt", + read_widget=TextRead(), + ) + ], + ), SignalR(name="BigEnum", read_pv="DEVICE:BigEnum", read_widget=TextRead()), SignalR(name="ReadBool", read_pv="DEVICE:ReadBool", read_widget=LED()), SignalR( diff --git a/tests/backends/tango/test_dsr.py b/tests/backends/tango/test_dsr.py index a5839e489..46050f8f7 100644 --- a/tests/backends/tango/test_dsr.py +++ b/tests/backends/tango/test_dsr.py @@ -17,6 +17,8 @@ def test_collect_attributes(mapping): "ReadWriteInt", "StringEnum", "WriteBool", + "SubController01_ReadInt", + "SubController02_ReadInt", ] assert attributes["ReadInt"].attr_write == AttrWriteType.READ assert attributes["ReadInt"].attr_type == CmdArgType.DevLong64 From 35e62c8894e6646a86553e851ab7cc2eb88d5d73 Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Mon, 18 Nov 2024 10:01:25 +0000 Subject: [PATCH 4/9] Remove dictionary unpack --- src/fastcs/backends/rest/rest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastcs/backends/rest/rest.py b/src/fastcs/backends/rest/rest.py index 6a90c3dd1..dfc0c5a73 100644 --- a/src/fastcs/backends/rest/rest.py +++ b/src/fastcs/backends/rest/rest.py @@ -50,7 +50,7 @@ def _put_request_body(attribute: AttrW[T]): # key=(type, ...) to declare a field without default value return create_model( f"Put{str(attribute.datatype.dtype)}Value", - **{"value": (attribute.datatype.dtype, ...)}, # type: ignore + value=(attribute.datatype.dtype, ...), ) From 5105e25355394dc70c6b07be0bd5687921b8bdc7 Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Mon, 18 Nov 2024 10:12:05 +0000 Subject: [PATCH 5/9] Use kebab-case for attr/command slug --- src/fastcs/backends/rest/rest.py | 4 ++-- tests/backends/rest/test_rest.py | 34 +++++++++++++++++--------------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/src/fastcs/backends/rest/rest.py b/src/fastcs/backends/rest/rest.py index dfc0c5a73..8c8a1cfe1 100644 --- a/src/fastcs/backends/rest/rest.py +++ b/src/fastcs/backends/rest/rest.py @@ -93,7 +93,7 @@ def _add_attribute_api_routes(app: FastAPI, mapping: Mapping) -> None: path = single_mapping.controller.path for attr_name, attribute in single_mapping.attributes.items(): - attr_name = attr_name.title().replace("_", "") + attr_name = attr_name.replace("_", "-") route = f"{'/'.join(path)}/{attr_name}" if path else attr_name match attribute: @@ -143,7 +143,7 @@ def _add_command_api_routes(app: FastAPI, mapping: Mapping) -> None: path = single_mapping.controller.path for name, method in single_mapping.command_methods.items(): - cmd_name = name.title().replace("_", "") + cmd_name = name.replace("_", "-") route = f"/{'/'.join(path)}/{cmd_name}" if path else cmd_name app.add_api_route( f"/{route}", diff --git a/tests/backends/rest/test_rest.py b/tests/backends/rest/test_rest.py index ae333845c..9098b543d 100644 --- a/tests/backends/rest/test_rest.py +++ b/tests/backends/rest/test_rest.py @@ -10,9 +10,9 @@ from fastcs.datatypes import Bool, Float, Int -def pascal_2_snake(input: list[str]) -> list[str]: +def kebab_2_snake(input: list[str]) -> list[str]: snake_list = copy.deepcopy(input) - snake_list[-1] = re.sub(r"(? Date: Mon, 18 Nov 2024 10:19:24 +0000 Subject: [PATCH 6/9] Remove unused import --- tests/backends/rest/test_rest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/backends/rest/test_rest.py b/tests/backends/rest/test_rest.py index 9098b543d..4998b5397 100644 --- a/tests/backends/rest/test_rest.py +++ b/tests/backends/rest/test_rest.py @@ -1,5 +1,4 @@ import copy -import re from typing import Any import pytest From a5e18cea8d69377ac9cc7e58d760ea34a048e63c Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Mon, 18 Nov 2024 11:18:35 +0000 Subject: [PATCH 7/9] Fix schema naming --- src/fastcs/backends/rest/rest.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/fastcs/backends/rest/rest.py b/src/fastcs/backends/rest/rest.py index 8c8a1cfe1..0eedc3d4b 100644 --- a/src/fastcs/backends/rest/rest.py +++ b/src/fastcs/backends/rest/rest.py @@ -47,9 +47,10 @@ def _put_request_body(attribute: AttrW[T]): Creates a pydantic model for each datatype which defines the schema of the PUT request body """ + type_name = str(attribute.datatype.dtype.__name__).title() # key=(type, ...) to declare a field without default value return create_model( - f"Put{str(attribute.datatype.dtype)}Value", + f"Put{type_name}Value", value=(attribute.datatype.dtype, ...), ) @@ -71,9 +72,10 @@ def _get_response_body(attribute: AttrR[T]): Creates a pydantic model for each datatype which defines the schema of the GET request body """ + type_name = str(attribute.datatype.dtype.__name__).title() # key=(type, ...) to declare a field without default value return create_model( - f"Get{str(attribute.datatype.dtype)}Value", + f"Get{type_name}Value", **{"value": (attribute.datatype.dtype, ...)}, # type: ignore ) From 5c9731a632f40862c8855d29a741c1cc760c98cb Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Mon, 18 Nov 2024 11:20:07 +0000 Subject: [PATCH 8/9] Fix missed unpack removal for get --- src/fastcs/backends/rest/rest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastcs/backends/rest/rest.py b/src/fastcs/backends/rest/rest.py index 0eedc3d4b..d41acdfde 100644 --- a/src/fastcs/backends/rest/rest.py +++ b/src/fastcs/backends/rest/rest.py @@ -76,7 +76,7 @@ def _get_response_body(attribute: AttrR[T]): # key=(type, ...) to declare a field without default value return create_model( f"Get{type_name}Value", - **{"value": (attribute.datatype.dtype, ...)}, # type: ignore + value=(attribute.datatype.dtype, ...), ) From 262fde41c86fd393c7b16211832c08d4f47dfc92 Mon Sep 17 00:00:00 2001 From: Marcell Nagy Date: Tue, 19 Nov 2024 13:52:30 +0000 Subject: [PATCH 9/9] Apply testing feedback from upstream --- tests/backends/rest/test_rest.py | 155 +++++++++++++++---------------- tests/conftest.py | 27 ++++-- 2 files changed, 93 insertions(+), 89 deletions(-) diff --git a/tests/backends/rest/test_rest.py b/tests/backends/rest/test_rest.py index 4998b5397..cd506acd2 100644 --- a/tests/backends/rest/test_rest.py +++ b/tests/backends/rest/test_rest.py @@ -1,91 +1,88 @@ -import copy -from typing import Any - import pytest from fastapi.testclient import TestClient -from fastcs.attributes import AttrR from fastcs.backends.rest.backend import RestBackend -from fastcs.datatypes import Bool, Float, Int - - -def kebab_2_snake(input: list[str]) -> list[str]: - snake_list = copy.deepcopy(input) - snake_list[-1] = snake_list[-1].replace("-", "_") - return snake_list class TestRestServer: - @pytest.fixture(scope="class", autouse=True) - def setup_class(self, assertable_controller): - self.controller = assertable_controller - @pytest.fixture(scope="class") - def client(self): - app = RestBackend(self.controller)._server._app + def client(self, assertable_controller): + app = RestBackend(assertable_controller)._server._app return TestClient(app) - @pytest.fixture(scope="class") - def client_read(self, client): - def _client_read(path: list[str], expected: Any): - route = "/" + "/".join(path) - with self.controller.assertPerformed(kebab_2_snake(path), "READ"): - response = client.get(route) - assert response.status_code == 200 - assert response.json()["value"] == expected - - return _client_read - - @pytest.fixture(scope="class") - def client_write(self, client): - def _client_write(path: list[str], value: Any): - route = "/" + "/".join(path) - with self.controller.assertPerformed(kebab_2_snake(path), "WRITE"): - response = client.put(route, json={"value": value}) - assert response.status_code == 204 - - return _client_write - - @pytest.fixture(scope="class") - def client_exec(self, client): - def _client_exec(path: list[str]): - route = "/" + "/".join(path) - with self.controller.assertPerformed(kebab_2_snake(path), "EXECUTE"): - response = client.put(route) + def test_read_int(self, assertable_controller, client): + expect = 0 + with assertable_controller.assert_read_here(["read_int"]): + response = client.get("/read-int") + assert response.status_code == 200 + assert response.json()["value"] == expect + + def test_read_write_int(self, assertable_controller, client): + expect = 0 + with assertable_controller.assert_read_here(["read_write_int"]): + response = client.get("/read-write-int") + assert response.status_code == 200 + assert response.json()["value"] == expect + new = 9 + with assertable_controller.assert_write_here(["read_write_int"]): + response = client.put("/read-write-int", json={"value": new}) + assert client.get("/read-write-int").json()["value"] == new + + def test_read_write_float(self, assertable_controller, client): + expect = 0 + with assertable_controller.assert_read_here(["read_write_float"]): + response = client.get("/read-write-float") + assert response.status_code == 200 + assert response.json()["value"] == expect + new = 0.5 + with assertable_controller.assert_write_here(["read_write_float"]): + response = client.put("/read-write-float", json={"value": new}) + assert client.get("/read-write-float").json()["value"] == new + + def test_read_bool(self, assertable_controller, client): + expect = False + with assertable_controller.assert_read_here(["read_bool"]): + response = client.get("/read-bool") + assert response.status_code == 200 + assert response.json()["value"] == expect + + def test_write_bool(self, assertable_controller, client): + with assertable_controller.assert_write_here(["write_bool"]): + client.put("/write-bool", json={"value": True}) + + def test_string_enum(self, assertable_controller, client): + expect = "" + with assertable_controller.assert_read_here(["string_enum"]): + response = client.get("/string-enum") + assert response.status_code == 200 + assert response.json()["value"] == expect + new = "new" + with assertable_controller.assert_write_here(["string_enum"]): + response = client.put("/string-enum", json={"value": new}) + assert client.get("/string-enum").json()["value"] == new + + def test_big_enum(self, assertable_controller, client): + expect = 0 + with assertable_controller.assert_read_here(["big_enum"]): + response = client.get("/big-enum") + assert response.status_code == 200 + assert response.json()["value"] == expect + + def test_go(self, assertable_controller, client): + with assertable_controller.assert_execute_here(["go"]): + response = client.put("/go") assert response.status_code == 204 - return _client_exec - - def test_read_int(self, client_read): - client_read(["read-int"], AttrR(Int())._value) - - def test_read_write_int(self, client_read, client_write): - client_read(["read-write-int"], AttrR(Int())._value) - client_write(["read-write-int"], AttrR(Int())._value) - - def test_read_write_float(self, client_read, client_write): - client_read(["read-write-float"], AttrR(Float())._value) - client_write(["read-write-float"], AttrR(Float())._value) - - def test_read_bool(self, client_read): - client_read(["read-bool"], AttrR(Bool())._value) - - def test_write_bool(self, client_write): - client_write(["write-bool"], AttrR(Bool())._value) - - # # We need to discuss enums - # def test_string_enum(self, client_read, client_write): - - def test_big_enum(self, client_read): - client_read( - ["big-enum"], AttrR(Int(), allowed_values=list(range(1, 18)))._value - ) - - def test_go(self, client_exec): - client_exec(["go"]) - - def test_read_child1(self, client_read): - client_read(["SubController01", "read-int"], AttrR(Int())._value) - - def test_read_child2(self, client_read): - client_read(["SubController02", "read-int"], AttrR(Int())._value) + def test_read_child1(self, assertable_controller, client): + expect = 0 + with assertable_controller.assert_read_here(["SubController01", "read_int"]): + response = client.get("/SubController01/read-int") + assert response.status_code == 200 + assert response.json()["value"] == expect + + def test_read_child2(self, assertable_controller, client): + expect = 0 + with assertable_controller.assert_read_here(["SubController02", "read_int"]): + response = client.get("/SubController02/read-int") + assert response.status_code == 200 + assert response.json()["value"] == expect diff --git a/tests/conftest.py b/tests/conftest.py index 9fa7212bf..727796428 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -102,17 +102,24 @@ def __init__(self, mocker: MockerFixture) -> None: self.mocker = mocker @contextmanager - def assertPerformed( - self, path: list[str], action: Literal["READ", "WRITE", "EXECUTE"] - ): + def assert_read_here(self, path: list[str]): + yield from self._assert_method(path, "get") + + @contextmanager + def assert_write_here(self, path: list[str]): + yield from self._assert_method(path, "process") + + @contextmanager + def assert_execute_here(self, path: list[str]): + yield from self._assert_method(path, "") + + def _assert_method(self, path: list[str], method: Literal["get", "process", ""]): + """ + This context manager can be used to confirm that a fastcs + controller's respective attribute or command methods are called + a single time within a context block + """ queue = copy.deepcopy(path) - match action: - case "READ": - method = "get" - case "WRITE": - method = "process" - case "EXECUTE": - method = "" # Navigate to subcontroller controller = self