From b2de312d42600eeac1c68fac2aafa0408c2bc7c4 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 3 Aug 2026 11:22:01 +0100 Subject: [PATCH 01/12] Test code for property errors This ought to be a helpful way to check when I've improved the error handling in properties. This is in response to #386. Unfortunately, too many of these tests already pass: this is an annoying artifact of the way TestClient is implemented. --- tests/test_property_errors.py | 122 ++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 tests/test_property_errors.py diff --git a/tests/test_property_errors.py b/tests/test_property_errors.py new file mode 100644 index 00000000..f3396642 --- /dev/null +++ b/tests/test_property_errors.py @@ -0,0 +1,122 @@ +"""Test how errors in property getters/setters are handled. + +This test file is intended to check the error handling code dealing with properties. +Errors in property getter/setter code should result in helpful HTTP errors and +Python exceptions. This test module is intended to check that various error +conditions result in easily-interpreted exceptions, log entries, and HTTP responses. +""" + +import pytest + +import labthings_fastapi as lt +from labthings_fastapi.testing import create_thing_without_server + + +class SpecificError(RuntimeError): + """A specific error class we can test for.""" + + +class ErrorThing(lt.Thing): + """A Thing that has properties with a variety of failure modes.""" + + @lt.property + def always_raises(self) -> int: + """An integer that errors on get and set.""" + raise SpecificError("'always_raises' failed, as expected.") + + @always_raises.setter + def _set_always_raises(self, value: int) -> None: + raise SpecificError("'always_raises' failed, as expected.") + + @lt.property + def wrongly_typed(self) -> int: + """A value of the wrong type.""" + return "not an integer" + + @lt.property + def violates_constraint(self) -> int: + """An integer that's outside the allowed range.""" + return 42 + + violates_constraint.constraints = {"le": 10} + + +@pytest.fixture +def thing(): + """A fixture returning an instance of ErrorThing.""" + return create_thing_without_server(ErrorThing) + + +@pytest.fixture +def client(): + """A fixture returning a ThingClient for an ErrorThing.""" + server = lt.ThingServer.from_things({"thing": ErrorThing}) + with server.test_client() as tc: + return lt.ThingClient.from_url("/thing/", client=tc) + + +# The functions below check errors when called directly from Python + + +def test_get_always_raises_python(thing: ErrorThing): + """Check always_raises errors when retrieved directly.""" + with pytest.raises(SpecificError, match="failed, as expected"): + _ = thing.always_raises + + +def test_set_always_raises_python(thing: ErrorThing): + """Check always_raises errors when set directly.""" + with pytest.raises(SpecificError, match="failed, as expected"): + thing.always_raises = 42 + + +def test_wrongly_typed_python(thing: ErrorThing): + """Check we can retrieve a wrongly typed value in Python.""" + # Currently, no validation is performed on property values + # when they are retrieved from Python + assert thing.wrongly_typed == "not an integer" + + +def test_violates_constraint_python(thing: ErrorThing): + """Check we can retrieve a value in Python that violates its constraint.""" + assert thing.violates_constraint == 42 + + +# The next section tests the same conditions as the previous one, but in the +# context of a server. +# Typing note: `client` is type hinted as an ErrorThing as it should have an +# equivalent signature. This is largely to enable static analysis in editors. +# It is actually a `lt.ThingClient` instance. + + +def test_get_always_raises_server(client: ErrorThing): + """Check always_raises errors nicely when retrieved over HTTP.""" + with pytest.raises(SpecificError, match="failed, as expected"): + _ = client.always_raises + + +def test_set_always_raises_server(client: ErrorThing): + """Check always_raises errors when set over HTTP.""" + with pytest.raises(SpecificError, match="failed, as expected"): + client.always_raises = 42 + + +def test_wrongly_typed_server(client: ErrorThing): + """Check the error when we return a wrongly typed value.""" + assert client.wrongly_typed == "not an integer" + + +def test_violates_constraint_server(client: ErrorThing): + """Check we can retrieve a value that violates its constraint. + + Constraints are not yet validated on the return values of property + getters. + """ + assert client.violates_constraint == 42 + + +if __name__ == "__main__": + # This block enables the test Thing here to be interacted with over + # HTTP, to allow manual testing from a variety of clients. + server = lt.ThingServer.from_things({"thing": ErrorThing}) + server.serve() From b25169c14f0fbbb862c4e115f243811af1f38013 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 3 Aug 2026 12:06:44 +0100 Subject: [PATCH 02/12] Catch and handle errors in properties This commit adds a decorator that provides a catch-all exception handler. If an exception occurs in a decorated function, it will log, and return a JSONResponse containing a ProblemDetails object, with an appropriate status code. This new wrapper is then used to ensure errors that occur in property get/set/reset functions are properly logged and a sensible HTTP response is given to the user. This needs testing explicitly in `test_problemdetails.py` and also needs tests fixing in `test_property_errors.py`. --- src/labthings_fastapi/problem_details.py | 57 ++++++++++++++++++++++++ src/labthings_fastapi/properties.py | 42 +++++++++-------- 2 files changed, 77 insertions(+), 22 deletions(-) diff --git a/src/labthings_fastapi/problem_details.py b/src/labthings_fastapi/problem_details.py index 0d7d1008..e50ec4e3 100644 --- a/src/labthings_fastapi/problem_details.py +++ b/src/labthings_fastapi/problem_details.py @@ -4,6 +4,12 @@ "Problem Details" objects to represent errors in HTTP responses. """ +from functools import wraps +from logging import Logger +from typing import Any, Callable, ParamSpec, TypeVar + +from fastapi import Response +from fastapi.responses import JSONResponse from pydantic import BaseModel, ConfigDict from typing_extensions import Self @@ -40,6 +46,15 @@ def from_exception(cls, exc: BaseException) -> Self: status=getattr(exc, "status_code", 500), ) + def json_response(self) -> Response: + """Return a JSONResponse representing this object. + + :return: a `JSONResponse` object suitable for returning from a request handler. + """ + # Typing note: this is typed as `Response` in case we move to using `pydantic` + # to serialise directly to JSON in the future. + return JSONResponse(self.model_dump(), status_code=self.status or 500) + # This URL should describe all exceptions in this module. DOCS_URL = ( @@ -61,3 +76,45 @@ def docs_url(exc: type[BaseException]) -> str | None: if exc.__module__ == "builtins": return f"{PYTHON_DOCS_URL}#{exc.__name__}" return None + + +Params = ParamSpec("Params") +ReturnT = TypeVar("ReturnT", bound=Response) + + +def exceptions_to_problemdetails( + logger: Logger | None, +) -> Callable[[Callable[Params, ReturnT]], Callable[Params, ReturnT]]: + """Decorate a function to handle errors with a `ProblemDetails` response. + + :param logger: the logger to use, or `None` for no logging. + :return: a decorator that handles errors by returning a `ProblemDetails` + object. + """ + + def decorator(func: Callable[Params, ReturnT]) -> Callable[Params, ReturnT]: + """Wrap a function in error handling code. + + This function is a decorator with no arguments, `logger` is available + as a local variable. + + :param func: the function to be wrapped. + :return: the function, wrapped in error handling code. + """ + + @wraps(func) + def decorated(*args: Any, **kwargs: Any) -> Any: + # Typing note: the type hints and docstring should be supplied by @wraps. + try: + return func(*args, **kwargs) + except Exception as e: # noqa: BLE001 + # This decorator is an intentional catch-all error handler, so BLE001 is + # appropriately ignored here. + if logger: + logger.error(e) + pd = ProblemDetails.from_exception(e) + return pd.json_response() + + return decorated + + return decorator diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index 4fcf19b2..9335cddb 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -61,7 +61,7 @@ class attribute. Documentation is in strings immediately following the overload, ) -from fastapi import Body, FastAPI, HTTPException, Response +from fastapi import Body, FastAPI, Response from pydantic import ( BaseModel, ConfigDict, @@ -80,7 +80,6 @@ class attribute. Documentation is in strings immediately following the ) from labthings_fastapi.exceptions import ( FeatureNotAvailableError, - InvalidReturnValueError, NotConnectedToServerError, PropertyRedefinitionError, ReadOnlyPropertyError, @@ -88,6 +87,7 @@ class attribute. Documentation is in strings immediately following the UnsupportedConstraintError, ) from labthings_fastapi.message_broker import Message +from labthings_fastapi.problem_details import exceptions_to_problemdetails from labthings_fastapi.thing_class_settings import get_validate_properties_on_set from labthings_fastapi.thing_description import type_to_dataschema from labthings_fastapi.thing_description._model import ( @@ -555,10 +555,12 @@ def add_to_fastapi(self, app: FastAPI, thing: Owner) -> None: # The function is initially defined with a ``body`` argument of type # ``Any`` but this will be replaced with the correct annotation a # few lines below. - def set_property(body: Any) -> None: + @exceptions_to_problemdetails(logger=thing.logger) + def set_property(body: Any) -> Response: if isinstance(body, RootModel): body = body.root self.__set__(thing, body) + return Response(status_code=201) set_property.__annotations__["body"] = Annotated[self.model, Body()] app.put( @@ -576,25 +578,19 @@ def set_property(body: Any) -> None: summary=self.title, description=f"## {self.title}\n\n{self.description or ''}", ) + @exceptions_to_problemdetails(logger=thing.logger) def get_property() -> Response: - try: - instance = validate_from_user_code( - model=self.model, - value=self.__get__(thing), - description=f"{thing.name}.{self.name}", - code=(self.owning_class, self.name), - ) - return serialise_from_user_code( - model_instance=instance, - description=f"{thing.name}.{self.name}", - code=(self.owning_class, self.name), - ) - except InvalidReturnValueError as e: - thing.logger.error(e) - raise HTTPException( - status_code=500, - detail=str(e), - ) from e + instance = validate_from_user_code( + model=self.model, + value=self.__get__(thing), + description=f"{thing.name}.{self.name}", + code=(self.owning_class, self.name), + ) + return serialise_from_user_code( + model_instance=instance, + description=f"{thing.name}.{self.name}", + code=(self.owning_class, self.name), + ) if self.is_resettable(thing): @@ -612,8 +608,10 @@ def get_property() -> Response: rf"with the ``name`` argument set to ``{self.name}``\ ." ), ) - def reset() -> None: + @exceptions_to_problemdetails(logger=thing.logger) + def reset() -> Response: self.reset(thing) + return Response(status_code=200) def property_affordance( self, thing: Owner, path: str | None = None From 11f83e93f51c7c9b87c8c29606a98ee4efd6f944 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 3 Aug 2026 12:08:45 +0100 Subject: [PATCH 03/12] Mock the logger in a test This is required by new error handling code in properties. --- tests/test_property.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_property.py b/tests/test_property.py index d5077d50..558a0cd0 100644 --- a/tests/test_property.py +++ b/tests/test_property.py @@ -231,8 +231,9 @@ class Example: """ prop._type = str | None - # Add a path attribute, so we can use Example as a mock Thing. + # Add path and logger, so we can use Example as a mock Thing. path = "/example/" + logger = None # Make a FastAPI app and retrieve the OpenAPI document app = fastapi.FastAPI() From a1242bcb90da93f162cb2b525b73f3d2c32b3e34 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 3 Aug 2026 15:01:01 +0100 Subject: [PATCH 04/12] Return a valid response when setting/resetting properties Previously, setting a property didn't return a response. I changed this to return an empty response, which then caused errors in ThingClient. I've now changed this to return a JSONResponse with a value of `None`. I believe this matches the previous behaviour, and fixes the test failures. --- src/labthings_fastapi/properties.py | 5 +++-- tests/test_property_errors.py | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index 9335cddb..c54c2ba6 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -62,6 +62,7 @@ class attribute. Documentation is in strings immediately following the ) from fastapi import Body, FastAPI, Response +from fastapi.responses import JSONResponse from pydantic import ( BaseModel, ConfigDict, @@ -560,7 +561,7 @@ def set_property(body: Any) -> Response: if isinstance(body, RootModel): body = body.root self.__set__(thing, body) - return Response(status_code=201) + return JSONResponse(None, status_code=201) set_property.__annotations__["body"] = Annotated[self.model, Body()] app.put( @@ -611,7 +612,7 @@ def get_property() -> Response: @exceptions_to_problemdetails(logger=thing.logger) def reset() -> Response: self.reset(thing) - return Response(status_code=200) + return JSONResponse(None, status_code=200) def property_affordance( self, thing: Owner, path: str | None = None diff --git a/tests/test_property_errors.py b/tests/test_property_errors.py index f3396642..451f03b2 100644 --- a/tests/test_property_errors.py +++ b/tests/test_property_errors.py @@ -9,6 +9,7 @@ import pytest import labthings_fastapi as lt +from labthings_fastapi.exceptions import ClientPropertyError from labthings_fastapi.testing import create_thing_without_server @@ -91,19 +92,23 @@ def test_violates_constraint_python(thing: ErrorThing): def test_get_always_raises_server(client: ErrorThing): """Check always_raises errors nicely when retrieved over HTTP.""" - with pytest.raises(SpecificError, match="failed, as expected"): + with pytest.raises(ClientPropertyError, match="failed, as expected"): _ = client.always_raises def test_set_always_raises_server(client: ErrorThing): """Check always_raises errors when set over HTTP.""" - with pytest.raises(SpecificError, match="failed, as expected"): + with pytest.raises(ClientPropertyError, match="failed, as expected"): client.always_raises = 42 def test_wrongly_typed_server(client: ErrorThing): """Check the error when we return a wrongly typed value.""" - assert client.wrongly_typed == "not an integer" + with pytest.raises( + ClientPropertyError, + match="Error validating thing.wrongly_typed", + ): + _ = client.wrongly_typed def test_violates_constraint_server(client: ErrorThing): @@ -112,7 +117,11 @@ def test_violates_constraint_server(client: ErrorThing): Constraints are not yet validated on the return values of property getters. """ - assert client.violates_constraint == 42 + with pytest.raises( + ClientPropertyError, + match="Error validating thing.violates_constraint", + ): + _ = client.violates_constraint if __name__ == "__main__": From 9f6031fb864a3a6e29f41a41b2a3e4097f18cf0f Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 3 Aug 2026 23:56:34 +0100 Subject: [PATCH 05/12] Verify that property set/reset response is unchanged I've run this test on both this branch and `main` to check that the response is identical. --- tests/test_properties.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_properties.py b/tests/test_properties.py index daf71f84..c0888496 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -294,6 +294,7 @@ def test_property_get_and_set(server): response = client.put("/thing/stringprop", json=test_str) # Check for a successful response code assert response.status_code == 201 + assert response.content == b"null" # Check it was written successfully after_value = client.get("/thing/stringprop") assert after_value.status_code == 200 From 4a4028fd4172ef5726cfbb76bfa0404505e63935 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Mon, 3 Aug 2026 23:56:55 +0100 Subject: [PATCH 06/12] Test new functions in `problem_details` submodule. --- tests/test_problem_details.py | 97 +++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/tests/test_problem_details.py b/tests/test_problem_details.py index 08c4cf55..daa909d9 100644 --- a/tests/test_problem_details.py +++ b/tests/test_problem_details.py @@ -1,6 +1,10 @@ """Test we can generate problem details to describe exceptions nicely.""" +import httpx2 import pytest +from fastapi import FastAPI, Response +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient from labthings_fastapi import exceptions, problem_details @@ -30,6 +34,21 @@ class CustomError(Exception): ] +# There are some valid ProblemDetails instances that couldn't be made by +# from_exception (for example, if detail or status is missing). We +# extend the list of test cases to include these. +PD_INSTANCES = [ + *[ + problem_details.ProblemDetails.from_exception(exc("Message")) + for exc, _url, _code in ERRORS + ], + problem_details.ProblemDetails(detail="Message"), + problem_details.ProblemDetails(detail="Message", status=501), + problem_details.ProblemDetails(), + problem_details.ProblemDetails(instance="Instance-specific message."), +] + + @pytest.mark.parametrize(("err", "url", "_code"), ERRORS) def test_docs_url(err, url, _code): """Check URLs for built-in and LabThings errors, and that we get None for others.""" @@ -43,3 +62,81 @@ def test_pd_from_exception(err, url, code): assert pd.detail == "Message" assert pd.title == err.__name__ assert pd.status == code + + +def evaluate_response(response: Response) -> httpx2.Response: + """Use a TestClient to turn a Starlette Response into an httpx2 one. + + This allows us to easily evaluate the body of a Response object. + This will create an ephemeral `TestClient` object. Doing so is not + massively efficient, but it doesn't slow down tests unduly and it + minimises custom code. + + :param response: a `fastapi.Response` object. + :return a `httpx2.Response` object. + """ + app = FastAPI() + + @app.get("/") + def return_error() -> Response: + return response + + with TestClient(app) as tc: + return tc.get("/") + + +@pytest.mark.parametrize("pd", PD_INSTANCES) +def test_response_from_exception(pd): + response = evaluate_response(pd.json_response()) + + assert response.status_code == pd.status or 500 + value = response.json() + assert value == pd.model_dump() + + +def test_exceptions_to_problemdetails_noerror(mocker): + """Check the `exceptions_to_problemdetails` decorator with no error.""" + + response = JSONResponse("success!", status_code=200) + logger = mocker.Mock() + + @problem_details.exceptions_to_problemdetails(logger=logger) + def successful(): + return response + + # The function should complete with no error and return the value + assert successful() is response + + # Our mocked logger should record nothing + assert logger.error.called is False + + +@pytest.mark.parametrize(("err", "url", "code"), ERRORS) +def test_exceptions_to_problemdetails_error(err, url, code, mocker): + """Check exceptions produce a response with the right message.""" + logger = mocker.Mock() + + @problem_details.exceptions_to_problemdetails(logger=logger) + def fails(): + raise err("Message") + + if not issubclass(err, Exception): + # BaseException (and other non-Exception errors) isn't caught. + with pytest.raises(err): + fails() + return + + # The function should complete, but the returned response describes + # the error. + response = evaluate_response(fails()) + assert response.status_code == code + value = response.json() + assert value["type"] == url + assert value["detail"] == "Message" + assert value["title"] == err.__name__ + assert value["status"] == code + + # Our mocked logger should record an error + assert logger.error.call_count == 1 + assert isinstance(logger.error.call_args[0][0], err) + assert str(logger.error.call_args[0][0]) == "Message" From 573e90ba35b61a753913466ab9f569c443f79be7 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 4 Aug 2026 00:04:46 +0100 Subject: [PATCH 07/12] Test that errors are logged properly by the decorator. --- tests/test_property_errors.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/test_property_errors.py b/tests/test_property_errors.py index 451f03b2..54ee0f6e 100644 --- a/tests/test_property_errors.py +++ b/tests/test_property_errors.py @@ -6,6 +6,8 @@ conditions result in easily-interpreted exceptions, log entries, and HTTP responses. """ +import logging + import pytest import labthings_fastapi as lt @@ -90,28 +92,37 @@ def test_violates_constraint_python(thing: ErrorThing): # It is actually a `lt.ThingClient` instance. -def test_get_always_raises_server(client: ErrorThing): +def test_get_always_raises_server(client: ErrorThing, caplog): """Check always_raises errors nicely when retrieved over HTTP.""" with pytest.raises(ClientPropertyError, match="failed, as expected"): _ = client.always_raises + assert caplog.record_tuples == [ + ("labthings_fastapi.things.thing", 40, "'always_raises' failed, as expected."), + ] -def test_set_always_raises_server(client: ErrorThing): +def test_set_always_raises_server(client: ErrorThing, caplog): """Check always_raises errors when set over HTTP.""" with pytest.raises(ClientPropertyError, match="failed, as expected"): client.always_raises = 42 + assert caplog.record_tuples == [ + ("labthings_fastapi.things.thing", 40, "'always_raises' failed, as expected."), + ] -def test_wrongly_typed_server(client: ErrorThing): +def test_wrongly_typed_server(client: ErrorThing, caplog): """Check the error when we return a wrongly typed value.""" with pytest.raises( ClientPropertyError, match="Error validating thing.wrongly_typed", ): _ = client.wrongly_typed + assert len(caplog.records) == 1 + assert caplog.records[0].levelno == logging.ERROR + assert "Error validating thing.wrongly_typed" in caplog.records[0].getMessage() -def test_violates_constraint_server(client: ErrorThing): +def test_violates_constraint_server(client: ErrorThing, caplog): """Check we can retrieve a value that violates its constraint. Constraints are not yet validated on the return values of property @@ -122,6 +133,9 @@ def test_violates_constraint_server(client: ErrorThing): match="Error validating thing.violates_constraint", ): _ = client.violates_constraint + assert len(caplog.records) == 1 + assert caplog.records[0].levelno == logging.ERROR + assert "validating thing.violates_constraint" in caplog.records[0].getMessage() if __name__ == "__main__": From 595c935c3615fd4c483ec20cae15cd7792eafcc2 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 4 Aug 2026 00:06:03 +0100 Subject: [PATCH 08/12] Remove name==main block I added a __name__ == "__main__" block for manual testing during development - this is no longer needed. --- tests/test_property_errors.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/test_property_errors.py b/tests/test_property_errors.py index 54ee0f6e..81e9e627 100644 --- a/tests/test_property_errors.py +++ b/tests/test_property_errors.py @@ -136,10 +136,3 @@ def test_violates_constraint_server(client: ErrorThing, caplog): assert len(caplog.records) == 1 assert caplog.records[0].levelno == logging.ERROR assert "validating thing.violates_constraint" in caplog.records[0].getMessage() - - -if __name__ == "__main__": - # This block enables the test Thing here to be interacted with over - # HTTP, to allow manual testing from a variety of clients. - server = lt.ThingServer.from_things({"thing": ErrorThing}) - server.serve() From 41c021fcc40910564a9e0270856c7143dc46ac14 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Tue, 4 Aug 2026 00:20:30 +0100 Subject: [PATCH 09/12] Add tests for property reset via HTTP This adds a base level of unit testing for resetting properties. --- tests/test_properties.py | 6 +++++- tests/test_property_errors.py | 25 ++++++++++++++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/tests/test_properties.py b/tests/test_properties.py index c0888496..f525bd9b 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -281,7 +281,7 @@ def func_prop(self) -> MyModel: assert Dummy.func_prop.value_type is MyModel -def test_property_get_and_set(server): +def test_property_get_and_set_and_reset(server): """Use PUT and GET requests to check the property. PUT sets the value and GET retrieves it, so we use a PUT @@ -299,6 +299,10 @@ def test_property_get_and_set(server): after_value = client.get("/thing/stringprop") assert after_value.status_code == 200 assert after_value.json() == test_str + # Reset the property over HTTP + response = client.post("/thing/stringprop/reset") + assert response.status_code == 200 + assert response.json() is None def test_boolprop(server): diff --git a/tests/test_property_errors.py b/tests/test_property_errors.py index 81e9e627..e84821ae 100644 --- a/tests/test_property_errors.py +++ b/tests/test_property_errors.py @@ -31,6 +31,10 @@ def always_raises(self) -> int: def _set_always_raises(self, value: int) -> None: raise SpecificError("'always_raises' failed, as expected.") + @always_raises.resetter + def _reset_always_raises(self) -> None: + raise SpecificError("'always_raises' failed, as expected.") + @lt.property def wrongly_typed(self) -> int: """A value of the wrong type.""" @@ -73,6 +77,12 @@ def test_set_always_raises_python(thing: ErrorThing): thing.always_raises = 42 +def test_reset_always_raises_python(thing: ErrorThing): + """Check always_raises errors when set directly.""" + with pytest.raises(SpecificError, match="failed, as expected"): + thing.properties["always_raises"].reset() + + def test_wrongly_typed_python(thing: ErrorThing): """Check we can retrieve a wrongly typed value in Python.""" # Currently, no validation is performed on property values @@ -101,13 +111,14 @@ def test_get_always_raises_server(client: ErrorThing, caplog): ] -def test_set_always_raises_server(client: ErrorThing, caplog): - """Check always_raises errors when set over HTTP.""" - with pytest.raises(ClientPropertyError, match="failed, as expected"): - client.always_raises = 42 - assert caplog.record_tuples == [ - ("labthings_fastapi.things.thing", 40, "'always_raises' failed, as expected."), - ] +def test_reset_always_raises_server(client: lt.ThingClient, caplog): + """Check always_raises errors when reset over HTTP.""" + # Reset isn't yet exposed in ThingClient, so we do it manually. + response = client.client.post("/thing/always_raises/reset") + assert response.status_code == 500 + value = response.json() + assert value["detail"] == "'always_raises' failed, as expected." + assert value["title"] == "SpecificError" def test_wrongly_typed_server(client: ErrorThing, caplog): From 2b208d6735df499fc6ded1d6f3d8f0846132e709 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 6 Aug 2026 09:47:37 +0100 Subject: [PATCH 10/12] Update tests/test_property_errors.py Co-authored-by: Beth <167304066+bprobert97@users.noreply.github.com> --- tests/test_property_errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_property_errors.py b/tests/test_property_errors.py index e84821ae..985b8848 100644 --- a/tests/test_property_errors.py +++ b/tests/test_property_errors.py @@ -78,7 +78,7 @@ def test_set_always_raises_python(thing: ErrorThing): def test_reset_always_raises_python(thing: ErrorThing): - """Check always_raises errors when set directly.""" + """Check always_raises errors when reset.""" with pytest.raises(SpecificError, match="failed, as expected"): thing.properties["always_raises"].reset() From 2612d5c6b4f2ff9657e35e9dfad9c9c4b09f1282 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 6 Aug 2026 09:51:06 +0100 Subject: [PATCH 11/12] Rename exceptions_to_problemdetails Thanks to @julianstirling for pointing out my inconsistent snake case before it hit `main`. --- src/labthings_fastapi/problem_details.py | 2 +- src/labthings_fastapi/properties.py | 8 ++++---- tests/test_problem_details.py | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/labthings_fastapi/problem_details.py b/src/labthings_fastapi/problem_details.py index e50ec4e3..09c9d4ce 100644 --- a/src/labthings_fastapi/problem_details.py +++ b/src/labthings_fastapi/problem_details.py @@ -82,7 +82,7 @@ def docs_url(exc: type[BaseException]) -> str | None: ReturnT = TypeVar("ReturnT", bound=Response) -def exceptions_to_problemdetails( +def exceptions_to_problem_details( logger: Logger | None, ) -> Callable[[Callable[Params, ReturnT]], Callable[Params, ReturnT]]: """Decorate a function to handle errors with a `ProblemDetails` response. diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py index c54c2ba6..2de6fc68 100644 --- a/src/labthings_fastapi/properties.py +++ b/src/labthings_fastapi/properties.py @@ -88,7 +88,7 @@ class attribute. Documentation is in strings immediately following the UnsupportedConstraintError, ) from labthings_fastapi.message_broker import Message -from labthings_fastapi.problem_details import exceptions_to_problemdetails +from labthings_fastapi.problem_details import exceptions_to_problem_details from labthings_fastapi.thing_class_settings import get_validate_properties_on_set from labthings_fastapi.thing_description import type_to_dataschema from labthings_fastapi.thing_description._model import ( @@ -556,7 +556,7 @@ def add_to_fastapi(self, app: FastAPI, thing: Owner) -> None: # The function is initially defined with a ``body`` argument of type # ``Any`` but this will be replaced with the correct annotation a # few lines below. - @exceptions_to_problemdetails(logger=thing.logger) + @exceptions_to_problem_details(logger=thing.logger) def set_property(body: Any) -> Response: if isinstance(body, RootModel): body = body.root @@ -579,7 +579,7 @@ def set_property(body: Any) -> Response: summary=self.title, description=f"## {self.title}\n\n{self.description or ''}", ) - @exceptions_to_problemdetails(logger=thing.logger) + @exceptions_to_problem_details(logger=thing.logger) def get_property() -> Response: instance = validate_from_user_code( model=self.model, @@ -609,7 +609,7 @@ def get_property() -> Response: rf"with the ``name`` argument set to ``{self.name}``\ ." ), ) - @exceptions_to_problemdetails(logger=thing.logger) + @exceptions_to_problem_details(logger=thing.logger) def reset() -> Response: self.reset(thing) return JSONResponse(None, status_code=200) diff --git a/tests/test_problem_details.py b/tests/test_problem_details.py index daa909d9..44a1ac6d 100644 --- a/tests/test_problem_details.py +++ b/tests/test_problem_details.py @@ -94,13 +94,13 @@ def test_response_from_exception(pd): assert value == pd.model_dump() -def test_exceptions_to_problemdetails_noerror(mocker): - """Check the `exceptions_to_problemdetails` decorator with no error.""" +def test_exceptions_to_problem_details_noerror(mocker): + """Check the `exceptions_to_problem_details` decorator with no error.""" response = JSONResponse("success!", status_code=200) logger = mocker.Mock() - @problem_details.exceptions_to_problemdetails(logger=logger) + @problem_details.exceptions_to_problem_details(logger=logger) def successful(): return response @@ -112,11 +112,11 @@ def successful(): @pytest.mark.parametrize(("err", "url", "code"), ERRORS) -def test_exceptions_to_problemdetails_error(err, url, code, mocker): +def test_exceptions_to_problem_details_error(err, url, code, mocker): """Check exceptions produce a response with the right message.""" logger = mocker.Mock() - @problem_details.exceptions_to_problemdetails(logger=logger) + @problem_details.exceptions_to_problem_details(logger=logger) def fails(): raise err("Message") From 10a5b3aaf29bc6c01c7f9a87df5de2afd073df37 Mon Sep 17 00:00:00 2001 From: Richard Bowman Date: Thu, 6 Aug 2026 09:55:08 +0100 Subject: [PATCH 12/12] Add a test for property set errors via HTTP --- tests/test_property_errors.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/test_property_errors.py b/tests/test_property_errors.py index 985b8848..f24b2a31 100644 --- a/tests/test_property_errors.py +++ b/tests/test_property_errors.py @@ -103,7 +103,11 @@ def test_violates_constraint_python(thing: ErrorThing): def test_get_always_raises_server(client: ErrorThing, caplog): - """Check always_raises errors nicely when retrieved over HTTP.""" + r"""Check always_raises errors nicely when retrieved over HTTP. + + Note that `caplog` here is capturing the *server* log, the client-side + code doesn't log, but does raise a `ClientPropertyError`\ . + """ with pytest.raises(ClientPropertyError, match="failed, as expected"): _ = client.always_raises assert caplog.record_tuples == [ @@ -111,6 +115,15 @@ def test_get_always_raises_server(client: ErrorThing, caplog): ] +def test_set_always_raises_server(client: ErrorThing, caplog): + """Check always_raises errors nicely when set over HTTP.""" + with pytest.raises(ClientPropertyError, match="failed, as expected"): + client.always_raises = 42 + assert caplog.record_tuples == [ + ("labthings_fastapi.things.thing", 40, "'always_raises' failed, as expected."), + ] + + def test_reset_always_raises_server(client: lt.ThingClient, caplog): """Check always_raises errors when reset over HTTP.""" # Reset isn't yet exposed in ThingClient, so we do it manually.