diff --git a/.codespellrc b/.codespellrc index a8af5d40..061e2403 100644 --- a/.codespellrc +++ b/.codespellrc @@ -7,6 +7,8 @@ skip = *.git, ./build, ./dist, ./docs/_build, + ./docs/build, + ./docs/source/autoapi, ./htmlcov, ./src/openflexure_microscope_server/static, .venv, diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 97dc8a5a..21d449cf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -75,6 +75,9 @@ jobs: - name: Analyse with MyPy run: mypy src + + - name: Type tests with MyPy + run: mypy --warn-unused-ignores typing_tests test-with-unpinned-deps: runs-on: ubuntu-latest diff --git a/dev-requirements.txt b/dev-requirements.txt index ac4a5d0e..c2a38e6b 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -34,6 +34,12 @@ click==8.2.1 # uvicorn codespell==2.4.1 # via labthings-fastapi (pyproject.toml) +colorama==0.4.6 + # via + # click + # pytest + # sphinx + # uvicorn coverage==7.9.2 # via pytest-cov dnspython==2.7.0 @@ -49,6 +55,10 @@ email-validator==2.2.0 # via # fastapi # pydantic +exceptiongroup==1.3.0 + # via + # anyio + # pytest fastapi==0.116.1 # via labthings-fastapi (pyproject.toml) fastapi-cli==0.0.8 @@ -58,13 +68,10 @@ fastapi-cloud-cli==0.1.4 flake8==7.3.0 # via # labthings-fastapi (pyproject.toml) - # flake8-docstrings # flake8-pyproject # flake8-rst # flake8-rst-docstrings # pydoclint -flake8-docstrings==1.7.0 - # via labthings-fastapi (pyproject.toml) flake8-pyproject==1.2.3 # via labthings-fastapi (pyproject.toml) flake8-rst==0.8.0 @@ -152,8 +159,6 @@ pydantic-settings==2.10.1 # via fastapi pydoclint==0.6.6 # via labthings-fastapi (pyproject.toml) -pydocstyle==6.3.0 - # via flake8-docstrings pyflakes==3.4.0 # via flake8 pygments==2.19.2 @@ -210,9 +215,7 @@ shellingham==1.5.4 sniffio==1.3.1 # via anyio snowballstemmer==3.0.1 - # via - # pydocstyle - # sphinx + # via sphinx sphinx==8.1.3 # via # labthings-fastapi (pyproject.toml) @@ -239,6 +242,14 @@ sphinxcontrib-serializinghtml==2.0.0 # via sphinx starlette==0.47.1 # via fastapi +tomli==2.2.1 + # via + # coverage + # flake8-pyproject + # mypy + # pydoclint + # pytest + # sphinx typer==0.16.0 # via # fastapi-cli @@ -249,16 +260,20 @@ typing-extensions==4.14.1 # via # labthings-fastapi (pyproject.toml) # anyio + # astroid + # exceptiongroup # fastapi # mypy # pydantic # pydantic-core # pydantic-extra-types # referencing + # rich # rich-toolkit # starlette # typer # typing-inspection + # uvicorn typing-inspection==0.4.1 # via pydantic-settings ujson==5.10.0 @@ -272,8 +287,6 @@ uvicorn==0.35.0 # fastapi # fastapi-cli # fastapi-cloud-cli -uvloop==0.21.0 - # via uvicorn watchfiles==1.1.0 # via uvicorn websockets==15.0.1 diff --git a/docs/source/documentation.rst b/docs/source/documentation.rst new file mode 100644 index 00000000..5a781435 --- /dev/null +++ b/docs/source/documentation.rst @@ -0,0 +1,33 @@ +.. _gen_docs: + +Generated documentation +======================= + +LabThings describes its HTTP API in two ways: with a :ref:`wot_td` and with an OpenAPI_ document. + +.. _openapi: + +OpenAPI +------- + +OpenAPI_ is a standard way to describe an HTTP interface. It lists all of the possible HTTP requests that may be made, along with a description of each one, and a description of the possible responses. + +.. _gen_td: + +Thing Description +----------------- + +Each :ref:`wot_thing` is documented by a Thing Description, which is a JSON document describing all of the ways to interact with that Thing (:ref:`wot_affordances`\ ). The WoT_ standard defines the `Thing Description`_ and includes a JSON Schema against which it may be validated. + +Thing Description documents are higher-level than OpenAPI_ and focus on the capabilities of the Thing. For example, they include a list of properties, where each action is described only once. LabThings treats the Thing Description as your public API, and as a general rule anything not described in the Thing Description is not available over HTTP or to a `.DirectThingClient`\ . + +Comparison of Thing Description and OpenAPI +------------------------------------------- + +Thing Description aims to be a neat way to describe the capabilities of a Thing, while OpenAPI focuses on detailed documentation of every possible interaction with the server. Thing Description is a newer and less well adopted standard that's specific to the Web of Things, while OpenAPI has been around for a while and is widely used and understood as a general-purpose API description. + +OpenAPI describes each HTTP endpoint individually. There are usually more HTTP endpoints than there are :ref:`wot_affordances` because a Property may have two endpoints, one to read its value and one to write it. Actions usually correspond to several endpoints; one to invoke the action, one to check the action's status, one to cancel it, and another to retrieve its output once it has finished. In principle, client code based on a Thing Description should be more meaningful because related endpoints can be grouped together into properties or actions that correspond to structures in the programming language (like methods and properties in Python). However, OpenAPI is a much more widely adopted standard and so both forms of documentation are generated by LabThings-FastAPI. + +.. _WoT: https://www.w3.org/WoT/ +.. _Thing Description: https://www.w3.org/TR/wot-thing-description/ +.. _OpenAPI: https://www.openapis.org/ diff --git a/docs/source/index.rst b/docs/source/index.rst index 2a38b82e..81c9301d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -15,6 +15,7 @@ Documentation for LabThings-FastAPI blobs.rst concurrency.rst using_things.rst + see_also.rst autoapi/index diff --git a/docs/source/quickstart/counter.py b/docs/source/quickstart/counter.py index ea2990c5..efe22030 100644 --- a/docs/source/quickstart/counter.py +++ b/docs/source/quickstart/counter.py @@ -22,9 +22,8 @@ def slowly_increase_counter(self) -> None: time.sleep(1) self.increment_counter() - counter = lt.ThingProperty( - model=int, initial_value=0, readonly=True, description="A pointless counter" - ) + counter: int = lt.property(default=0, readonly=True) + "A pointless counter" if __name__ == "__main__": diff --git a/docs/source/see_also.rst b/docs/source/see_also.rst new file mode 100644 index 00000000..cbb6b696 --- /dev/null +++ b/docs/source/see_also.rst @@ -0,0 +1,48 @@ +See Also +======== + +LabThings-FastAPI makes quite heavy use of a few key concepts from external libraries, including `fastapi`, `pydantic`, and of course Python's core library. This page attempts to summarise these, and also acts as a useful place for docstrings to link to, so we can avoid repetition. + +.. _descriptors: + +Descriptors +----------- + +Descriptors are a way to intercept attribute access on an object. By default, attributes of an object are just variables - so an object called ``foo`` might have an attribute called ``bar``, and you may read its value with ``foo.bar``, write its value with ``foo.bar = "baz"``, and delete the attribute with ``del foo.bar``. If ``foo`` is a descriptor, Python will call the ``__get__`` method of that descriptor when it's read and the ``__set__`` method when it's written to. You have quite probably used a descriptor already, because the built-in `~builtins.property` creates a descriptor object: that's what runs your getter method when the property is accessed. The descriptor protocol is described with plenty of examples in the `Descriptor Guide`_ in the Python documentation. + +In LabThings-FastAPI, descriptors are used to implement :ref:`wot_actions` and :ref:`wot_properties` on `.Thing` subclasses. The intention is that these will function like standard Python methods and properties, but will also be available over HTTP, along with automatic documentation in the :ref:`wot_td` and OpenAPI documents. + +There are a few useful notes that relate to many of the descriptors in LabThings-FastAPI: + +* Descriptor objects **may have more than one owner**. As a rule, a descriptor object + (e.g. an instance of `.DataProperty`) is assigned to an attribute of one `.Thing` subclass. There may, however, be multiple *instances* of that class, so it is not safe to assume that the descriptor object corresponds to only one `.Thing`. This is why the `.Thing` is passed to the ``__get__`` method: we should ensure that any values being remembered are keyed to the owning `.Thing` and are not simply stored in the descriptor. Usually, this is done using `.WeakKeyDictionary` objects, which allow us to look up values based on the `.Thing`, without interfering with garbage collection. + + The example below shows how this can go wrong. + + .. code-block:: python + + class BadProperty: + "An example of a descriptor that has unwanted behaviour." + def __init__(self): + self._value = None + + def __get__(self, obj): + return self._value + + def __set__(self, obj, val): + self._value = val + + class BrokenExample: + myprop = BadProperty() + + a = BrokenExample() + b = BrokenExample() + + assert a.myprop is None + b.myprop = True + assert a.myprop is None # FAILS because `myprop` shares values between a and b + +* Descriptor objects **may know their name**. Python calls ``__set_name__`` on a descriptor if it is available. This allows the descriptor to know the name of the attribute to which it is assigned. LabThings-FastAPI uses the name in the URL and in the Thing Description. When ``__set_name__`` is called, the descriptor **is also passed the class that owns it**. This allows us to check for type hints and docstrings that are part of the class, rather than part of the descriptor. +* There is a convention that descriptors return their value when accessed as an instance attribute, but return themselves when accessed as a class attribute (as done by `builtins.property`). LabThings adheres to that convention. + +.. _`Descriptor Guide`: https://docs.python.org/3/howto/descriptor.html \ No newline at end of file diff --git a/docs/source/tutorial/index.rst b/docs/source/tutorial/index.rst index 19a2f06f..7bd46ef2 100644 --- a/docs/source/tutorial/index.rst +++ b/docs/source/tutorial/index.rst @@ -7,6 +7,8 @@ LabThings-FastAPI tutorial installing_labthings.rst running_labthings.rst + writing_a_thing.rst + properties.rst .. In due course, these pages should exist... diff --git a/docs/source/tutorial/properties.rst b/docs/source/tutorial/properties.rst new file mode 100644 index 00000000..9e3b970e --- /dev/null +++ b/docs/source/tutorial/properties.rst @@ -0,0 +1,136 @@ +.. _tutorial_properties: + +Properties +========================= + + + +Properties are values that can be read from and written to a Thing. They are used to represent the state of the Thing, such as its current temperature, brightness, or status. :ref:`wot_properties` are a key concept in the Web of Things standard. + +LabThings implements properties in a very similar way to the built-in Python `~builtins.property`. The key difference is that defining an attribute as a `.property` means that the property will be listed in the :ref:`gen_td` and exposed over HTTP. This is important for two reasons: + +* Only properties declared using `.property` (usually imported as ``lt.property``) can be accessed over HTTP. Regular attributes or properties using `builtins.property` are only available to your `.Thing` internally, except in some special cases. +* Communication between `.Thing`\ s within a LabThings server should be done using a `.DirectThingClient` class. The purpose of `.DirectThingClient` is to provide the same interface as a `.ThingClient` over HTTP, so it will also only expose functionality described in the Thing Description. + +You can add properties to a `.Thing` by using `.property` (usually imported as ``lt.property``). + +Data properties +------------------------- + +Data properties behave like variables: they simply store a value that is used by other code on the `.Thing`. They are defined similarly to fields in `dataclasses` or `pydantic` models: + +.. code-block:: python + + import labthings_fastapi as lt + + class MyThing(lt.Thing): + my_property: int = lt.property(default=42) + +The example above defines a property called `my_property` that has a default value of `42`. Note the type hint `int` which indicates that the property should hold an integer value. This is important, as the type will be enforced when the property is written to via HTTP, and it will appear in :ref:`gen_docs`. By default, this property may be read or written to by HTTP requests. If you want to make it read-only, you can set the `readonly` parameter to `True`: + +.. code-block:: python + + class MyThing(lt.Thing): + my_property: int = lt.property(default=42, readonly=True) + +Note that the ``readonly`` parameter only affects *client* code, i.e. it may not be written to via HTTP requests or `.DirectThingClient` instances. However, the property can still be modified by the Thing's code, e.g. in response to an action or another property change as ``self.my_property = 100``. + +It is a good idea to make sure there is a docstring for your property. This will be used in the :ref:`gen_docs`, and it will help users understand what the property is for. You can add a docstring to the property by placing a string immediately after the property definition: + +.. code-block:: python + + class MyThing(lt.Thing): + my_property: int = lt.property(default=42, readonly=True) + """A property that holds an integer value.""" + +You don't need to include the type in the docstring, as it will be inferred from the type hint. However, you can include additional information about the property, such as its units or any constraints on its value. + +Data properties may be *observed*, which means notifications will be sent when the property is written to (see below). + +Functional properties +------------------------- + +It is also possible to have properties that run code when they are read or written to. These are called functional properties, and they are defined using the `lt.FunctionalProperty` class. They might communicate with hardware (for example to read or write a setting on an instrument), or they might perform some computation based on other properties. They are defined with a decorator, very similarly to the built-in `property` function: + +.. code-block:: python + + import labthings_fastapi as lt + + class MyThing(lt.Thing): + my_property: int = lt.property(default=42) + """A property that holds an integer value.""" + + @lt.property + def twice_my_property(self) -> int: + """Twice the value of my_property.""" + return self.my_property * 2 + +The example above defines a functional property called `twice_my_property` that returns twice the value of `my_property`. The type hint `-> int` indicates that the property should return an integer value. When this property is read via HTTP, the code in the method will be executed, and the result will be returned to the client. As with `property`, the docstring of the property is taken from the method's docstring, so you can include additional information about the property there. + +Functional properties may also have a "setter" method, which is called when the property is written to via HTTP. This allows you to perform some action when the property is set, such as updating a hardware setting or performing some computation. The setter method should take a single argument, which is the new value of the property: + +.. code-block:: python + + import labthings_fastapi as lt + + class MyThing(lt.Thing): + my_property: int = lt.property(default=42) + """A property that holds an integer value.""" + + @lt.property + def twice_my_property(self) -> int: + """Twice the value of my_property.""" + return self.my_property * 2 + + @twice_my_property.setter + def twice_my_property(self, value: int): + """Set the value of twice_my_property.""" + self.my_property = value // 2 + +Adding a setter makes the property read-write (if only a getter is present, it must be read-only). + +It is possible to make a property read-only for clients by setting its ``readonly`` attribute: this has the same behaviour as for data properties. + +.. code-block:: python + + import labthings_fastapi as lt + + class MyThing(lt.Thing): + my_property: int = lt.property(default=42) + """A property that holds an integer value.""" + + @lt.property + def twice_my_property(self) -> int: + """Twice the value of my_property.""" + return self.my_property * 2 + + @twice_my_property.setter + def twice_my_property(self, value: int): + """Set the value of twice_my_property.""" + self.my_property = value // 2 + + # Make the property read-only for clients + twice_my_property.readonly = True + +In the example above, ``twice_my_property`` may be set by code within ``MyThing`` but cannot be written to via HTTP requests or `.DirectThingClient` instances. + +Functional properties may not be observed, as they are not backed by a simple value. If you need to notify clients when the value changes, you can use a data property that is updated by the functional property. In the example above, ``my_property`` may be observed, while ``twice_my_property`` cannot be observed. It would be possible to observe changes in ``my_property`` and then query ``twice_my_property`` for its new value. + +HTTP interface +-------------- + +LabThings is primarily controlled using HTTP. Mozilla have a good `Overview of HTTP`_ that is worth a read if you are unfamiliar with the concept of requests, or what ``GET`` and ``PUT`` mean. + +Each property in LabThings will be assigned a URL, which allows it to be read and (optionally) written to. The easiest way to explore this is in the interactive OpenAPI documentation, served by your LabThings server at ``/docs``\ . Properties can be read using a ``GET`` request and written using a ``PUT`` request. + +LabThings follows the `HTTP Protocol Binding`_ from the Web of Things standard. That's quite a detailed document: for a gentle introduction to HTTP and what a request means, see + +.. _`Overview of HTTP`: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Overview +.. _`HTTP Protocol Binding`: https://w3c.github.io/wot-binding-templates/bindings/protocols/http/index.html + +Observable properties +------------------------- + +Properties can be made observable, which means that clients can subscribe to changes in the property's value. This is useful for properties that change frequently, such as sensor readings or instrument settings. In order for a property to be observable, LabThings must know whenever it changes. Currently, this means only data properties can be observed, as functional properties do not have a simple value that can be tracked. + +Properties are currently only observable via websockets: in the future, it may be possible to observe them from other `.Thing` instances or from other parts of the code. diff --git a/docs/source/tutorial/writing_a_thing.rst b/docs/source/tutorial/writing_a_thing.rst new file mode 100644 index 00000000..9bec6327 --- /dev/null +++ b/docs/source/tutorial/writing_a_thing.rst @@ -0,0 +1,44 @@ +.. tutorial_thing: + +Writing a Thing +========================= + +In this section, we will write a simple example `.Thing` that provides some functionality on the server. + +.. note:: + + Usually, you will write your own `.Thing` in a separate Python module and run it using a configuration file as described in :ref:`tutorial_running`. However, for this tutorial, we will write the `.Thing` in a single file, and use a ``__name__ == "__main__"`` block to run it directly. This is not recommended for production code, but it is convenient for a tutorial. + +Our first Thing will pretend to be a light: we can set its brightness and turn it on and off. A first, most basic implementation might look like: + +.. code-block:: python + + import labthings_fastapi as lt + + class Light(lt.Thing): + """A computer-controlled light, our first example Thing.""" + + brightness: int = lt.property(default=100) + """The brightness of the light, in % of maximum.""" + + is_on: bool = lt.property(default=False, readonly=true) + """Whether the light is currently on.""" + + @lt.action + def toggle(self): + """Swap the light between on and off.""" + self.is_on = not self.is_on + + + light = Light() + server = lt.ThingServer() + server.add_thing("/light", light) + + if __name__ == "__main__": + import uvicorn + # We run the server using `uvicorn`: + uvicorn.run(server.app, port=5000) + +If you visit `http://localhost:5000/light`, you will see the Thing Description. You can also interact with it using the OpenAPI documentation at `http://localhost:5000/docs`. If you visit `http://localhost:5000/light/brightness`, you can set the brightness of the light, and if you visit `http://localhost:5000/light/is_on`, you can see whether the light is on. Changing values on the server requires a ``PUT`` or ``POST`` request, which is easiest to do using the OpenAPI "Try it out" feature. Check that you can use a ``POST`` request to the ``toggle`` endpoint to turn the light on and off. + +There are two types of :ref:`wot_affordances` in this example: properties and actions. Properties are used to read and write values, while actions are used to perform operations that change the state of the Thing. In this case, we have a property for the brightness of the light and a property to indicate whether the light is on or off. The action ``toggle`` changes the state of the light by toggling the ``is_on`` property between ``True`` and ``False``. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e0b0758a..772475e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,6 @@ dev = [ "Pillow", "flake8", "flake8-pyproject", - "flake8-docstrings", "flake8-rst", "flake8-rst-docstrings", "pydoclint[flake8]", @@ -76,28 +75,45 @@ target-version = "py310" docstring-code-format = true [tool.ruff.lint] +external = ["DOC401", "F824", "DOC101", "DOC103"] # used via flake8/pydoclint select = ["E4", "E7", "E9", "F", "D", "DOC"] ignore = [ "D203", # incompatible with D204 "D213", # incompatible with D212 - "DOC402", # doesn't work with sphinx-style docstrings, use pydoclint - "DOC201", # doesn't work with sphinx-style docstrings, use pydoclint - "DOC501", # doesn't work with sphinx-style docstrings, use pydoclint - "DOC502", # doesn't work with sphinx-style docstrings, use pydoclint + "DOC402", # doesn't work with sphinx-style docstrings, use flake8/pydoclint + "DOC201", # doesn't work with sphinx-style docstrings, use flake8/pydoclint + "DOC501", # doesn't work with sphinx-style docstrings, use flake8/pydoclint + "DOC502", # doesn't work with sphinx-style docstrings, use flake8/pydoclint ] preview = true [tool.ruff.lint.per-file-ignores] +# Tests are currently not fully docstring-ed, we'll ignore this for now. "tests/*" = ["D", "DOC"] +# Typing tests do have docstrings, but it's not helpful to insist on imperative +# mood etc. +"typing_tests/*" = ["D404", "D401"] "docs/*" = ["D", "DOC"] +[tool.ruff.lint.pydocstyle] +# This lets the D401 checker understand that decorated thing properties and thing +# settings act like properties so should be documented as such. +property-decorators = [ + "labthings_fastapi.property", + "labthings_fastapi.setting", +] + [tool.mypy] -plugins = ["pydantic.mypy", "numpy.typing.mypy_plugin"] +plugins = ["pydantic.mypy"] + [tool.flake8] extend-ignore = [ "DOC301", # allow class + __init__ docstrings - "D202", # conflicts with ruff format + "F401", # already implemented by ruff, which respects "import x as x" + "E301", # leave this to ruff (blank lines) + "E302", # leave this to ruff (blank lines) + "E501", # leave this to ruff (line length), #noqa may exceed max line length. ] max-line-length = 88 rst-roles = [ diff --git a/src/labthings_fastapi/__init__.py b/src/labthings_fastapi/__init__.py index ec969f65..a77559ef 100644 --- a/src/labthings_fastapi/__init__.py +++ b/src/labthings_fastapi/__init__.py @@ -20,10 +20,8 @@ """ from .thing import Thing -from .descriptors import ThingProperty, ThingSetting +from .properties import property, setting, DataProperty, DataSetting from .decorators import ( - thing_property, - thing_setting, thing_action, fastapi_endpoint, ) @@ -42,10 +40,10 @@ # re-export style, we may switch in the future. __all__ = [ "Thing", - "ThingProperty", - "ThingSetting", - "thing_property", - "thing_setting", + "property", + "setting", + "DataProperty", + "DataSetting", "thing_action", "fastapi_endpoint", "deps", diff --git a/src/labthings_fastapi/base_descriptor.py b/src/labthings_fastapi/base_descriptor.py new file mode 100644 index 00000000..919a1f0d --- /dev/null +++ b/src/labthings_fastapi/base_descriptor.py @@ -0,0 +1,467 @@ +"""A base class for descriptors in LabThings. + +:ref:`descriptors` are used to describe :ref:`wot_affordances` in LabThings-FastAPI. +There is some behaviour common to most of these, and `.BaseDescriptor` centralises +the code that implements it. +""" + +from __future__ import annotations +import ast +import inspect +from itertools import pairwise +import textwrap +from typing import overload, Generic, Mapping, TypeVar, TYPE_CHECKING +from types import MappingProxyType +from weakref import WeakKeyDictionary +from typing_extensions import Self + +from .utilities.introspection import get_docstring, get_summary + +if TYPE_CHECKING: + from .thing import Thing + +Value = TypeVar("Value") +"""The value returned by the descriptor, when called on an instance.""" + + +class DescriptorNotAddedToClassError(RuntimeError): + """Descriptor has not yet been added to a class. + + This error is raised if certain properties of descriptors are accessed + before ``__set_name__`` has been called on the descriptor. ``__set_name__`` + is part of the descriptor protocol, and is called when a class is defined + to notify the descriptor of its name and owning class. + + If you see this error, it often means that a descriptor has been instantiated + but not attached to a class, for example: + + .. code-block:: python + + import labthings as lt + + + class Test(lt.Thing): + myprop: int = lt.property(default=0) # This is OK + + + orphaned_prop: int = lt.property(default=0) # Not OK + + Test.myprop.model # Evaluates to a pydantic model + + orphaned_prop.model # Raises this exception + """ + + +class DescriptorAddedToClassTwiceError(RuntimeError): + """A Descriptor has been added to a class more than once. + + This error is raised if ``__set_name__`` is called more than once on a + descriptor. This happens when either the same descriptor instance is + used twice in one class definition, or if a descriptor instance is used + on more than one class. + + .. note:: + + `.FunctionalProperty` includes a special case that will ignore the + ``__set_name__`` call corresponding to the setter. This allows the + property to be defined like ``prop4`` below, even though it does + assign the descriptor to two names. That behaviour is specific to + `.FunctionalProperty` and `.FunctionalSetting` and is not part of + `.BaseDescriptor` because `.BaseDescriptor` has no setter. + + ``mypy`` does not allow custom property-like descriptors to follow the + syntax used by the built-in ``property`` of giving both the getter and + setter functions the same name: this causes an error because it is + a redefinition. We suggest using a different name for the setter to + work around this, hence the need for an exception. + + .. code-block:: python + + class MyDescriptor(BaseDescriptor): + "An example descriptor that inherits from BaseDescriptor." + + def __init__(getter=None): + "Initialise the descriptor, allowing use as a decorator." + self._getter = getter + + def setter(self, setter): + "Add a setter to the descriptor." + self._setter = setter + return self + + + class Example: + "An example class with descriptors." + + # prop1 is fine - only used once. + prop1 = MyDescriptor() + + # prop2 reuses the name ``prop2`` which may confuse ``mypy`` but + # will only call ``__set_name__`` once. + @MyDescriptor + def prop2(self): + "A dummy property" + return False + + @prop2.setter + def prop2(self, val): + "Set the dummy property" + pass + + # prop3a and prop3b will cause this error + prop3a = MyDescriptor() + prop3b = MyDescriptor() + + # prop4 and set_prop4 will cause this error on BaseDescriptor + # but there is a specific exception in FunctionalProperty + # to allow this form. + @MyDescriptor + def prop4(self): + "An example property with two names" + return True + + @prop4.setter + def set_prop4(self, val): + "A setter for prop4 that is not named prop4." + pass + + .. note:: + + Because this exception is raised in ``__set_name__`` it will not + appear to come from the descriptor assignment, but instead it will + be raised at the end of the class definition. The descriptor name(s) + should be in the error message. + + """ + + +class BaseDescriptor(Generic[Value]): + r"""A base class for descriptors in LabThings-FastAPI. + + This class implements several behaviours common to descriptors in LabThings: + + * The descriptor remembers the name it's assigned to in ``name``, for use in + :ref:`gen_docs`\ . + * The descriptor inspects its owning class, and looks for an attribute + docstring (i.e. a string constant immediately following the attribute + assignment). + * When called as a class attribute, the descriptor returns itself, as done by + e.g. `property`. + * The docstring and name are used to provide a ``title`` and ``description`` + that may be used in :ref:`gen_docs` and elsewhere. + + .. code-block:: python + + class Example: + my_prop = BaseDescriptor() + '''My Property. + + This is a nice long docstring describing my property, which + can span multiple lines. + ''' + + + p = Example.my_prop + assert p.name == "my_prop" + assert p.title == "My Property." + assert p.description.startswith("This is") + """ + + def __init__(self) -> None: + """Initialise a BaseDescriptor.""" + self._name: str | None = None + self._title: str | None = None + self._description: str | None = None + # We set the instance __doc__ to None so the descriptor class docstring + # doesn't get picked up by OpenAPI/Thing Description. + self.__doc__ = None + # We explicitly check when __set_name__ is called, so we can raise helpful + # errors + self._set_name_called: bool = False + self._owner_name: str = "" + + def __set_name__(self, owner: type[Thing], name: str) -> None: + r"""Take note of the name to which the descriptor is assigned. + + This is called when the descriptor is assigned to an attribute of a class. + This function remembers the name, so it can be used in :ref:`gen_docs`\ . + + This function also inspects the owning class, and will retrieve the + docstring for its attribute. This allows us to use a string immediately + after the descriptor is defined, rather than passing the docstring as + an argument. + See `.get_class_attribute_docstrings` for more details. + + :param owner: the `.Thing` subclass to which we are being attached. + :param name: the name to which we have been assigned. + + :raises DescriptorAddedToClassTwiceError: if the descriptor has been + assigned to two class attributes. + """ + if self._set_name_called: + raise DescriptorAddedToClassTwiceError( + f"The descriptor {self._name} on {self._owner_name} has been " + f"added to a class a second time ({owner.__qualname__}.{name}). " + "This descriptor may only be added to a class once." + ) + # Remember the name to which we're assigned. Accessed by the read only + # property ``name``. + self._set_name_called = True + self._name = name + self._owner_name = owner.__qualname__ + + # Check for docstrings on the owning class, and retrieve the one for + # this attribute (identified by `name`). + attr_docs = get_class_attribute_docstrings(owner) + if name in attr_docs: + self.__doc__ = attr_docs[name] + + def assert_set_name_called(self) -> None: + """Raise an exception if ``__set_name__`` has not yet been called. + + :raises DescriptorNotAddedToClassError: if ``__set_name__`` has not yet + been called. + """ + if not self._set_name_called: + raise DescriptorNotAddedToClassError( + f"{self.__class__.__name__} must be assigned to an attribute of " + "a class, as part of the class definition. This exception is " + "raised because `__set_name__` has not yet been called, which " + "usually means it was not instantiated as a class attribute." + ) + + @property + def name(self) -> str: + """The name of this descriptor. + + When the descriptor is assigned to an attribute of a class, we + remember the name of the attribute. There will be some time in + between the descriptor being instantiated and the name being set. + + We call `.BaseDescriptor.assert_set_name_called` so an exception will + be raised if this property is accessed before the descriptor has been + assigned to a class attribute. + + The ``name`` of :ref:`wot_affordances` is used in their URL and in + the :ref:`gen_docs` served by LabThings. + """ + self.assert_set_name_called() + assert self._name is not None + # The assert statement is mostly for typing: if assert_set_name_called + # doesn't raise an error, self._name has been set. + return self._name + + @property + def title(self) -> str: + """A human-readable title for the descriptor. + + The :ref:`wot_td` requires a human-readable title for all + :ref:`wot_affordances` described. This property will generate a + suitable string from either the name or the docstring. + + The title is either the first line of the docstring, or the name + of the descriptor. Note that, if there's no summary line in the + descriptor's instance docstring, or if ``__set__name__`` has not + yet been called (i.e. if this attribute is accessed before the + class on which the descriptor is defined has been fully set up), + the `.NameNotSetError` from ``self.name`` will propagate, i.e. + this property will either return a string or fail with an + exception. + + Note also that, if the docstring for this descriptor is defined + on the class rather than passed in (via a getter function or + action function's docstring), it will also not be available until + after ``__set_name__`` has been called. + """ + if not self._title: + # First, try to retrieve the first line of the docstring. + # This is the preferred option for the title. + self._title = get_summary(self) + if not self._title: + # If there's no docstring, or it doesn't have a summary line, + # use the name of the descriptor instead. + # Note that this will either succeed or raise an exception. + self._title = self.name + return self._title + + @property + def description(self) -> str | None: + """A description of the descriptor for use in documentation. + + This property will return the docstring describing the descriptor. + As the first line of the docstring (if present) is used as the + ``title`` in :ref:`gen_docs` it will be removed from this property. + """ + return get_docstring(self, remove_summary=True) + + # I have ignored D105 (missing docstrings) on the overloads - these should not + # exist on @overload definitions. + @overload + def __get__(self, obj: Thing, type: type | None = None) -> Value: ... # noqa: D105 + + @overload + def __get__(self, obj: None, type: type) -> Self: ... # noqa: D105 + + def __get__(self, obj: Thing | None, type: type | None = None) -> Value | Self: + """Return the value or the descriptor, as per `property`. + + If ``obj`` is ``None`` (i.e. the descriptor is accessed as a class attribute), + we return the descriptor, i.e. ``self``. + + If ``obj`` is not ``None``, we return a value. To remove the need for this + boilerplate in every subclass, we will call ``__instance_get__`` to get the + value. + + :param obj: the `.Thing` instance to which we are attached. + :param type: the `.Thing` subclass on which we are defined. + + :return: the value of the descriptor returned from ``__instance_get__`` when + accessed on an instance, or the descriptor object if accessed on a class. + """ + if obj is not None: + return self.instance_get(obj) + return self + + def instance_get(self, obj: Thing) -> Value: + """Return the value of the descriptor. + + This method is called from ``__get__`` if the descriptor is accessed as an + instance attribute. This means that ``obj`` is guaranteed to be present. + + ``__get__`` may be called on either an instance or a class, and if it is + called on the class, the convention is that we should return the descriptor + object (i.e. ``self``), as done by `builtins.property`. + + `.BaseDescriptor.__get__` takes care of this logic, so we need only consider + the case where we are called as an instance attribute. This simplifies type + annotations and removes the need for overload definitions in every subclass. + + :param obj: is the `.Thing` instance on which this descriptor is being + accessed. + :return: the value of the descriptor (i.e. property value, or bound method). + + :raises NotImplementedError: if it is not overridden. + """ + raise NotImplementedError( + "__instance_get__ must be defined on BaseDescriptor subclasses. \n\n" + "See BaseDescriptor.__instance_get__ for details." + ) + + +# get_class_attribute_docstrings is a relatively expensive function that +# will be called potentially quite a few times on the same class. It will +# return the same result each time (because it depends only on the source +# code of the class, which can't change), so it makes sense to cache it. +# +# We use weak keys to avoid messing up garbage collection, and cache the +# mapping of attribute names to attribute docstrings. +_class_attribute_docstring_cache: WeakKeyDictionary[type, Mapping[str, str]] = ( + WeakKeyDictionary() +) + + +def get_class_attribute_docstrings(cls: type) -> Mapping[str, str]: + """Retrieve docstrings for the attributes of a class. + + Python formally supports ``__doc__`` attributes on classes and functions, and + this means that classes and methods can self-describe in a way that is picked + up by documentation tools. There isn't currently a language feature specifically + provided to annotate other attributes of a class, but there is a convention + that seems almost universally adopted by documentation tools, which is to + add a string literal immediately after the attribute assignment. While it's + not a formal language feature, Python does explicitly allow these string + literals (which don't have any other purpose) to enable documentation tools + to document attributes. + + This function inspects a class, and returns a dictionary mapping attribute + names to docstrings, where the docstring is a string immediately following + the attribute. For example: + + .. code-block:: python + + class Example: + my_constant: int = 10 + "A number that is all mine." + + + docs = get_class_attribute_docstrings(Example) + + assert docs["my_constant"] == "A number that is all mine." + + + .. note:: + + This function relies on re-parsing the source of the class, so it will + not work on classes that are not defined in a file (for example, if you + just paste the example above into a Python interpreter). In that case, + an empty dictionary is returned. + + The same limitation means dynamically defined classes will result in + an empty dictionary. + + .. note:: + + This function uses a cache, so subsequent calls on the same class will + return a cached value. As dynamic classes are not supported, this is + not expected to be a problem. + + :param cls: The class to inspect + :return: A mapping of attribute names to docstrings. Note that this will be + wrapped in a `types.MappingProxyType` to prevent accidental modification. + + :raises TypeError: if the supplied object is not a class. + """ + # For a helpful article on how this works, see: + # https://davidism.com/attribute-docstrings/ + if cls in _class_attribute_docstring_cache: # Attempt to use the cache + return _class_attribute_docstring_cache[cls] + + # We start by getting hold of the source code of our class. This requires + # the class to be loaded from a file, which is nearly always the case. + # We will simply return an empty dictionary if this fails: there is never + # any guarantee docstrings are available. + try: + src = inspect.getsource(cls) + except (OSError, AttributeError): + # An OSError is raised if the source is not available. + # An AttributeError is raised if the source was loaded from + # a WindowsPath object, perhaps using ``runpy`` + return {} + # The line below parses the class to get a syntax tree. + module_ast = ast.parse(textwrap.dedent(src)) + assert isinstance(module_ast, ast.Module) + class_def = module_ast.body[0] + if not isinstance(class_def, ast.ClassDef): + raise TypeError("The object supplied was not a class.") + # Work through each pair of nodes, looking for an assignment followed by + # a string. + docs: dict[str, str] = {} + for a, b in pairwise(class_def.body): + if not isinstance(a, ast.Assign | ast.AnnAssign): + continue # The first node isn't an assignment + if ( + not isinstance(b, ast.Expr) + or not isinstance(b.value, ast.Constant) + or not isinstance(b.value.value, str) + ): + continue # The second node must be a string constant + + # Assignments may have multiple targets (a=b=c) so we + # need to cope with a list of targets. + if isinstance(a, ast.Assign): + targets = a.targets + else: # Annotated assignments have only one target, so make it a list. + targets = [a.target] + + # Clean up the docstring as per the usual rules + doc = inspect.cleandoc(b.value.value) + + for target in targets: + if not isinstance(target, ast.Name): + # We only care about things assigned to plain names. Assignment to + # attributes of objects, or items in dictionaries, are irrelevant. + continue + docs[target.id] = doc + + _class_attribute_docstring_cache[cls] = MappingProxyType(docs) + return _class_attribute_docstring_cache[cls] diff --git a/src/labthings_fastapi/client/in_server.py b/src/labthings_fastapi/client/in_server.py index 17b99327..2aedf6c6 100644 --- a/src/labthings_fastapi/client/in_server.py +++ b/src/labthings_fastapi/client/in_server.py @@ -21,7 +21,7 @@ from pydantic import BaseModel from ..descriptors.action import ActionDescriptor -from ..descriptors.property import ThingProperty +from ..properties import BaseProperty from ..utilities import attributes from . import PropertyClientDescriptor from ..thing import Thing @@ -219,7 +219,7 @@ def action_method(self, **kwargs): def add_property( - attrs: dict[str, Any], property_name: str, property: ThingProperty + attrs: dict[str, Any], property_name: str, property: BaseProperty ) -> None: """Add a property to a DirectThingClient subclass. @@ -238,7 +238,7 @@ def add_property( property.model, description=property.description, writeable=not property.readonly, - readable=True, # TODO: make this configurable in ThingProperty + readable=True, ) @@ -283,7 +283,7 @@ def init_proxy(self, request: Request, **dependencies: Mapping[str, Any]): } dependencies: list[inspect.Parameter] = [] for name, item in attributes(thing_class): - if isinstance(item, ThingProperty): + if isinstance(item, BaseProperty): add_property(client_attrs, name, item) elif isinstance(item, ActionDescriptor): if actions is None or name in actions: diff --git a/src/labthings_fastapi/decorators/__init__.py b/src/labthings_fastapi/decorators/__init__.py index 0cef9b10..b1950b76 100644 --- a/src/labthings_fastapi/decorators/__init__.py +++ b/src/labthings_fastapi/decorators/__init__.py @@ -40,12 +40,9 @@ from typing import Optional, Callable, overload from ..descriptors import ( ActionDescriptor, - ThingProperty, - ThingSetting, EndpointDescriptor, HTTPMethod, ) -from ..utilities.introspection import return_type def mark_thing_action(func: Callable, **kwargs) -> ActionDescriptor: @@ -123,79 +120,6 @@ def thing_action( return partial(mark_thing_action, **kwargs) -def thing_property(func: Callable) -> ThingProperty: - """Mark a method of a Thing as a LabThings Property. - - This should be used as a decorator with a getter and a setter - just like a standard python `property` decorator. If extra functionality - is not required in the decorator, then using the `.ThingProperty` class - directly may allow for clearer code - - Properties should always have a type annotation. This type annotation - will be used in automatic documentation and also to serialise the value - to JSON when it is sent over th network. This mean that the type of your - property should either be JSON serialisable (i.e. simple built-in types) - or a subclass of `pydantic.BaseModel`. - - :param func: A method to use as the getter for the new property. - - :return: A `.ThingProperty` descriptor that works like `property` but - allows the value to be read over HTTP. - """ - # Replace the function with a `Descriptor` that's a `ThingProperty` - return ThingProperty( - return_type(func), - readonly=True, - observable=False, - getter=func, - ) - - -def thing_setting(func: Callable) -> ThingSetting: - """Mark a method of a Thing as a LabThings Setting. - - A setting is a property that is saved to disk, so it persists even when - the LabThings server is restarted. - - This should be used as a decorator with a getter and a setter - just like a standard python property decorator. If extra functionality - is not required in the decorator, then using the `ThingSetting` class - directly may allow for clearer code where the property works like a - variable. - - When creating a setting using this decorator, you must always add a setter - as it is used to load the value from disk. This follows the same syntax as - for `property`, i.e. a second function with the same name, decorated with - ``@my_property_name.setter``. - - A type annotation is required, and should follow the same constraints as - for :deco:`thing_property`. - - If the type is a pydantic BaseModel, then the setter must also be able to accept - the dictionary representation of this BaseModel as this is what will be used to - set the Setting when loading from disk on starting the server. - - .. note:: - If a setting is mutated rather than set, this will not trigger saving. - For example: if a Thing has a setting called ``dictsetting`` holding the - dictionary ``{"a": 1, "b": 2}`` then ``self.dictsetting = {"a": 2, "b": 2}`` - would trigger saving but ``self.dictsetting[a] = 2`` would not, as the - setter for ``dictsetting`` is never called. - - :param func: A method to use as the getter for the new property. - - :return: A `.ThingSetting` descriptor that works like `property` but - allows the value to be read over HTTP and saves it to disk. - """ - # Replace the function with a `Descriptor` that's a `ThingSetting` - return ThingSetting( - return_type(func), - readonly=True, - observable=False, - getter=func, - ) - - def fastapi_endpoint( method: HTTPMethod, path: Optional[str] = None, **kwargs ) -> Callable[[Callable], EndpointDescriptor]: diff --git a/src/labthings_fastapi/descriptors/__init__.py b/src/labthings_fastapi/descriptors/__init__.py index 404cce21..581ea7ad 100644 --- a/src/labthings_fastapi/descriptors/__init__.py +++ b/src/labthings_fastapi/descriptors/__init__.py @@ -4,15 +4,11 @@ """ from .action import ActionDescriptor -from .property import ThingProperty -from .property import ThingSetting from .endpoint import EndpointDescriptor from .endpoint import HTTPMethod __all__ = [ "ActionDescriptor", - "ThingProperty", - "ThingSetting", "EndpointDescriptor", "HTTPMethod", ] diff --git a/src/labthings_fastapi/descriptors/property.py b/src/labthings_fastapi/descriptors/property.py deleted file mode 100644 index d877f33b..00000000 --- a/src/labthings_fastapi/descriptors/property.py +++ /dev/null @@ -1,402 +0,0 @@ -"""Define a descriptor to represent properties. - -:ref:`wot_properties` are represented in LabThings by `.ThingProperty` descriptors. -These descriptors work similarly to regular Python properties or attributes, -with the addition of features that allow them to be accessed over HTTP and -documented in the :ref:`wot_td` and OpenAPI documents. - -This module defines the `.ThingProperty` class. -""" - -from __future__ import annotations -from typing import TYPE_CHECKING, Annotated, Any, Callable, Optional -from weakref import WeakSet - -from typing_extensions import Self -from pydantic import BaseModel, RootModel -from fastapi import Body, FastAPI - -from ..utilities import labthings_data, wrap_plain_types_in_rootmodel -from ..utilities.introspection import get_summary, get_docstring -from ..thing_description._model import PropertyAffordance, Form, DataSchema, PropertyOp -from ..thing_description import type_to_dataschema -from ..exceptions import NotConnectedToServerError - - -if TYPE_CHECKING: - from ..thing import Thing - - -class ThingProperty: - """A property that can be accessed via the HTTP API. - - By default, a ThingProperty acts like - a normal variable, but functionality can be added in several ways. - """ - - model: type[BaseModel] - readonly: bool = False - - def __init__( - self, - model: type, - initial_value: Any = None, - readonly: bool = False, - observable: bool = False, - description: Optional[str] = None, - title: Optional[str] = None, - getter: Optional[Callable] = None, - setter: Optional[Callable] = None, - ): - """Create a property that can be accessed via the HTTP API. - - `.ThingProperty` is a descriptor that functions like a variable, optionally - with notifications when it is set. It may also have a getter and setter, - which work in a similar way to Python properties. - - `.ThingProperty` can behave in several different ways: - - * If no ``getter`` or ``setter`` is specified, it will behave like a simple - data attribute (i.e. a variable). If ``observable`` is ``True``, it is - possible to register for notifications when the value is set. In this - case, an ``initial_value`` is required. - * If a ``getter`` is specified and ``observable`` is ``False``, the ``getter`` - will be called when the property is accessed, and its return value - will be the property's value, just like the builtin ``property``. The - property will be read-only both locally and via HTTP. - * If a ``getter`` is specified and ``observable`` is ``True``, the ``getter`` - is used instead of ``initial_value`` but thereafter the property - behaves like a variable. The ``getter`` is only on first access. - The property may be written to locally, and whether it's writable - via HTTP depends on the ``readonly`` argument. - * If both a ``getter`` and ``setter`` are specified and ``observable`` is - ``False``, - the property behaves like a Python property, with the ``getter`` being - called when the property is accessed, and the ``setter`` being called - when the property is set. The property is read-only via HTTP if - ``readonly`` is ``True``. It may always be written to locally. - * If ``observable`` is ``True`` and a ``setter`` is specified, the property - will behave like a variable, but will call the ``setter`` - when the property is set. The ``setter`` may perform tasks like sending - the updated value to the hardware, but it is not responsible for - remembering the value. The initial value is set via the ``getter`` or - ``initial_value``. - - - :param model: The type of the property. This is optional, because it is - better to use type hints (see notes on typing above). - :param initial_value: The initial value of the property. If this is set, - the property must not have a getter, and should behave like a variable. - :param readonly: If True, the property cannot be set via the HTTP API. - :param observable: If True, the property can be observed for changes via - websockets. This causes the setter to run code in the async event loop - that will notify a list of subscribers each time the property is set. - Currently, only websockets can be used to observe properties. - :param description: A description of the property, used in the API - documentation. LabThings will attempt to take this from the docstring - if not supplied. - :param title: A human-readable title for the property, used in the API - documentation. Defaults to the first line of the docstring, or the name - of the property. - :param getter: A function that gets the value of the property. - :param setter: A function that sets the value of the property. - - :raise ValueError: if the initial value or type are missing or incorrectly - specified. - """ - if getter and initial_value is not None: - raise ValueError("getter and an initial value are mutually exclusive.") - if model is None: - raise ValueError("LabThings Properties must have a type") - self.model = wrap_plain_types_in_rootmodel(model) - self.readonly = readonly - self.observable = observable - self.initial_value = initial_value - self._description = description - self._title = title - # The lines below allow _getter and _setter to be specified by subclasses - self._setter = setter or getattr(self, "_setter", None) - self._getter = getter or getattr(self, "_getter", None) - # Try to generate a DataSchema, so that we can raise an error that's easy to - # link to the offending ThingProperty - type_to_dataschema(self.model) - - def __set_name__(self, owner: type[Thing], name: str) -> None: - """Take note of the name to which the descriptor is assigned. - - This is called when the descriptor is assigned to an attribute of a class. - - :param owner: the `.Thing` subclass to which we are being attached. - :param name: the name to which we have been assigned. - """ - self._name = name - - @property - def title(self): - """A human-readable title for the property.""" - if self._title: - return self._title - if self._getter and get_summary(self._getter): - return get_summary(self._getter) - return self.name - - @property - def description(self): - """A description of the property.""" - return self._description or get_docstring(self._getter, remove_summary=True) - - def __get__(self, obj: Thing | None, type: type | None = None) -> Any: - """Return the value of the property. - - If `obj` is none (i.e. we are getting the attribute of the class), - we return the descriptor. - - If no getter is set, we'll return either the initial value, or the value - from the object's __dict__, i.e. we behave like a variable. - - If a getter is set, we will use it, unless the property is observable, at - which point the getter is only ever used once, to set the initial value. - - :param obj: the `.Thing` to which we are attached. - :param type: the class on which we are defined. - - :return: the value of the property (when accessed on an instance), or - this descriptor if accessed as a class attribute. - """ - if obj is None: - return self - try: - if self._getter and not self.observable: - # if there's a getter and the property isn't observable, use it - return self._getter(obj) - # otherwise, behave like a variable and return our value - return obj.__dict__[self.name] - except KeyError: - if self._getter: - # if we get to here, the property should be observable, so cache - obj.__dict__[self.name] = self._getter(obj) - return obj.__dict__[self.name] - else: - return self.initial_value - - def __set__(self, obj: Thing, value: Any) -> None: - """Set the property's value. - - :param obj: the `.Thing` to which we are attached. - :param value: the new value for the property. - """ - obj.__dict__[self.name] = value - if self._setter: - self._setter(obj, value) - self.emit_changed_event(obj, value) - - def _observers_set(self, obj: Thing): - """Return the observers of this property. - - Each observer in this set will be notified when the property is changed. - See ``.ThingProperty.emit_changed_event`` - - :param obj: the `.Thing` to which we are attached. - - :return: the set of observers corresponding to ``obj``. - """ - ld = labthings_data(obj) - if self.name not in ld.property_observers: - ld.property_observers[self.name] = WeakSet() - return ld.property_observers[self.name] - - def emit_changed_event(self, obj: Thing, value: Any) -> None: - """Notify subscribers that the property has changed. - - This function is run when properties are updated. It must be run from - within a thread. This could be the `Invocation` thread of a running action, or - the property should be updated over via a client/http. It must be run from a - thread as it is communicating with the event loop via an `asyncio` blocking - portal and can cause deadlock if run in the event loop. - - :param obj: the `.Thing` to which we are attached. - :param value: the new property value, to be sent to observers. - - :raise NotConnectedToServerError: if the Thing that is calling the property - update is not connected to a server with a running event loop. - """ - runner = obj._labthings_blocking_portal - if not runner: - thing_name = obj.__class__.__name__ - msg = ( - f"Cannot emit property updated changed event. Is {thing_name} " - "connected to a running server?" - ) - raise NotConnectedToServerError(msg) - runner.start_task_soon( - self.emit_changed_event_async, - obj, - value, - ) - - async def emit_changed_event_async(self, obj: Thing, value: Any): - """Notify subscribers that the property has changed. - - This function may only be run in the `anyio` event loop. See - `.ThingProperty.emit_changed_event`. - - :param obj: the `.Thing` to which we are attached. - :param value: the new property value, to be sent to observers. - """ - for observer in self._observers_set(obj): - await observer.send( - {"messageType": "propertyStatus", "data": {self._name: value}} - ) - - @property - def name(self): - """The name of the property. - - This should be consistent between the class definition and the - :ref:`wot_td` as well as appearing in the URLs for getting and setting. - """ - return self._name - - def add_to_fastapi(self, app: FastAPI, thing: Thing) -> None: - """Add this action to a FastAPI app, bound to a particular Thing. - - :param app: The FastAPI application we are adding endpoints to. - :param thing: The `.Thing` we are adding the endpoints for. - """ - # We can't use the decorator in the usual way, because we'd need to - # annotate the type of `body` with `self.model` which is only defined - # at runtime. - # The solution below is to manually add the annotation, before passing - # the function to the decorator. - if not self.readonly: - - def set_property(body): # We'll annotate body later - if isinstance(body, RootModel): - body = body.root - return self.__set__(thing, body) - - set_property.__annotations__["body"] = Annotated[self.model, Body()] - app.put( - thing.path + self.name, - status_code=201, - response_description="Property set successfully", - summary=f"Set {self.title}", - description=f"## {self.title}\n\n{self.description or ''}", - )(set_property) - - @app.get( - thing.path + self.name, - response_model=self.model, - response_description=f"Value of {self.name}", - summary=self.title, - description=f"## {self.title}\n\n{self.description or ''}", - ) - def get_property(): - return self.__get__(thing) - - def property_affordance( - self, thing: Thing, path: Optional[str] = None - ) -> PropertyAffordance: - """Represent the property in a Thing Description. - - :param thing: the `.Thing` to which we are attached. - :param path: the URL of the `.Thing`. If not present, we will retrieve - the ``path`` from ``thing``. - - :return: A description of the property in :ref:`wot_td` format. - """ - path = path or thing.path - ops = [PropertyOp.readproperty] - if not self.readonly: - ops.append(PropertyOp.writeproperty) - forms = [ - Form[PropertyOp]( - href=path + self.name, - op=ops, - ), - ] - data_schema: DataSchema = type_to_dataschema(self.model) - pa: PropertyAffordance = PropertyAffordance( - title=self.title, - forms=forms, - description=self.description, - ) - # We merge the data schema with the property affordance (which subclasses the - # DataSchema model) with the affordance second so its values take priority. - # Note that this works because all of the fields that get filled in by - # DataSchema are optional - so the PropertyAffordance is still valid without - # them. - return PropertyAffordance( - **{ - **data_schema.model_dump(exclude_none=True), - **pa.model_dump(exclude_none=True), - } - ) - - def getter(self, func: Callable) -> Self: - """Set the function that gets the property's value. - - :param func: is the new getter function. - - :return: this property (to allow its use as a decorator). - """ - self._getter = func - return self - - def setter(self, func: Callable) -> Self: - """Change the setter function. - - `.ThingProperty` descriptors return the value they hold - when they are accessed. However, they can run code when they are set: this - decorator sets a function as that code. - - :param func: is the new setter function. - - :return: this property (to allow its use as a decorator). - """ - self._setter = func - self.readonly = False - return self - - -class ThingSetting(ThingProperty): - """A `.ThingProperty` that persists on disk. - - A setting can be accessed via the HTTP API and is persistent between sessions. - - A `.ThingSetting` is a `.ThingProperty` with extra functionality for triggering - a `.Thing` to save its settings. - - Note: If a setting is mutated rather than assigned to, this will not trigger saving. - For example: if a Thing has a setting called `dictsetting` holding the dictionary - `{"a": 1, "b": 2}` then `self.dictsetting = {"a": 2, "b": 2}` would trigger saving - but `self.dictsetting[a] = 2` would not, as the setter for `dictsetting` is never - called. - - The setting otherwise acts just like a normal variable. - """ - - def __set__(self, obj: Thing, value: Any): - """Set the setting's value. - - This will cause the settings to be saved to disk. - - :param obj: the `.Thing` to which we are attached. - :param value: the new value of the setting. - """ - super().__set__(obj, value) - obj.save_settings() - - def set_without_emit(self, obj: Thing, value: Any): - """Set the property's value, but do not emit event to notify the server. - - This function is not expected to be used externally. It is called during - initial setup so that the setting can be set from disk before the server - is fully started. - - :param obj: the `.Thing` to which we are attached. - :param value: the new value of the setting. - """ - obj.__dict__[self.name] = value - if self._setter: - self._setter(obj, value) diff --git a/src/labthings_fastapi/example_things/__init__.py b/src/labthings_fastapi/example_things/__init__.py index 3c8b045f..3ed50647 100644 --- a/src/labthings_fastapi/example_things/__init__.py +++ b/src/labthings_fastapi/example_things/__init__.py @@ -7,8 +7,8 @@ import time from typing import Any, Optional, Annotated from labthings_fastapi.thing import Thing -from labthings_fastapi.decorators import thing_action, thing_property -from labthings_fastapi.descriptors import ThingProperty +from labthings_fastapi.decorators import thing_action +from labthings_fastapi.properties import property as lt_property from pydantic import Field @@ -94,15 +94,11 @@ def slowly_increase_counter(self, increments: int = 60, delay: float = 1): time.sleep(delay) self.increment_counter() - counter = ThingProperty( - model=int, initial_value=0, readonly=True, description="A pointless counter" - ) + counter: int = lt_property(default=0, readonly=True) + "A pointless counter" - foo = ThingProperty( - model=str, - initial_value="Example", - description="A pointless string for demo purposes.", - ) + foo: str = lt_property(default="Example") + "A pointless string for demo purposes." @thing_action def action_without_arguments(self) -> None: @@ -129,7 +125,7 @@ def broken_action(self): """ raise RuntimeError("This is a broken action") - @thing_property + @lt_property def broken_property(self): """Raise an exception when the property is accessed. diff --git a/src/labthings_fastapi/exceptions.py b/src/labthings_fastapi/exceptions.py index 3fa595ae..1a6a03cd 100644 --- a/src/labthings_fastapi/exceptions.py +++ b/src/labthings_fastapi/exceptions.py @@ -1,16 +1,27 @@ """A submodule for custom LabThings-FastAPI Exceptions.""" -from .dependencies.invocation import InvocationCancelledError +# The "import x as x" syntax means symbols are interpreted as being re-exported, +# so they won't be flagged as unused by the linter. +# An __all__ for this module is less than helpful, unless we have an +# automated check that everything's included. +from .dependencies.invocation import ( + InvocationCancelledError as InvocationCancelledError, +) class NotConnectedToServerError(RuntimeError): """The Thing is not connected to a server. - This exception is called if a ThingAction is called or - is a ThingProperty is updated on a Thing that is not + This exception is called if an Action is called or + a `.DataProperty` is updated on a Thing that is not connected to a ThingServer. A server connection is needed to manage asynchronous behaviour. """ -__all__ = ["NotConnectedToServerError", "InvocationCancelledError"] +class ReadOnlyPropertyError(AttributeError): + """A property is read-only. + + No setter has been defined for this `.FunctionalProperty`, so + it may not be written to. + """ diff --git a/src/labthings_fastapi/properties.py b/src/labthings_fastapi/properties.py new file mode 100644 index 00000000..9af1bbf9 --- /dev/null +++ b/src/labthings_fastapi/properties.py @@ -0,0 +1,1021 @@ +"""Define properties of `.Thing` objects. + +:ref:`wot_properties` are attributes of a `.Thing` that may be read or written to +over HTTP, and they are described in :ref:`gen_docs`. They are implemented with +a function `.property` (usually referenced as ``lt.property``), which is +intentionally similar to Python's built in `property`. + +Properties can be defined in two ways as shown below: + +.. code-block:: python + + import labthings_fastapi as lt + + class Counter(lt.Thing): + "A counter that knows what's remaining." + + count: int = lt.property(default=0, readonly=True) + "The number of times we've incremented the counter." + + target: int = lt.property(default=10) + "The number of times to increment before we stop." + + @lt.property + def remaining(self) -> int: + "The number of steps remaining." + return self.target - self.count + + @remaining.setter + def remaining(self, value: int) -> None: + self.target = self.count + value + + The first two properties are simple variables: they may be read and assigned + to, and will behave just like a regular variable. Their syntax is similar to + `dataclasses` or `pydantic` in that `.property` is used as a "field specifier" + to set options like the default value, and the type annotation is on the + class attribute. Documentation is in strings immediately following the + properties, which is understood by most automatic documentation tools. + + ``remaining`` is defined using a "getter" function, meaning this code will + be run each time ``counter.remaining`` is accessed. Its type will be the + return type of the function, and its docstring will come from the function + too. Setters with only a getter are read-only. + + Adding a "setter" to properties is optional, and makes them read-write. +""" + +from __future__ import annotations +import builtins +from types import EllipsisType +from typing import ( + Annotated, + Any, + Callable, + Generic, + TypeAlias, + TypeVar, + overload, + TYPE_CHECKING, +) +from typing_extensions import Self +import typing +from weakref import WeakSet + +from fastapi import Body, FastAPI +from pydantic import BaseModel, RootModel + +from .thing_description import type_to_dataschema +from .thing_description._model import ( + DataSchema, + Form, + PropertyAffordance, + PropertyOp, +) +from .utilities import labthings_data, wrap_plain_types_in_rootmodel +from .utilities.introspection import return_type +from .base_descriptor import BaseDescriptor +from .exceptions import ( + NotConnectedToServerError, + ReadOnlyPropertyError, +) + +if TYPE_CHECKING: + from .thing import Thing + + +# Note on ignored linter codes: +# +# D103 refers to missing docstrings. I have ignored this on @overload definitions +# because they shouldn't have docstrings - the docstring belongs only on the +# function they overload. +# D105 is the same as D103, but for __init__ (i.e. magic methods). +# DOC101 and DOC103 are also a result of overloads not having docstrings +# DOC201 is ignored on properties. Because we are overriding the +# builtin `property`, we are using `@builtins.property` which is not recognised +# by pydoclint as a property. I've therefore ignored those codes manually. +# pydocstyle ("D" codes) is run in Ruff and correctly recognises +# builtins.property as a property decorator. + + +# The following exceptions are raised only when creating/setting up properties. +class OverspecifiedDefaultError(ValueError): + """The default value has been specified more than once. + + This error is raised when a `.DataProperty` is instantiated with both a + ``default`` value and a ``default_factory`` provided. + """ + + +class MissingDefaultError(ValueError): + """The default value has not been specified. + + This error is raised when a `.DataProperty` is instantiated without a + ``default`` value or a ``default_factory`` function. + """ + + +class InconsistentTypeError(TypeError): + """Different type hints have been given for a property. + + Every property should have a type hint, which may be provided in a few + different ways. If multiple type hints are provided, they must match. + See `.property` for more details. + """ + + +class MissingTypeError(TypeError): + """No type hints have been given for a property. + + Every property should have a type hint, which may be provided in a few + different ways. This error indicates that no type hint was found. + """ + + +Value = TypeVar("Value") +if TYPE_CHECKING: + # It's hard to type check methods, because the type of ``self`` + # will be a subclass of `.Thing`, and `Callable` types are + # contravariant in their arguments (i.e. + # ``Callable[[SpecificThing,], Value])`` is not a subtype of + # ``Callable[[Thing,], Value]``. + # It is probably not particularly important for us to check th + # type of ``self`` when decorating methods, so it is left as + # ``Any`` to avoid the major confusion that would result from + # trying to type it more tightly. + # + # Note: in ``@overload`` definitions, it's sometimes necessary + # to avoid the use of these aliases, as ``mypy`` can't + # pick which variant is in use without the explicit `Callable`. + ValueFactory: TypeAlias = Callable[[], Value] + ValueGetter: TypeAlias = Callable[[Any], Value] + ValueSetter: TypeAlias = Callable[[Any, Value], None] + + +def default_factory_from_arguments( + default: Value | EllipsisType = ..., + default_factory: ValueFactory | None = None, +) -> ValueFactory: + """Process default arguments to get a default factory function. + + This function takes the ``default`` and ``default_factory`` arguments + and will either return the ``default_factory`` if it is provided, or + will wrap the default value provided in a factory function. + + Note that this wrapping does not copy the default value each time it is + called, so mutable default values are **only** safe if supplied as a + factory function. + + This is used to avoid repeating the logic of checking whether a default + value or a factory function has been provided, and it returns a factory + rather than a default value so that it may be called multiple times to + get copies of the default value. + + This function also ensures the default is specified exactly once, and + raises exceptions if it is not. + + This logic originally lived only in the initialiser of `.DataProperty` + but it was needed in the `.property` and `.setting` functions in order + to correctly type them (so that specifying both or neither of the + ``default`` and ``default_factory`` arguments would raise an error + with mypy). + + :param default: the default value, or an ellipsis if not specified. + :param default_factory: a function that returns the default value. + :return: a function that returns the default value. + :raises OverspecifiedDefaultError: if both ``default`` and + ``default_factory`` are specified. + :raises MissingDefaultError: if neither ``default`` nor ``default_factory`` + are specified. + """ + if default is ... and default_factory is None: + # If the default is an ellipsis, we have no default value. + # Not having a default_factory alongside this + # is not allowed for DataProperty, so we raise an error. + raise MissingDefaultError() + if default is not ... and default_factory is not None: + # If both default and default_factory are set, we raise an error. + raise OverspecifiedDefaultError() + if default is not ...: + + def default_factory() -> Value: + return default + + if not callable(default_factory): + raise MissingDefaultError("The default_factory must be callable.") + return default_factory + + +# See comment at the top of the file regarding ignored linter rules. +@overload # use as a decorator @property +def property( # noqa: D103 + getter: Callable[[Any], Value], +) -> FunctionalProperty[Value]: ... + + +@overload # use as `field: int = property(default=0)` +def property( # noqa: D103 + *, default: Value, readonly: bool = False +) -> Value: ... + + +@overload # use as `field: int = property(default_factory=lambda: 0)` +def property( # noqa: D103 + *, default_factory: Callable[[], Value], readonly: bool = False +) -> Value: ... + + +def property( + getter: ValueGetter | EllipsisType = ..., + *, + default: Value | EllipsisType = ..., + default_factory: ValueFactory | None = None, + readonly: bool = False, +) -> Value | FunctionalProperty[Value]: + r"""Define a Property on a `.Thing`\ . + + This function may be used to define :ref:`wot_properties` in + two ways, as either a decorator or a field specifier. See the + examples in the :mod:`.thing_property` documentation. + + Properties should always have a type annotation. This type annotation + will be used in automatic documentation and also to serialise the value + to JSON when it is sent over the network. This mean that the type of your + property should either be JSON serialisable (i.e. simple built-in types) + or a subclass of `pydantic.BaseModel`. + + :param getter: is a method of a class that returns the value + of this property. This is usually supplied by using ``property`` + as a decorator. + :param default: is the default value. Either this, ``getter`` or + ``default_factory`` must be specified. Specifying both + or neither will raise an exception. + :param default_factory: should return your default value. + This may be used as an alternative to ``default`` if you + need to use a mutable datatype. For example, it would be + better to specify ``default_factory=list`` than + ``default=[]`` because the second form would be shared + between all `.Thing`\ s with this property. + :param readonly: whether the property should be read-only + via the `.ThingClient` interface (i.e. over HTTP or via + a `.DirectThingClient`). This is automatically true if + ``property`` is used as a decorator and no setter is + specified. + + :return: a property descriptor, either a `.FunctionalProperty` + if used as a decorator, or a `.DataProperty` if used as + a field. + + :raises MissingDefaultError: if no valid default value is supplied, + and a getter is not in use. + :raises OverspecifiedDefaultError: if the default is specified more + than once (e.g. ``default``, ``default_factory``, or ``getter``). + + **Typing Notes** + + This function has somewhat complicated type hints, for two reasons. + Firstly, it may be used either as a decorator or as a field specifier, + so ``default`` performs double duty as a default value or a getter. + Secondly, when used as a field specifier the type hint for the + property is attached to the attribute of the class to which the + function's output is assigned. This means ``property`` does not know + its type hint until after it's been called. + + When used as a field specifier, ``property`` returns a generic + `.DataProperty` descriptor instance, which will determine its type + when it is attached to the `.Thing`. The type hint on the return + value of ``property`` in that situation is a "white lie": we annotate + the return as having the same type as the ``default`` value (or the + ``default_factory`` return value). This means that type checkers such + as ``mypy`` will check that the default is valid for the type of the + field, and won't raise an error about assigning, for example, an + instance of ``DataProperty[int]`` to a field annotated as ``int``. + + Finally, the type of the ``default`` argument includes `.EllipsisType` + so that we can use ``...`` as its default value. This allows us to + distinguish between ``default`` not being set (``...``) and a desired + default value of ``None``. Similarly, ``...`` is the default value for + ``getter`` so we can raise a more helpful error if a non-callable + value is passed as the first argument. + """ + if getter is not ...: + # If the default is callable, we're being used as a decorator + # without arguments. + if not callable(getter): + raise MissingDefaultError( + "A non-callable getter was passed to `property`. Usually," + "this means the default value was not passed as a keyword " + "argument, which is required." + ) + if default_factory or default is not ...: + raise OverspecifiedDefaultError( + "A getter was specified at the same time as a default. Only " + "one of a getter, default, and default_factory may be used." + ) + return FunctionalProperty( + fget=getter, + ) + return DataProperty( # type: ignore[return-value] + default_factory=default_factory_from_arguments(default, default_factory), + readonly=readonly, + ) + + +class BaseProperty(BaseDescriptor[Value], Generic[Value]): + """A descriptor that marks Properties on Things. + + This class is used to determine whether an attribute of a `.Thing` should + be treated as a Property (see :ref:`wot_properties` - essentially, it + means the value should be available over HTTP). + + `.BaseProperty` should not be used directly, instead it is recommended to + use `.property` to declare properties on your `.Thing` subclass. + """ + + def __init__(self) -> None: + """Initialise a BaseProperty.""" + super().__init__() + self._type: type | None = None + self._model: type[BaseModel] | None = None + self.readonly: bool = False + + @builtins.property + def value_type(self) -> type[Value]: + """The type of this descriptor's value. + + :raises MissingTypeError: if the type has not been set. + :return: the type of the descriptor's value. + """ + if self._type is None: + raise MissingTypeError("This property does not have a valid type.") + return self._type + + @builtins.property + def model(self) -> type[BaseModel]: + """A Pydantic model for the property's type. + + `pydantic` models are used to serialise and deserialise values from + and to JSON. If the property is defined with a type hint that is not + a `pydantic.BaseModel` subclass, this property will ensure it is + wrapped in a `pydantic.RootModel` so it can be used with FastAPI. + + If `.BaseProperty.value_type` is already a `pydantic.BaseModel` + subclass, this returns it unchanged. + + :return: a Pydantic model for the property's type. + """ + if self._model is None: + self._model = wrap_plain_types_in_rootmodel(self.value_type) + return self._model + + def add_to_fastapi(self, app: FastAPI, thing: Thing) -> None: + """Add this action to a FastAPI app, bound to a particular Thing. + + :param app: The FastAPI application we are adding endpoints to. + :param thing: The `.Thing` we are adding the endpoints for. + """ + assert thing.path is not None + # We can't use the decorator in the usual way, because we'd need to + # annotate the type of `body` with `self.model` which is only defined + # at runtime. + # The solution below is to manually add the annotation, before passing + # the function to the decorator. + if not self.readonly: + + def set_property(body): # We'll annotate body later + if isinstance(body, RootModel): + body = body.root + return self.__set__(thing, body) + + set_property.__annotations__["body"] = Annotated[self.model, Body()] + app.put( + thing.path + self.name, + status_code=201, + response_description="Property set successfully", + summary=f"Set {self.title}", + description=f"## {self.title}\n\n{self.description or ''}", + )(set_property) + + @app.get( + thing.path + self.name, + response_model=self.model, + response_description=f"Value of {self.name}", + summary=self.title, + description=f"## {self.title}\n\n{self.description or ''}", + ) + def get_property(): + return self.__get__(thing) + + def property_affordance( + self, thing: Thing, path: str | None = None + ) -> PropertyAffordance: + """Represent the property in a Thing Description. + + :param thing: the `.Thing` to which we are attached. + :param path: the URL of the `.Thing`. If not present, we will retrieve + the ``path`` from ``thing``. + + :return: A description of the property in :ref:`wot_td` format. + """ + path = path or thing.path + assert path is not None, "Cannot create a property affordance without a path" + ops = [PropertyOp.readproperty] + if not self.readonly: + ops.append(PropertyOp.writeproperty) + forms = [ + Form[PropertyOp]( + href=path + self.name, + op=ops, + ), + ] + data_schema: DataSchema = type_to_dataschema(self.model) + pa: PropertyAffordance = PropertyAffordance( + title=self.title, + forms=forms, + description=self.description, + ) + # We merge the data schema with the property affordance (which subclasses the + # DataSchema model) with the affordance second so its values take priority. + # Note that this works because all of the fields that get filled in by + # DataSchema are optional - so the PropertyAffordance is still valid without + # them. + return PropertyAffordance( + **{ + **data_schema.model_dump(exclude_none=True), + **pa.model_dump(exclude_none=True), + } + ) + + +class DataProperty(BaseProperty[Value], Generic[Value]): + """A Property descriptor that acts like a regular variable. + + `.DataProperty` descriptors remember their value, and can be read and + written to like a regular Python variable. + """ + + @overload + def __init__( # noqa: D105,D107,DOC101,DOC103 + self, default: Value, *, readonly: bool = False + ) -> None: ... + + @overload + def __init__( # noqa: D105,D107,DOC101,DOC103 + self, *, default_factory: ValueFactory, readonly: bool = False + ) -> None: ... + + def __init__( + self, + default: Value | EllipsisType = ..., + *, + default_factory: ValueFactory | None = None, + readonly: bool = False, + ) -> None: + """Create a property that acts like a regular variable. + + `.DataProperty` descriptors function just like variables, in that + they can be read and written to as attributes of the `.Thing` and + their value will be the same every time it is read (i.e. it changes + only when it is set). This differs from `.FunctionalProperty` which + uses a "getter" function just like `builtins.property` and may + return a different value each time. + + `.DataProperty` instances may always be set, when they are accessed + as an attribute of the `.Thing` instance. The ``readonly`` parameter + applies only to client code, whether it is remote or a + `.DirectThingClient` wrapper. + + The type of the property's value will be inferred either from the + type subscript or from an annotation on the class attribute. This + is done in ``__get_name__`` because neither is available during + ``__init__``. + + :param default: the default value. This or ``default_factory`` must + be provided. Note that, as ``None`` is a valid default value, + this uses ``...`` instead as a way of checking whether ``default`` + has been set. + :param default_factory: a function that returns the default value. + This is appropriate for datatypes such as lists, where using + a mutable default value can lead to odd behaviour. + :param readonly: if ``True``, the property may not be written to via + HTTP, or via `.DirectThingClient` objects, i.e. it may only be + set as an attribute of the `.Thing` and not from a client. + """ + super().__init__() + self._default_factory = default_factory_from_arguments( + default=default, default_factory=default_factory + ) + self.readonly = readonly + self._type: type | None = None # Will be set in __set_name__ + + def __set_name__(self, owner: type[Thing], name: str) -> None: + """Take note of the name and type. + + This function is where we determine the type of the property. It may + be specified in two ways: either by subscripting ``DataProperty`` + or by annotating the attribute: + + .. code-block:: python + + class MyThing(Thing): + subscripted_property = DataProperty[int](0) + annotated_property: int = DataProperty(0) + + The second form often works better with autocompletion, though it is + preferred to use `.property` for consistent naming. + + Neither form allows us to access the type during ``__init__``, which + is why we find the type here. If there is a problem, exceptions raised + will appear to come from the class definition, so it's important to + include the name of the attribute. + + See :ref:`descriptors` for links to the Python docs about when + this function is called. + + :param owner: the `.Thing` subclass to which we are being attached. + :param name: the name to which we have been assigned. + + :raises InconsistentTypeError: if the type is specified twice and + the two types are not identical. + :raises MissingTypeError: if no type hints have been given. + """ + # Call BaseDescriptor so we remember the name + super().__set_name__(owner, name) + + # Check for type subscripts + if hasattr(self, "__orig_class__"): + # We have been instantiated with a subscript, e.g. BaseProperty[int]. + # + # __orig_class__ is set on generic classes when they are instantiated + # with a subscripted type. + self._type = typing.get_args(self.__orig_class__)[0] + + # Check for annotations on the parent class + annotations = typing.get_type_hints(owner, include_extras=True) + field_annotation = annotations.get(name, None) + if field_annotation is not None: + # We have been assigned to an annotated class attribute, e.g. + # myprop: int = BaseProperty(0) + if self._type is not None and self._type != field_annotation: + raise InconsistentTypeError( + f"Property {name} on {owner} has conflicting types.\n\n" + f"The field annotation of {field_annotation} conflicts " + f"with the inferred type of {self._type}." + ) + self._type = field_annotation + if self._type is None: + raise MissingTypeError( + f"No type hint was found for property {name} on {owner}." + ) + + @builtins.property + def value_type(self) -> type[Value]: # noqa: DOC201 + """The type of the descriptor's value.""" + self.assert_set_name_called() + return super().value_type + + def instance_get(self, obj: Thing) -> Value: + """Return the property's value. + + This will supply a default if the property has not yet been set. + + :param obj: The `.Thing` on which the property is being accessed. + :return: the value of the property. + """ + if self.name not in obj.__dict__: + # Note that a static default is converted to a factory function + # in __init__. + obj.__dict__[self.name] = self._default_factory() + return obj.__dict__[self.name] + + def __set__( + self, obj: Thing, value: Value, emit_changed_event: bool = True + ) -> None: + """Set the property's value. + + This sets the property's value, and notifies any observers. + + :param obj: the `.Thing` to which we are attached. + :param value: the new value for the property. + :param emit_changed_event: whether to emit a changed event. + """ + obj.__dict__[self.name] = value + if emit_changed_event: + self.emit_changed_event(obj, value) + + def _observers_set(self, obj: Thing): + """Return the observers of this property. + + Each observer in this set will be notified when the property is changed. + See ``.DataProperty.emit_changed_event`` + + :param obj: the `.Thing` to which we are attached. + + :return: the set of observers corresponding to ``obj``. + """ + ld = labthings_data(obj) + if self.name not in ld.property_observers: + ld.property_observers[self.name] = WeakSet() + return ld.property_observers[self.name] + + def emit_changed_event(self, obj: Thing, value: Value) -> None: + """Notify subscribers that the property has changed. + + This function is run when properties are updated. It must be run from + within a thread. This could be the `Invocation` thread of a running action, or + the property should be updated over via a client/http. It must be run from a + thread as it is communicating with the event loop via an `asyncio` blocking + portal and can cause deadlock if run in the event loop. + + :param obj: the `.Thing` to which we are attached. + :param value: the new property value, to be sent to observers. + + :raise NotConnectedToServerError: if the Thing that is calling the property + update is not connected to a server with a running event loop. + """ + runner = obj._labthings_blocking_portal + if not runner: + thing_name = obj.__class__.__name__ + msg = ( + f"Cannot emit property updated changed event. Is {thing_name} " + "connected to a running server?" + ) + raise NotConnectedToServerError(msg) + runner.start_task_soon( + self.emit_changed_event_async, + obj, + value, + ) + + async def emit_changed_event_async(self, obj: Thing, value: Value): + """Notify subscribers that the property has changed. + + This function may only be run in the `anyio` event loop. See + `.DataProperty.emit_changed_event`. + + :param obj: the `.Thing` to which we are attached. + :param value: the new property value, to be sent to observers. + """ + for observer in self._observers_set(obj): + await observer.send( + {"messageType": "propertyStatus", "data": {self._name: value}} + ) + + +class FunctionalProperty(BaseProperty[Value], Generic[Value]): + """A property that uses a getter and a setter. + + For properties that should work like variables, use `.DataProperty`. For + properties that need to run code every time they are read, use this class. + + Functional properties should work very much like Python's `builtins.property` + except that they are also available over HTTP. + """ + + def __init__( + self, + fget: ValueGetter, + ): + """Set up a FunctionalProperty. + + Create a descriptor for a property that uses a getter function. + + This class also inherits from `builtins.property` to help type checking + tools understand that it functions like a property. + + :param fget: the getter function, called when the property is read. + """ + super().__init__() + self._fget: ValueGetter = fget + self._type = return_type(self._fget) + self._fset: ValueSetter | None = None + self.readonly: bool = True + + @builtins.property + def fget(self) -> ValueGetter: # noqa: DOC201 + """The getter function.""" + return self._fget + + @builtins.property + def fset(self) -> ValueSetter | None: # noqa: DOC201 + """The setter function.""" + return self._fset + + def getter(self, fget: ValueGetter) -> Self: + """Set the getter function of the property. + + This function returns the descriptor, so it may be used as a decorator. + If the function has a docstring, it will be used as the property docstring. + + :param fget: The new getter function. + :return: this descriptor (i.e. ``self``). This allows use as a decorator. + """ + self._fget = fget + self._type = return_type(self._fget) + self.__doc__ = fget.__doc__ + return self + + def setter(self, fset: ValueSetter) -> Self: + r"""Set the setter function of the property. + + This function returns the descriptor, so it may be used as a decorator. + + Once a setter has been added to a property, it will automatically become + writeable from client code (over HTTP and via `.DirectThingClient`). + To override this behaviour you may set ``readonly`` back to ``True``. + + .. code-block:: python + + class MyThing(lt.Thing): + def __init__(self): + self._myprop: int = 0 + + @lt.property + def myprop(self) -> int: + "An example property that is an integer" + return self._myprop + + @myprop.setter + def set_myprop(self, val: int) -> None: + self._myprop = val + + myprop.readonly = True # Prevent client code from setting it + + .. note:: + + The example code above is not quite what would be done for the built-in + ``@property`` decorator, because our setter does not have the same name + as the getter. Using a different name avoids type checkers such as + ``mypy`` raising an error that the getter has been redefined with a + different type. The behaviour is identical whether the setter and getter + have the same name or not. The only difference is that the `.Thing` + will have an additional method called ``set_myprop`` in the example + above. + + :param fset: The new setter function. + :return: this descriptor (i.e. ``self``). This allows use as a decorator. + + **Typing Notes** + + Python's built-in ``property`` is treated as a special case by ``mypy`` + and others, and our descriptor is not treated in the same way. + Naming the setter and getter the same is required by `builtins.property` + because the property must be overwritten when the setter is added, as + `builtins.property` is not mutable. + + Our descriptor is mutable, so the setter may be added without having to + overwrite the object. While it would be nice to use exactly the same + conventions as `builtins.property`, it currently causes type errors that + must be silenced manually. We suggest using a different name for the setter + as an alternative to adding ``# type: ignore[no-redef]`` to the setter + function. + + It will cause problems elsewhere in the code if descriptors are assigned + to more than one attribute, and this is checked in + `.BaseDescriptor.__set_name__`\ . We therefore return the setter rather + than the descriptor if the names don't match. The type hint does not + reflect this, as it would cause problems when the names do match (the + descriptor would become a ``FunctionalProperty | Callable`` and thus + typing errors would happen whenever it's accessed). + """ + self._fset = fset + self.readonly = False + if fset.__name__ != self.fget.__name__: + # Don't return the descriptor if it's named differently. + # see typing notes in docstring. + return fset # type: ignore[return-value] + return self + + def instance_get(self, obj: Thing) -> Value: + """Get the value of the property. + + :param obj: the `.Thing` on which the attribute is accessed. + :return: the value of the property. + """ + return self.fget(obj) + + def __set__(self, obj: Thing, value: Value): + """Set the value of the property. + + :param obj: the `.Thing` on which the attribute is accessed. + :param value: the value of the property. + + :raises ReadOnlyPropertyError: if the property cannot be set. + """ + if self.fset is None: + raise ReadOnlyPropertyError(f"Property {self.name} of {obj} has no setter.") + self.fset(obj, value) + + +@overload # use as a decorator @setting +def setting( # noqa: D103 + getter: Callable[[Any], Value], +) -> FunctionalSetting[Value]: ... + + +@overload # use as `field: int = setting(default=0)`` +def setting( # noqa: D103 + *, default: Value, readonly: bool = False +) -> Value: ... + + +@overload # use as `field: int = setting(default_factory=lambda: 0)` +def setting( # noqa: D103 + *, default_factory: Callable[[], Value], readonly: bool = False +) -> Value: ... + + +def setting( + getter: ValueGetter | EllipsisType = ..., + *, + default: Value | EllipsisType = ..., + default_factory: ValueFactory | None = None, + readonly: bool = False, +) -> FunctionalSetting[Value] | Value: + r"""Define a Setting on a `.Thing`\ . + + A setting is a property that is saved to disk. + + This function defines a setting, which is a special Property that will + be saved to disk, so it persists even when the LabThings server is + restarted. It is otherwise very similar to `.property`\ . + + A type annotation is required, and should follow the same constraints as + for :deco:`.property`. + + Every ``setting`` on a `.Thing` will be read each time the settings are + saved, which may be quite frequent. This means your getter must not take + too long to run, or have side-effects. Settings that use getters and + setters may be removed in the future pending the outcome of `#159`_. + + .. _`#159`: https://github.com/labthings/labthings-fastapi/issues/159 + + If the type is a pydantic BaseModel, then the setter must also be able to accept + the dictionary representation of this BaseModel as this is what will be used to + set the Setting when loading from disk on starting the server. + + .. note:: + If a setting is mutated rather than set, this will not trigger saving. + For example: if a Thing has a setting called ``dictsetting`` holding the + dictionary ``{"a": 1, "b": 2}`` then ``self.dictsetting = {"a": 2, "b": 2}`` + would trigger saving but ``self.dictsetting[a] = 2`` would not, as the + setter for ``dictsetting`` is never called. + + :param getter: is a method of a class that returns the value + of this property. This is usually supplied by using ``property`` + as a decorator. + :param default: is the default value. Either this, ``getter`` or + ``default_factory`` must be specified. Specifying both + or neither will raise an exception. + :param default_factory: should return your default value. + This may be used as an alternative to ``default`` if you + need to use a mutable datatype. For example, it would be + better to specify ``default_factory=list`` than + ``default=[]`` because the second form would be shared + between all `.Thing`\ s with this setting. + :param readonly: whether the setting should be read-only + via the `.ThingClient` interface (i.e. over HTTP or via + a `.DirectThingClient`). + + :return: a setting descriptor. + + :raises MissingDefaultError: if no valid default or getter is supplied. + :raises OverspecifiedDefaultError: if the default is specified more + than once (e.g. ``default``, ``default_factory``, or ``getter``). + + **Typing Notes** + + See the typing notes on `.property` as they all apply to `.setting` as + well. + """ + if getter is not ...: + # If the default is callable, we're being used as a decorator + # without arguments. + if not callable(getter): + raise MissingDefaultError( + "A non-callable getter was passed to `setting`. Usually," + "this means the default value was not passed as a keyword " + "argument, which is required." + ) + if default_factory or default is not ...: + raise OverspecifiedDefaultError( + "A getter was specified at the same time as a default. Only " + "one of a getter, default, and default_factory may be used." + ) + return FunctionalSetting( + fget=getter, + ) + return DataSetting( # type: ignore[return-value] + default_factory=default_factory_from_arguments(default, default_factory), + readonly=readonly, + ) + + +class BaseSetting(BaseProperty[Value], Generic[Value]): + r"""A base class for settings. + + This is a subclass of `.BaseProperty` that is used to define settings. + It is not intended to be used directly, but via `.setting` and the + two concrete implementations: `.DataSetting` and `.FunctionalSetting`\ . + """ + + def set_without_emit(self, obj: Thing, value: Value) -> None: + """Set the setting's value without emitting an event. + + This is used to set the setting's value without notifying observers. + It is used during initialisation to set the value from disk before + the server is fully started. + + :param obj: the `.Thing` to which we are attached. + :param value: the new value of the setting. + + :raises NotImplementedError: this method should be implemented in subclasses. + """ + raise NotImplementedError("This method should be implemented in subclasses.") + + +class DataSetting(DataProperty[Value], BaseSetting[Value], Generic[Value]): + """A `.DataProperty` that persists on disk. + + A setting can be accessed via the HTTP API and is persistent between sessions. + + A `.DataSetting` is a `.DataProperty` with extra functionality for triggering + a `.Thing` to save its settings. + + Note: If a setting is mutated rather than assigned to, this will not trigger saving. + For example: if a Thing has a setting called `dictsetting` holding the dictionary + `{"a": 1, "b": 2}` then `self.dictsetting = {"a": 2, "b": 2}` would trigger saving + but `self.dictsetting[a] = 2` would not, as the setter for `dictsetting` is never + called. + + The setting otherwise acts just like a normal variable. + """ + + def __set__( + self, obj: Thing, value: Value, emit_changed_event: bool = True + ) -> None: + """Set the setting's value. + + This will cause the settings to be saved to disk. + + :param obj: the `.Thing` to which we are attached. + :param value: the new value of the setting. + :param emit_changed_event: whether to emit a changed event. + """ + super().__set__(obj, value, emit_changed_event) + obj.save_settings() + + def set_without_emit(self, obj: Thing, value: Value) -> None: + """Set the property's value, but do not emit event to notify the server. + + This function is not expected to be used externally. It is called during + initial setup so that the setting can be set from disk before the server + is fully started. + + :param obj: the `.Thing` to which we are attached. + :param value: the new value of the setting. + """ + super().__set__(obj, value, emit_changed_event=False) + + +class FunctionalSetting(FunctionalProperty[Value], BaseSetting[Value], Generic[Value]): + """A `.FunctionalProperty` that persists on disk. + + A setting can be accessed via the HTTP API and is persistent between sessions. + + A `.FunctionalSetting` is a `.FunctionalProperty` with extra functionality for + triggering a `.Thing` to save its settings. + + Note: If a setting is mutated rather than assigned to, this will not trigger + saving. For example: if a Thing has a setting called ``dictsetting`` holding + the dictionary ``{"a": 1, "b": 2}`` then ``self.dictsetting = {"a": 2, "b": 2}`` + would trigger saving but ``self.dictsetting[a] = 2`` would not, as the setter + for ``dictsetting`` is never called. + + The setting otherwise acts just like a `.FunctionalProperty``, i.e. it uses a + getter and a setter function. + """ + + def __set__(self, obj: Thing, value: Value) -> None: + """Set the setting's value. + + This will cause the settings to be saved to disk. + + :param obj: the `.Thing` to which we are attached. + :param value: the new value of the setting. + """ + super().__set__(obj, value) + obj.save_settings() + + def set_without_emit(self, obj: Thing, value: Value) -> None: + """Set the property's value, but do not emit event to notify the server. + + This function is not expected to be used externally. It is called during + initial setup so that the setting can be set from disk before the server + is fully started. + + :param obj: the `.Thing` to which we are attached. + :param value: the new value of the setting. + """ + # FunctionalProperty does not emit changed events, so no special + # behaviour is needed. + super().__set__(obj, value) diff --git a/src/labthings_fastapi/thing.py b/src/labthings_fastapi/thing.py index 4ef50820..3d3cb739 100644 --- a/src/labthings_fastapi/thing.py +++ b/src/labthings_fastapi/thing.py @@ -21,7 +21,8 @@ from pydantic import BaseModel -from .descriptors import ThingProperty, ThingSetting, ActionDescriptor +from .properties import DataProperty, BaseSetting +from .descriptors import ActionDescriptor from .thing_description._model import ThingDescription, NoSecurityScheme from .utilities import class_attributes from .thing_description import validation @@ -57,9 +58,12 @@ class Thing: ``finally:`` block. * Properties and Actions are defined using decorators: the :deco:`.thing_action` decorator declares a method to be an action, which will run when it's triggered, - and the :deco:`.thing_property` decorator (or `.ThingProperty` descriptor) does - the same for a property. See the documentation on those functions for more - detail. + and the :deco:`.property` decorator does the same for a property. + + Properties may also be defined using dataclass-style syntax, if they do + not need getter and setter functions. + + See the documentation on those functions for more detail. * `title` will be used in various places as the human-readable name of your Thing, so it makes sense to set this in a subclass. @@ -153,14 +157,14 @@ async def websocket(ws: WebSocket): # A private variable to hold the list of settings so it doesn't need to be # iterated through each time it is read - _settings_store: Optional[dict[str, ThingSetting]] = None + _settings_store: Optional[dict[str, BaseSetting]] = None @property - def _settings(self) -> dict[str, ThingSetting]: + def _settings(self) -> dict[str, BaseSetting]: """A private property that returns a dict of all settings for this Thing. Each dict key is the name of the setting, the corresponding value is the - ThingSetting class (a descriptor). This can be used to directly get the + BaseSetting class (a descriptor). This can be used to directly get the descriptor so that the value can be set without emitting signals, such as on startup. """ @@ -169,7 +173,7 @@ def _settings(self) -> dict[str, ThingSetting]: self._settings_store = {} for name, attr in class_attributes(self): - if isinstance(attr, ThingSetting): + if isinstance(attr, BaseSetting): self._settings_store[name] = attr return self._settings_store @@ -344,7 +348,7 @@ def observe_property(self, property_name: str, stream: ObjectSendStream) -> None :raise KeyError: if the requested name is not defined on this Thing. """ prop = getattr(self.__class__, property_name) - if not isinstance(prop, ThingProperty): + if not isinstance(prop, DataProperty): raise KeyError(f"{property_name} is not a LabThings Property") prop._observers_set(self).add(stream) diff --git a/src/labthings_fastapi/utilities/__init__.py b/src/labthings_fastapi/utilities/__init__.py index 4b5008a4..ca7b8885 100644 --- a/src/labthings_fastapi/utilities/__init__.py +++ b/src/labthings_fastapi/utilities/__init__.py @@ -57,7 +57,7 @@ class LabThingsObjectData: property_observers: Dict[str, WeakSet] = Field(default_factory=dict) r"""The observers added to each property. - Keys are property names, values are weak sets used by `.ThingProperty`\ . + Keys are property names, values are weak sets used by `.DataProperty`\ . """ action_observers: Dict[str, WeakSet] = Field(default_factory=dict) r"""The observers added to each action. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_action_cancel.py b/tests/test_action_cancel.py index a5a4ad49..7ec923cf 100644 --- a/tests/test_action_cancel.py +++ b/tests/test_action_cancel.py @@ -4,22 +4,19 @@ import uuid from fastapi.testclient import TestClient -from temp_client import poll_task, task_href +from .temp_client import poll_task, task_href import labthings_fastapi as lt import time class CancellableCountingThing(lt.Thing): - counter = lt.ThingProperty(int, 0, observable=False) - check = lt.ThingProperty( - bool, - False, - observable=False, - description=( - "This variable is used to check that the action can detect a cancel event " - "and react by performing another task, in this case, setting this variable." - ), - ) + counter: int = lt.property(default=0) + check: bool = lt.property(default=False) + """Whether the count has been cancelled. + + This variable is used to check that the action can detect a cancel event + and react by performing another task, in this case, setting this variable. + """ @lt.thing_action def count_slowly(self, cancel: lt.deps.CancelHook, n: int = 10): diff --git a/tests/test_action_logging.py b/tests/test_action_logging.py index c98c08b0..86fecf9e 100644 --- a/tests/test_action_logging.py +++ b/tests/test_action_logging.py @@ -4,7 +4,7 @@ import logging from fastapi.testclient import TestClient -from temp_client import poll_task +from .temp_client import poll_task import labthings_fastapi as lt from labthings_fastapi.actions.invocation_model import LogRecordModel diff --git a/tests/test_action_manager.py b/tests/test_action_manager.py index 4ae2ab4f..eea32c9a 100644 --- a/tests/test_action_manager.py +++ b/tests/test_action_manager.py @@ -1,7 +1,7 @@ from fastapi.testclient import TestClient import pytest import httpx -from temp_client import poll_task +from .temp_client import poll_task import time import labthings_fastapi as lt from labthings_fastapi.actions import ACTION_INVOCATIONS_PATH @@ -13,9 +13,8 @@ def increment_counter(self): """Increment the counter""" self.counter += 1 - counter = lt.ThingProperty( - model=int, initial_value=0, readonly=True, description="A pointless counter" - ) + counter: int = lt.property(default=0, readonly=True) + "A pointless counter" thing = TestThing() diff --git a/tests/test_actions.py b/tests/test_actions.py index 18a5d257..98c6949d 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -1,6 +1,6 @@ from fastapi.testclient import TestClient import pytest -from temp_client import poll_task, get_link +from .temp_client import poll_task, get_link from labthings_fastapi.example_things import MyThing import labthings_fastapi as lt diff --git a/tests/test_base_descriptor.py b/tests/test_base_descriptor.py new file mode 100644 index 00000000..e283d438 --- /dev/null +++ b/tests/test_base_descriptor.py @@ -0,0 +1,286 @@ +import pytest +from labthings_fastapi.base_descriptor import ( + BaseDescriptor, + DescriptorNotAddedToClassError, + DescriptorAddedToClassTwiceError, + get_class_attribute_docstrings, +) +from .utilities import raises_or_is_caused_by + + +class MockProperty(BaseDescriptor[str]): + """A mock property class.""" + + # The line below isn't defined on a `Thing`, so mypy + # errors - but we ignore this for testing. + def instance_get(self, _obj) -> str: + """This is called by BaseProperty.__get__.""" + return "An example value." + + +class Example: + """A class containing some attributes that may or may not have docstrings. + + We will use code in `base_descriptor` to inspect this class and test it finds + the right docstrings. + """ + + my_constant: int = 10 + "A number that is all mine." + + my_undocumented_constant: int = 20 + + my_property = MockProperty() + "Docs for my_property." + + my_undocumented_property = MockProperty() + + my_property_with_nice_docs = MockProperty() + """Title goes here. + + The docstring should have a one-line title followed by + a body giving a longer description of what's going on. + """ + + my_property_with_only_description = MockProperty() + """ + This is a poorly formatted docstring that does not have + a one-line title. It should result in the property name + being used as a title, and this text as description. + """ + + # This line looks like an attribute assignment with a docstring, + # but it's not - because we are not assigning to a simple name. + # This tests that such assignments won't cause errors. + my_property_with_nice_docs.attribute = "dummy value" + """A spurious docstring.""" + + # As above, this is testing that we safely ignore assignments + # that are not to simple names. The code below should not + # cause an error, but will cause a ``continue`` statement + # to skip actions, testing another code path when the + # class is analysed. + dict_attribute = {} + dict_attribute["foo"] = "bar" + """Here is a spurious docstring that should be ignored.""" + + base_descriptor = BaseDescriptor() + """This descriptor should raise NotImplementedError.""" + + +def test_docstrings_are_retrieved(): + """Check that the docstring can be picked up from the class definition. + + This test checks that: + * We get docstrings for exactly the attributes we expect. + * The one-line docstrings are picked up correctly. + * The docstring-inspection code isn't confused by spurious docstrings + next to assignments that are not to simple names. (see comments on + the class definition of `Example`). + + Detection and interpretation of multiline docstrings is tested in + `test_basedescriptor_with_good_docstring`. + """ + docs = get_class_attribute_docstrings(Example) + assert docs["my_constant"] == "A number that is all mine." + assert docs["my_property"] == "Docs for my_property." + expected_names = [ + "my_constant", + "my_property", + "my_property_with_nice_docs", + "my_property_with_only_description", + "base_descriptor", + ] + assert set(docs.keys()) == set(expected_names) + + +def test_non_classes_raise_errors(): + """Check we validate the input object. + + If `get_class_attribute_docstrings` is called on something other than + a class, we should raise an error. + """ + + def dummy(): + pass + + with pytest.raises(TypeError): + get_class_attribute_docstrings(dummy) + + +def test_uncheckable_class(): + """Check we don't crash if we can't check a class. + + If `inspect.getsource` fails, we should return an empty dict. + """ + MyClass = type("MyClass", (), {"intattr": 10}) + doc = get_class_attribute_docstrings(MyClass) + assert doc == {} + + +def test_docstrings_are_cached(): + """Check that the docstrings aren't being regenerated every time.""" + docs1 = get_class_attribute_docstrings(Example) + docs2 = get_class_attribute_docstrings(Example) + # The dictionary of attribute docstrings is cached, keyed on the + # class. The test below checks the same object is returned, not + # just one with the same values in it - this implies the cache + # is working. + assert docs1 is docs2 + + +def test_basedescriptor_with_good_docstring(): + """Check we get the right documentation properties.""" + prop = Example.my_property_with_nice_docs + assert prop.name == "my_property_with_nice_docs" + assert prop.title == "Title goes here." + assert prop.description.startswith("The docstring") + + +def test_basedescriptor_with_oneline_docstring(): + """Check we get the right documentation properties for a one-liner.""" + prop = Example.my_property + assert prop.name == "my_property" + assert prop.title == "Docs for my_property." + assert prop.description.startswith("Docs for my_property.") + + +def test_basedescriptor_with_bad_multiline_docstring(): + """Check a docstring with no title produces the expected result. + + A multiline docstring with no title (i.e. no blank second line) + should result in the whole docstring being used as the description. + """ + prop = Example.my_property_with_only_description + assert prop.name == "my_property_with_only_description" + assert prop.title == "This is a poorly formatted docstring that does not have" + assert prop.description.startswith("This is a poorly formatted") + + +def test_basedescriptor_orphaned(): + """Check the right error is raised if we ask for the name outside a class.""" + prop = MockProperty() + with pytest.raises(DescriptorNotAddedToClassError): + prop.name + + +def test_basedescriptor_fallback(): + """Check the title defaults to the name.""" + p = Example.my_undocumented_property + assert p.title == "my_undocumented_property" + assert p.__doc__ is None + assert p.description is None + + +def test_basedescriptor_get(): + """Check the __get__ function works + + BaseDescriptor provides an implementation of __get__ that + returns the descriptor when accessed as a class attribute, + and calls `instance_get` when accessed as the attribute of + an instance. This test checks both those scenarios. + """ + e = Example() + assert e.my_property == "An example value." + assert isinstance(Example.my_property, MockProperty) + with pytest.raises(NotImplementedError): + # BaseDescriptor requires `instance_get` to be overridden. + e.base_descriptor + + +class MockFunctionalProperty(MockProperty): + """A mock property class with a setter decorator. + + This class is used by test_decorator_different_names. + """ + + def __init__(self, fget): + """Add a mock getter and initialise variables.""" + super().__init__() + self._getter = fget + self._setter = None + self._names = [] + + def setter(self, fset): + """Can be used as a decorator to add a setter.""" + self._setter = fset + return self + + def __set_name__(self, owner, name): + """Check how many times __set_name__ is called.""" + self._names.append(name) + super().__set_name__(owner, name) + + +def test_decorator_different_names(): + """Check that adding a descriptor to a class twice raises the right error. + + Much confusion will result if a ``BaseDescriptor`` is added to a class twice + or added to two different classes. This test checks an error is raised when + that happens. + + Note that there is an exception to this in `.FunctionalProperty` and that + exception is tested in ``test_property.py`` in this folder. + """ + # First, very obviously double-assign a BaseDescriptor + with raises_or_is_caused_by(DescriptorAddedToClassTwiceError) as excinfo: + + class ExplicitExample: + """An example class.""" + + prop1 = BaseDescriptor() + prop2 = prop1 + + # The exception occurs at the end of the class definition, so check we include + # the property names. + assert "prop1" in str(excinfo.value) + assert "prop2" in str(excinfo.value) + + # The next form of properties works and doesn't trigger the error, but is + # flagged (arguably spuriously) as an error by mypy. + class ValidExceptInMyPy: + """An example class that fails type checking but is valid Python.""" + + @MockFunctionalProperty + def prop1(self): + return False + + @prop1.setter + def prop1(self, val): + pass + + # This workaround satisfies MyPy but double-assigns the descriptor. + # It should raise an error here, but is a special case in + # `.FunctionalProperty.__set_name__` so will be OK for `.FunctionalProperty` + # and `.FunctionalSetting` as a result. + with raises_or_is_caused_by(DescriptorAddedToClassTwiceError) as excinfo: + + class DecoratorExample: + """Another example class.""" + + @MockFunctionalProperty + def prop1(self): + return False + + @prop1.setter + def set_prop1(self, val): + pass + + # The exception occurs at the end of the class definition, so check we include + # the property names. + assert "prop1" in str(excinfo.value) + assert "set_prop1" in str(excinfo.value) + + # For good measure, check reuse across classes is also prevented. + class FirstExampleClass: + prop = BaseDescriptor() + + with raises_or_is_caused_by(DescriptorAddedToClassTwiceError) as excinfo: + + class SecondExampleClass: + prop = FirstExampleClass.prop + + # The message should mention names and classes + assert "prop" in str(excinfo.value) + assert "FirstExampleClass" in str(excinfo.value) + assert "SecondExampleClass" in str(excinfo.value) diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 2d6f4689..a8195d86 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -7,7 +7,7 @@ from fastapi import Depends, FastAPI, Request from labthings_fastapi.deps import InvocationID from fastapi.testclient import TestClient -from module_with_deps import FancyIDDep +from .module_with_deps import FancyIDDep def test_invocation_id(): diff --git a/tests/test_dependencies_2.py b/tests/test_dependencies_2.py index cf5a2a33..8702ead0 100644 --- a/tests/test_dependencies_2.py +++ b/tests/test_dependencies_2.py @@ -20,7 +20,7 @@ from typing import Annotated from fastapi import Depends, FastAPI from fastapi.testclient import TestClient -from module_with_deps import FancyIDDep, FancyID, ClassDependsOnFancyID +from .module_with_deps import FancyIDDep, FancyID, ClassDependsOnFancyID import labthings_fastapi as lt diff --git a/tests/test_dependency_metadata.py b/tests/test_dependency_metadata.py index 2536d8f2..c0a04e7d 100644 --- a/tests/test_dependency_metadata.py +++ b/tests/test_dependency_metadata.py @@ -4,7 +4,7 @@ from typing import Any, Mapping from fastapi.testclient import TestClient -from temp_client import poll_task +from .temp_client import poll_task import labthings_fastapi as lt @@ -13,7 +13,7 @@ def __init__(self): lt.Thing.__init__(self) self._a = 0 - @lt.thing_property + @lt.property def a(self): return self._a diff --git a/tests/test_docs.py b/tests/test_docs.py index 3e9fcdfb..7b705bc3 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -1,8 +1,8 @@ from pathlib import Path from runpy import run_path -from test_server_cli import MonitoredProcess from fastapi.testclient import TestClient from labthings_fastapi import ThingClient +from .test_server_cli import MonitoredProcess this_file = Path(__file__) @@ -12,7 +12,10 @@ def run_quickstart_counter(): # A server is started in the `__name__ == "__main__" block` - run_path(docs / "quickstart" / "counter.py") + # Running from a WindowsPath confuses the documentation code + # in `base_descriptor.get_class_attribute_docstrings` hence + # the cast to a `str` + run_path(str(docs / "quickstart" / "counter.py")) def test_quickstart_counter(): diff --git a/tests/test_properties.py b/tests/test_properties.py index 3946c7e1..9380798e 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -1,26 +1,32 @@ from threading import Thread +from typing import Any from pytest import raises -from pydantic import BaseModel +from pydantic import BaseModel, RootModel from fastapi.testclient import TestClient +import pytest import labthings_fastapi as lt from labthings_fastapi.exceptions import NotConnectedToServerError +from .temp_client import poll_task -class TestThing(lt.Thing): - boolprop = lt.ThingProperty(bool, False, description="A boolean property") - stringprop = lt.ThingProperty(str, "foo", description="A string property") +class PropertyTestThing(lt.Thing): + boolprop: bool = lt.property(default=False) + "A boolean property" + + stringprop: str = lt.property(default="foo") + "A string property" _undoc = None - @lt.thing_property + @lt.property def undoc(self): return self._undoc _float = 1.0 - @lt.thing_property + @lt.property def floatprop(self) -> float: return self._float @@ -34,100 +40,208 @@ def toggle_boolprop(self): @lt.thing_action def toggle_boolprop_from_thread(self): + """Toggle boolprop from a new threading.Thread. + + This checks we can still toggle the property from a thread + that definitely isn't a worker thread created by FastAPI. + """ t = Thread(target=self.toggle_boolprop) t.start() + # Ensure the thread has finished before the action completes: + t.join() -thing = TestThing() -server = lt.ThingServer() -server.add_thing(thing, "/thing") +@pytest.fixture +def server(): + thing = PropertyTestThing() + server = lt.ThingServer() + server.add_thing(thing, "/thing") + return server -def test_instantiation_with_type(): +def test_types_are_found(): + """Check the correct type is determined for PropertyTestThing's properties. + + Note that the special case of types that are already BaseModel subclasses + is tested in test_instantiation_with_model. """ - Check the internal model (data type) of the ThingSetting descriptor is a BaseModel + T = PropertyTestThing + # BaseProperty.value_type should be the (Python) type of the property + assert T.boolprop.value_type is bool + assert T.stringprop.value_type is str + assert T.undoc.value_type is Any + assert T.floatprop.value_type is float + # BaseProperty.model should wrap that type in a RootModel + for name in ["boolprop", "stringprop", "undoc", "floatprop"]: + p = getattr(T, name) + # Check the returned model is a rootmodel + assert issubclass(p.model, RootModel) + # Check that it is wrapping the correct type + assert p.model.model_fields["root"].annotation is p.value_type + + +def test_instantiation_with_type(): + """Check the property's type is correctly wrapped in a BaseModel. To send the data over HTTP LabThings-FastAPI uses Pydantic models to describe data - types. + types. If a property is defined using simple Python types, we need to wrap them + in a `pydantic` model. The type is exposed as `.value_type` and the wrapped + model as `.model`. """ - prop = lt.ThingProperty(bool, False) - assert issubclass(prop.model, BaseModel) + # `prop` will not work unless the property is assigned to a thing + class Dummy(lt.Thing): + prop: bool = lt.property(default=False) + + @lt.property + def func_prop(self) -> bool: + return False + + assert Dummy.prop.value_type is bool + assert issubclass(Dummy.prop.model, BaseModel) + assert Dummy.func_prop.value_type is bool + assert issubclass(Dummy.func_prop.model, BaseModel) + + +def test_instantiation_with_model() -> None: + """If a property's type is already a model, it should not be wrapped.""" -def test_instantiation_with_model(): class MyModel(BaseModel): a: int = 1 b: float = 2.0 - prop = lt.ThingProperty(MyModel, MyModel()) - assert prop.model is MyModel + class Dummy: + prop: MyModel = lt.property(default=MyModel()) + + @lt.property + def func_prop(self) -> MyModel: + return MyModel() + + assert Dummy.prop.model is MyModel + assert Dummy.prop.value_type is MyModel + # Dummy.prop is typed as MyModel, but it's a descriptor + + assert Dummy.func_prop.model is MyModel + assert Dummy.func_prop.value_type is MyModel -def test_property_get_and_set(): +def test_property_get_and_set(server): + """Use PUT and GET requests to check the property. + + PUT sets the value and GET retrieves it, so we use a PUT + to set a known value, and check it comes back when we read + it with a GET request. + """ with TestClient(server.app) as client: test_str = "A silly test string" - client.put("/thing/stringprop", json=test_str) + # Write to the property: + response = client.put("/thing/stringprop", json=test_str) + # Check for a successful response code + assert response.status_code == 201 + # Check it was written successfully after_value = client.get("/thing/stringprop") + assert after_value.status_code == 200 assert after_value.json() == test_str -def test_ThingProperty(): +def test_boolprop(server): + """Test that the boolean property can be read and written. + + PUT requests write to the property, and GET reads it. + """ with TestClient(server.app) as client: r = client.get("/thing/boolprop") - assert r.json() is False - client.put("/thing/boolprop", json=True) + assert r.status_code == 200 # Successful read + assert r.json() is False # Known initial value + r = client.put("/thing/boolprop", json=True) + assert r.status_code == 201 # Successful write r = client.get("/thing/boolprop") + assert r.status_code == 200 # Successful read assert r.json() is True -def test_decorator_with_no_annotation(): +def test_decorator_with_no_annotation(server): + """Test a property made with an un-annotated function.""" with TestClient(server.app) as client: r = client.get("/thing/undoc") - assert r.json() is None + assert r.status_code == 200 # Read the property OK + assert r.json() is None # The return value was None r = client.put("/thing/undoc", json="foo") - assert r.status_code != 200 + assert r.status_code == 405 # Read-only, so "method not allowed" -def test_readwrite_with_getter_and_setter(): +def test_readwrite_with_getter_and_setter(server): + """Test floatprop can be read and written with a getter/setter.""" with TestClient(server.app) as client: r = client.get("/thing/floatprop") - assert r.json() == 1.0 + assert r.status_code == 200 # Read the property OK + assert r.json() == 1.0 # Got the expected value r = client.put("/thing/floatprop", json=2.0) - assert r.status_code == 201 + assert r.status_code == 201 # Wrote to the property OK r = client.get("/thing/floatprop") - assert r.json() == 2.0 + assert r.status_code == 200 # Read the property OK + assert r.json() == 2.0 # Got the value we wrote + # We check here that writing an invalid value raises an error code: r = client.put("/thing/floatprop", json="foo") - assert r.status_code != 200 + assert r.status_code == 422 # Unprocessable entity (wrong type) + +def test_sync_action(server): + """Check that we can change a property by invoking an action. -def test_sync_action(): + This action doesn't start any extra threads. + """ with TestClient(server.app) as client: - client.put("/thing/boolprop", json=False) - r = client.get("/thing/boolprop") - assert r.json() is False + # Write to the property so it has a known value + r = client.put("/thing/boolprop", json=False) + assert r.status_code == 201 # successful write + r = client.get("/thing/boolprop") # Read it back + assert r.status_code == 200 # successful read + assert r.json() is False # the value we wrote + + # Now, we invoke the action with a POST request r = client.post("/thing/toggle_boolprop", json={}) - assert r.status_code in [200, 201] + assert r.status_code == 201 # Action started OK + # In the future, an action that completes quickly + # could return 200, which would indicate it has + # already finished. Currently, we always return + # 201 to say we started successfully - we need + # to poll the task to check it's finished. + poll_task(client, r.json()) + # Read the property after it's been toggled r = client.get("/thing/boolprop") + assert r.status_code == 200 assert r.json() is True -def test_setting_from_thread(): +def test_setting_from_thread(server): + """Repeat test_sync_action but toggle the property from a new thread. + + This checks there's nothing special about the action thread. + """ with TestClient(server.app) as client: - client.put("/thing/boolprop", json=False) + # Reset boolprop to a known state + r = client.put("/thing/boolprop", json=False) + assert r.status_code == 201 r = client.get("/thing/boolprop") + assert r.status_code == 200 assert r.json() is False r = client.post("/thing/toggle_boolprop_from_thread", json={}) - assert r.status_code in [200, 201] + assert r.status_code == 201 # Action started OK + poll_task(client, r.json()) + # Check the property changed. r = client.get("/thing/boolprop") + assert r.status_code == 200 assert r.json() is True -def test_setting_without_event_loop(): - """Test that an exception is raised if updating a ThingProperty +def test_setting_without_event_loop(server): + """Test that an exception is raised if updating a DataProperty without connecting the Thing to a running server with an event loop. """ # This test may need to change, if we change the intended behaviour # Currently it should never be necessary to change properties from the # main thread, so we raise an error if you try to do so + thing = PropertyTestThing() with raises(NotConnectedToServerError): thing.boolprop = False # Can't call it until the event loop's running diff --git a/tests/test_property.py b/tests/test_property.py new file mode 100644 index 00000000..e15ab1be --- /dev/null +++ b/tests/test_property.py @@ -0,0 +1,254 @@ +"""Test `lt.property` and its associated classes. + +This is a new test module, intended to test individual bits of code, +rather than check the whole property mechanism at once. This should +mean this module is more bottom-up than the old +`test_properties.py`. Currently, both are part of the test suite, +as it's helpful to take both approaches. + +This module currently focuses on checking the top level functions, +in particular checking `lt.property` and `lt.setting` work in the +same way. +""" + +import fastapi +from fastapi.testclient import TestClient +import pydantic +import pytest +from labthings_fastapi import properties as tp +from labthings_fastapi.base_descriptor import DescriptorAddedToClassTwiceError +from .utilities import raises_or_is_caused_by + + +def test_default_factory_from_arguments(): + """Check the function that implements default/default_factory behaves correctly. + + It should always return a function that + returns a default value, and should error if both arguments + are provided, or if none are provided. + """ + # Check for an error with no arguments + with pytest.raises(tp.MissingDefaultError): + tp.default_factory_from_arguments() + + # Check for an error with both arguments + with pytest.raises(tp.OverspecifiedDefaultError): + tp.default_factory_from_arguments([], list) + + # Check a factory is passed unchanged + assert tp.default_factory_from_arguments(..., list) is list + + # Check a value is wrapped in a factory + factory = tp.default_factory_from_arguments(True, None) + assert factory() is True + + # Check there's an error if our default factory isn't callable + with pytest.raises(tp.MissingDefaultError): + tp.default_factory_from_arguments(default_factory=False) + + # Check None works as a default value + factory = tp.default_factory_from_arguments(default=None) + assert factory() is None + + +class ArgCapturer: + """A class that remembers its init arguments.""" + + def __init__(self, *args, **kwargs): + """Store arguments for later inspection.""" + self.args = args + self.kwargs = kwargs + + +def mock_and_capture_args(monkeypatch, target, name): + """Replace a class with an ArgCapturer + + A dynamically created subclass will be swapped in for the + specified class, allowing its arguments to be checked. + + :param monkeypatch: the pytest fixture. + :param target: the module where the class is defined. + :param name: the class name. + """ + MockClass = type( + name, + (ArgCapturer,), + {}, + ) + monkeypatch.setattr(target, name, MockClass) + + +@pytest.mark.parametrize("func", [tp.property, tp.setting]) +def test_toplevel_function(monkeypatch, func): + """Test the various ways in which `lt.property` or `lt.setting` may be invoked. + + This test is parametrized, so `func` will be either `tp.property` or `tp.setting`. + We then look up the corresponding descriptor classes. + + It's unfortunate that the body of this test is a bit generic, but as both + functions should work identically, it's worth ensuring they are tested the same. + + This is intended only to test that `func` invokes the classes correctly, + so they are mocked. + """ + # Mock DataProperty,FunctionalProperty or the equivalent for settings + # suffix will be "Property" or "Setting" + suffix = func.__name__.capitalize() + mock_and_capture_args(monkeypatch, tp, f"Data{suffix}") + mock_and_capture_args(monkeypatch, tp, f"Functional{suffix}") + DataClass = getattr(tp, f"Data{suffix}") + FunctionalClass = getattr(tp, f"Functional{suffix}") + + def getter(self) -> str: + return "test" + + # This is equivalent to use as a decorator + prop = func(getter) + # The decorator should instantiate a FunctionalProperty/FunctionalSetting + assert isinstance(prop, FunctionalClass) + assert prop.args == () + assert prop.kwargs == {"fget": getter} + + # When instantiated with a default, we make a + # DataProperty/DataSetting. Note that we convert the default + # to a datafactory using `default_factory_from_arguments` + # so the class gets a default factory.` + prop = func(default=0) + assert isinstance(prop, DataClass) + assert prop.args == () + assert prop.kwargs["default_factory"]() == 0 + assert prop.kwargs["readonly"] is False + assert len(prop.kwargs) == 2 + + # The same thing should happen when we use a factory, + # except it should pass through the factory function unchanged. + prop = func(default_factory=list) + assert isinstance(prop, DataClass) + assert prop.args == () + assert prop.kwargs["default_factory"] is list + assert prop.kwargs["readonly"] is False + assert len(prop.kwargs) == 2 + + # The positional argument is the setter, so `None` is not valid + # and probably means someone forgot to add `default=`. + with pytest.raises(tp.MissingDefaultError): + func(None) + + # Calling with no arguments is also not valid and raises an error + with pytest.raises(tp.MissingDefaultError): + func() + + # If more than one default is specified, we should raise an error. + with pytest.raises(tp.OverspecifiedDefaultError): + func(default=[], default_factory=list) + with pytest.raises(tp.OverspecifiedDefaultError): + func(getter, default=[]) + with pytest.raises(tp.OverspecifiedDefaultError): + func(getter, default_factory=list) + + +def test_baseproperty_type_and_model(): + """Test type functionality in BaseProperty + + This checks baseproperty correctly wraps plain types in a + `pydantic.RootModel`. + """ + prop = tp.BaseProperty() + + # By default, we have no type so `.type` errors. + with pytest.raises(tp.MissingTypeError): + prop.value_type + with pytest.raises(tp.MissingTypeError): + prop.model + + # Once _type is set, these should both work. + prop._type = str | None + assert str(prop.value_type) == "str | None" + assert issubclass(prop.model, pydantic.RootModel) + assert str(prop.model.model_fields["root"].annotation) == "str | None" + + +def test_baseproperty_type_and_model_pydantic(): + """Test type functionality in BaseProperty + + This checks baseproperty behaves correctly when its + type is a BaseModel instance. + """ + prop = tp.BaseProperty() + + class MyModel(pydantic.BaseModel): + foo: str + bar: int + + # Once _type is set, these should both work. + prop._type = MyModel + assert prop.value_type is MyModel + assert prop.model is MyModel + + +def test_baseproperty_add_to_fastapi(): + """Check the method that adds the property to the HTTP API.""" + # Subclass to add __set__ (which is missing on BaseProperty as it's + # implemented by subclasses). + + class MyProperty(tp.BaseProperty): + def __set__(self, obj, val): + pass + + class Example: + prop = MyProperty() + """A docstring with a title. + + A docstring body. + """ + prop._type = str | None + + # Add a path attribute, so we can use Example as a mock Thing. + path = "/example/" + + # Make a FastAPI app and retrieve the OpenAPI document + app = fastapi.FastAPI() + Example.prop.add_to_fastapi(app, Example()) + with TestClient(app) as tc: + r = tc.get("/openapi.json") + assert r.status_code == 200 + openapi = r.json() + + # Check the property appears at the expected place + entry = openapi["paths"]["/example/prop"] + # Check it declares the right methods + assert set(entry.keys()) == set(["get", "put"]) + + +def test_decorator_exception(): + r"""Check decorators work as expected when the setter has a different name. + + This is done to satisfy ``mypy`` and more information is in the + documentation for `.property`\ , `.DescriptorAddedToClassTwiceError` + and `.FunctionalProperty.__set_name__`\ . + """ + # The exception should be specific - a simple double assignment is + # still an error + with raises_or_is_caused_by(DescriptorAddedToClassTwiceError): + + class BadExample: + """A class with a wrongly reused descriptor.""" + + prop1: int = tp.property(default=0) + prop2: int = prop1 + + # The example below should be exempted from the rule, i.e. no error + class Example: + @tp.property + def prop(self) -> bool: + """An example getter.""" + + @prop.setter + def set_prop(self, val: bool) -> None: + """A setter named differently.""" + pass + + assert isinstance(Example.prop, tp.FunctionalProperty) + assert Example.prop.name == "prop" + assert not isinstance(Example.set_prop, tp.FunctionalProperty) + assert callable(Example.set_prop) diff --git a/tests/test_settings.py b/tests/test_settings.py index 50bb7656..8183149a 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -8,24 +8,73 @@ from fastapi.testclient import TestClient import labthings_fastapi as lt +from .temp_client import poll_task class TestThing(lt.Thing): - boolsetting = lt.ThingSetting(bool, False, description="A boolean setting") - stringsetting = lt.ThingSetting(str, "foo", description="A string setting") - dictsetting = lt.ThingSetting( - dict, {"a": 1, "b": 2}, description="A dictionary setting" - ) + """A test `.Thing` with some settings and actions.""" - _float = 1.0 + def __init__(self) -> None: + super().__init__() + # Initialize functional settings with default values + self._floatsetting: float = 1.0 + self._localonlysetting = "Local-only default." - @lt.thing_setting + boolsetting: bool = lt.setting(default=False) + "A boolean setting" + + stringsetting: str = lt.setting(default="foo") + "A string setting" + + dictsetting: dict = lt.setting(default_factory=lambda: {"a": 1, "b": 2}) + "A dictionary setting" + + @lt.setting def floatsetting(self) -> float: - return self._float + """A float setting.""" + return self._floatsetting @floatsetting.setter def floatsetting(self, value: float): - self._float = value + self._floatsetting = value + + @lt.setting + def localonlysetting(self) -> str: + """A setting that is not writeable from HTTP clients or DirectThingClients. + + This setting has a setter, so may be written to from this Thing, or + when settings are loaded. However, it's marked as read-only later, which + means HTTP clients or DirectThingClient subclasses can't write to it. + """ + return self._localonlysetting + + @localonlysetting.setter + def localonlysetting(self, value: str): + self._localonlysetting = value + + localonlysetting.readonly = True + + localonly_boolsetting: bool = lt.setting(default=False, readonly=True) + + @lt.thing_action + def write_localonly_setting(self, value: str) -> None: + """Change the value of the local-only setting. + + This is allowed - the setting is only read-only for code running + over HTTP or via a DirectThingClient. By using this action, we can + check it's writeable for local code. + """ + self.localonlysetting = value + + @lt.thing_action + def toggle_localonly_boolsetting(self) -> None: + """Toggle the local-only bool setting. + + Settings with `readonly=True` are read-only for client code via HTTP + or a DirectThingClient. This action checks they are still writeable + from within the Thing. + """ + self.localonly_boolsetting = not self.localonly_boolsetting @lt.thing_action def toggle_boolsetting(self): @@ -37,13 +86,66 @@ def toggle_boolsetting_from_thread(self): t.start() +TestThingClientDep = lt.deps.direct_thing_client_dependency(TestThing, "/thing/") +TestThingDep = lt.deps.raw_thing_dependency(TestThing) + + +class ClientThing(lt.Thing): + """This Thing attempts to set read-only settings on TestThing. + + Read-only settings may not be set by DirectThingClient wrappers, + which is what this class tests. + """ + + @lt.thing_action + def set_localonlysetting( + self, + client: TestThingClientDep, + val: str, + ): + """Attempt to set a setting with a DirectThingClient.""" + client.localonlysetting = val + + @lt.thing_action + def set_localonly_boolsetting( + self, + client: TestThingClientDep, + val: bool, + ): + """Attempt to set a setting with a DirectThingClient.""" + client.localonly_boolsetting = val + + @lt.thing_action + def directly_set_localonlysetting( + self, + test_thing: TestThingDep, + val: str, + ): + """Attempt to set a setting directly.""" + test_thing.localonlysetting = val + + @lt.thing_action + def directly_set_localonly_boolsetting( + self, + test_thing: TestThingDep, + val: bool, + ): + """Attempt to set a setting directly.""" + test_thing.localonly_boolsetting = val + + def _get_setting_file(server, thingpath): path = os.path.join(server.settings_folder, thingpath.lstrip("/"), "settings.json") return os.path.normpath(path) def _settings_dict( - boolsetting=False, floatsetting=1.0, stringsetting="foo", dictsetting=None + boolsetting=False, + floatsetting=1.0, + stringsetting="foo", + dictsetting=None, + localonlysetting="Local-only default.", + localonly_boolsetting=False, ): """Return the expected settings dictionary @@ -56,6 +158,8 @@ def _settings_dict( "floatsetting": floatsetting, "stringsetting": stringsetting, "dictsetting": dictsetting, + "localonlysetting": localonlysetting, + "localonly_boolsetting": localonly_boolsetting, } @@ -64,6 +168,11 @@ def thing(): return TestThing() +@pytest.fixture +def client_thing(): + return ClientThing() + + @pytest.fixture def server(): with tempfile.TemporaryDirectory() as tempdir: @@ -77,25 +186,136 @@ def test_setting_available(thing): assert not thing.boolsetting assert thing.stringsetting == "foo" assert thing.floatsetting == 1.0 + assert thing.localonlysetting == "Local-only default." + assert thing.dictsetting == {"a": 1, "b": 2} + +def test_functional_settings_save(thing, server): + """Check updated settings are saved to disk -def test_settings_save(thing, server): - """Check updated settings are saved to disk""" + ``floatsetting`` is a functional setting, we should also test + a `.DataSetting` for completeness.""" setting_file = _get_setting_file(server, "/thing") server.add_thing(thing, "/thing") # No setting file created when first added assert not os.path.isfile(setting_file) with TestClient(server.app) as client: + # We write a new value to the property with a PUT request r = client.put("/thing/floatsetting", json=2.0) + # A 201 return code means the operation succeeded (i.e. + # the property was written to) assert r.status_code == 201 + # We check the value with a GET request r = client.get("/thing/floatsetting") assert r.json() == 2.0 + # After successfully writing to the setting, it should + # have created a settings file. assert os.path.isfile(setting_file) with open(setting_file, "r", encoding="utf-8") as file_obj: # Check settings on file match expected dictionary assert json.load(file_obj) == _settings_dict(floatsetting=2.0) +def test_data_settings_save(thing, server): + """Check updated settings are saved to disk + + This uses ``intsetting`` which is a `.DataSetting` so it tests + a different code path to the functional setting above.""" + setting_file = _get_setting_file(server, "/thing") + server.add_thing(thing, "/thing") + # The settings file should not be created yet - it's created the + # first time we write to a setting. + assert not os.path.isfile(setting_file) + with TestClient(server.app) as client: + # Change the value using a PUT request + r = client.put("/thing/boolsetting", json=True) + # Check the value was written successfully (201 response code) + assert r.status_code == 201 + # Check the value is what we expect + r = client.get("/thing/boolsetting") + assert r.json() is True + # After successfully writing to the setting, it should + # have created a settings file. + assert os.path.isfile(setting_file) + with open(setting_file, "r", encoding="utf-8") as file_obj: + # Check settings on file match expected dictionary + assert json.load(file_obj) == _settings_dict(boolsetting=True) + + +@pytest.mark.parametrize( + ("endpoint", "value"), + [ + ("localonlysetting", "Other value"), + ("localonly_boolsetting", True), + ], +) +@pytest.mark.parametrize( + "method", + ["http", "direct_thing_client", "direct"], +) +def test_readonly_setting(thing, client_thing, server, endpoint, value, method): + """Check read-only functional settings cannot be set remotely. + + Functional settings must always have a setter, and will be + writeable from within the Thing. However, they should not + be settable remotely or via a DirectThingClient. + + This test is a bit complicated, but it checks both a + `.FunctionalSetting` and a `.DataSetting` via all three + methods: HTTP, DirectThingClient, and directly on the Thing. + Only the last method should work. + + The test is parametrized so it will run 6 times, trying one + block of code inside the ``with`` block each time. + """ + setting_file = _get_setting_file(server, "/thing") + server.add_thing(thing, "/thing") + server.add_thing(client_thing, "/client_thing") + # No setting file created when first added + assert not os.path.isfile(setting_file) + + # Access it over "HTTP" with a TestClient + # This doesn't actually serve over the network but will use + # all the same codepaths. + with TestClient(server.app) as client: + if method == "http": + # Attempt to set read-only setting + r = client.put(f"/thing/{endpoint}", json=value) + assert r.status_code == 405 + + if method == "direct_thing_client": + # Attempt to set read-only setting via a DirectThingClient + r = client.post(f"/client_thing/set_{endpoint}", json={"val": value}) + assert r.status_code == 201 + invocation = poll_task(client, r.json()) + # The setting is not changed (that's tested later), but the action + # does complete. It should fail with an error, but this is expected + # behaviour - see #165. + assert invocation["status"] == "completed" + + # Check the setting hasn't changed over HTTP + r = client.get(f"/thing/{endpoint}") + assert r.json() == _settings_dict()[endpoint] + assert r.status_code == 200 + + if method == "direct": + # Actually set read-only setting via raw_thing_dependency + r = client.post( + f"/client_thing/directly_set_{endpoint}", json={"val": value} + ) + invocation = poll_task(client, r.json()) + assert invocation["status"] == "completed" + + if method == "direct": + # Setting directly should succeed, so the file should exist. + with open(setting_file, "r", encoding="utf-8") as file_obj: + # Check settings on file match expected dictionary + assert json.load(file_obj) == _settings_dict(**{endpoint: value}) + else: + # Other methods fail, so there should be no file here. + assert not os.path.isfile(setting_file) # No file created + + def test_settings_dict_save(thing, server): """Check settings are saved if the dict is updated in full""" setting_file = _get_setting_file(server, "/thing") diff --git a/tests/test_thing_dependencies.py b/tests/test_thing_dependencies.py index 1279cd37..a9fd7a30 100644 --- a/tests/test_thing_dependencies.py +++ b/tests/test_thing_dependencies.py @@ -7,7 +7,7 @@ from fastapi import Request import pytest import labthings_fastapi as lt -from temp_client import poll_task +from .temp_client import poll_task from labthings_fastapi.client.in_server import direct_thing_client_class from labthings_fastapi.utilities.introspection import fastapi_dependency_params diff --git a/tests/test_thing_lifecycle.py b/tests/test_thing_lifecycle.py index 2d7331b9..1024cd5c 100644 --- a/tests/test_thing_lifecycle.py +++ b/tests/test_thing_lifecycle.py @@ -3,7 +3,8 @@ class TestThing(lt.Thing): - alive = lt.ThingProperty(bool, False, description="Is the thing alive?") + alive: bool = lt.property(default=False) + "Whether the thing is alive." def __enter__(self): print("setting up TestThing from __enter__") diff --git a/tests/utilities.py b/tests/utilities.py new file mode 100644 index 00000000..c5d3a574 --- /dev/null +++ b/tests/utilities.py @@ -0,0 +1,32 @@ +"""Useful functions for test code.""" + +from contextlib import contextmanager +from typing import Iterator +import pytest + + +@contextmanager +def raises_or_is_caused_by( + exception_cls: type[Exception], +) -> Iterator[pytest.ExceptionInfo]: + r"""Wrap `pytest.raises` to cope with exceptions that are wrapped in another error. + + Some errors raised during class creation are wrapped in a `RuntimeError` on older + Python versions. This makes them harder to test for. + + This context manager checks the exception, and if it is not the expected class it + will then check the ``__cause__`` attribute. If the ``__cause__`` matches, we + replace the exception in the yielded ``excinfo`` object with its ``__cause__`` + so that the correct exception may be inspected. + + If neither matches, we will fail with an `AssertionError`\ . + """ + with pytest.raises(Exception) as excinfo: + yield excinfo + if not isinstance(excinfo.value, exception_cls): + assert isinstance(excinfo.value.__cause__, exception_cls) + assert excinfo._excinfo is not None + # If excinfo._excinfo is None, we missed an exception and the code should + # already have failed. + traceback = excinfo._excinfo[2] + excinfo._excinfo = (exception_cls, excinfo.value.__cause__, traceback) diff --git a/typing_tests/README.md b/typing_tests/README.md new file mode 100644 index 00000000..109d79cc --- /dev/null +++ b/typing_tests/README.md @@ -0,0 +1,11 @@ +# Typing tests: check `labthings_fastapi` plays nicely with `mypy`. + +The codebase is type-checked with `mypy src/` and tested with `pytest`, however neither of these explicitly check that `mypy` can infer the correct types for `Thing` attributes like properties and actions. The Python files in this folder are intended to be checked using: + +```terminal +mypy --warn-unused-ignores typing_tests +``` + +The files include valid code that's accompanied by `assert_type` statements (which check the inferred types are what we expect them to be) as well as invalid code where the expected `mypy` errors are ignored. This tests for expected errors - if an expected error is not thrown, it will cause an `unused-ignore` error. + +There are more elaborate type testing solutions available, but all of them add dependencies and require us to learn a new tool. This folder of "tests" feels like a reasonable way to test that the package plays well with static type checking, without too much added complication. \ No newline at end of file diff --git a/typing_tests/thing_definitions.py b/typing_tests/thing_definitions.py new file mode 100644 index 00000000..b0815e91 --- /dev/null +++ b/typing_tests/thing_definitions.py @@ -0,0 +1,278 @@ +"""Test thing definitions for type checking. + +This module checks that code defining a Thing may be type checked using +mypy. + +Note that most of the properties are typed as ``int`` or ``int | None`` +and we do not attempt to cover all possible types. A greater range of types +should be tested in code that's actually run, in the `tests` folder. For +this file, what's important is checking that: + +1. The type of the default/factory is compatible with the property + (though not necessarily identical). +2. Errors are raised if types don't match. +3. Class and instance attributes have the expected types. + +This requires at least a couple of types, where one is compatible +with the other, hence ``int`` and ``int | None`` which lets us +check compatibility, and also check ``None`` is OK as a default. + +See README.md for how it's run. +""" + +import labthings_fastapi as lt +from labthings_fastapi.properties import FunctionalProperty + +from typing_extensions import assert_type +import typing + + +def optional_int_factory() -> int | None: + """Return an optional int.""" + return None + + +def int_factory() -> int: + """Return an int.""" + return 0 + + +unbound_prop = lt.property(default=0) +"""A property that is not bound to a Thing. + +This will go wrong at runtime if we access its ``model`` but it should +have its type inferred as an `int`. This is intended to let mypy check +the default is of the correct type when used with dataclass-style syntax +(``prop: int = lt.property(default=0)`` ). +""" +assert_type(unbound_prop, int) + +unbound_prop_2 = lt.property(default_factory=int_factory) +"""A property that is not bound to a Thing, with a factory. + +As with `.unbound_prop` this won't work at runtime, but its type should +be inferred as `int` (which allows checking the default type matches +the attribute type annotation, when used on a class). +""" + +assert_type(unbound_prop_2, int) + + +@lt.property +def strprop(self) -> str: + """A functional property that should not cause mypy errors.""" + return "foo" + + +assert_type(strprop, FunctionalProperty[str]) + + +class TestPropertyDefaultsMatch(lt.Thing): + """A Thing that checks our property type hints are working. + + This Thing defines properties in various ways. Some of these should cause + mypy to throw errors, for example if the default has the wrong type. + """ + + # These properties should not cause mypy errors, as the default matches + # the type hint. + intprop: int = lt.property(default=0) + optionalintprop: int | None = lt.property(default=None) + optionalintprop2: int | None = lt.property(default=0) + optionalintprop3: int | None = lt.property(default_factory=optional_int_factory) + optionalintprop4: int | None = lt.property(default_factory=int_factory) + + # This property should cause mypy to throw an error, as the default is a string. + # The type hint is an int, so this should cause a mypy error. + intprop2: int = lt.property(default="foo") # type: ignore[assignment] + intprop3: int = lt.property(default_factory=optional_int_factory) # type: ignore[assignment] + + # Data properties must always have a default, so this line should fail + # with mypy. It will also raise an exception at runtime, and there's a + # test for that run with pytest. + intprop4: int = lt.property() # type: ignore[call-overload] + "This property should cause mypy to throw an error, as it has no default." + + listprop: list[int] = lt.property(default_factory=list) + """A list property with a default factory. + + Note the default factory is a less specific type. + + Default types must be compatible with the attribute type, but not + necessarily the same. This tests a common scenario, where the default (an + empty list) is compatible, but not the same as ``list[int]`` . + + Note this is "tested" simply by the absence of `mypy` errors. + """ + + +# Check that the type hints on an instance of the class are correct. +test_defaults_match = TestPropertyDefaultsMatch() +assert_type(test_defaults_match.intprop, int) +assert_type(test_defaults_match.intprop2, int) +assert_type(test_defaults_match.intprop3, int) +assert_type(test_defaults_match.optionalintprop, int | None) +assert_type(test_defaults_match.optionalintprop2, int | None) +assert_type(test_defaults_match.optionalintprop3, int | None) +assert_type(test_defaults_match.optionalintprop4, int | None) + +# NB the types of the class attributes will be the same as the instance attributes +# because of the type hint on `lt.property`. This is incorrect (the class attributes +# will be `DataProperty` instances), but it is not something that code outside of +# LabThings-FastAPI should rely on. See typing notes in `lt.property` docstring +# for more details. + + +class TestExplicitDescriptor(lt.Thing): + r"""A Thing that checks our explicit descriptor type hints are working. + + This tests `.DataProperty` descriptors work as intended when used directly, + rather than via ``lt.property``\ . + + ``lt.property`` has a "white lie" on its return type, which makes it + work with dataclass-style syntax (type annotation on the class attribute + rather than part of the descriptor). It's therefore useful to test + the underlying class as well. + """ + + intprop1 = lt.DataProperty[int](default=0) + """A DataProperty that should not cause mypy errors.""" + + intprop2 = lt.DataProperty[int](default_factory=int_factory) + """This property should not cause mypy errors, as the factory matches the type hint.""" + + intprop3 = lt.DataProperty[int](default_factory=optional_int_factory) + """Uses a factory function that doesn't match the type hint. + + This ought to cause mypy to throw an error, as the factory function can + return None, but at time of writing this doesn't happen. + + This error is caught correctly when called via `lt.property`. + """ + + intprop4 = lt.DataProperty[int](default="foo") # type: ignore[call-overload] + """This property should cause mypy to throw an error, as the default is a string.""" + + intprop5 = lt.DataProperty[int]() # type: ignore[call-overload] + """This property should cause mypy to throw an error, as it has no default.""" + + optionalintprop1 = lt.DataProperty[int | None](default=None) + """A DataProperty that should not cause mypy errors.""" + + optionalintprop2 = lt.DataProperty[int | None](default_factory=optional_int_factory) + """This property should not cause mypy errors, as the factory matches the type hint.""" + + optionalintprop3 = lt.DataProperty[int | None](default_factory=int_factory) + """Uses a factory function that is a subset of the type hint.""" + + +# Check instance attributes are typed correctly. +test_explicit_descriptor = TestExplicitDescriptor() +assert_type(test_explicit_descriptor.intprop1, int) +assert_type(test_explicit_descriptor.intprop2, int) +assert_type(test_explicit_descriptor.intprop3, int) + +assert_type(test_explicit_descriptor.optionalintprop1, int | None) +assert_type(test_explicit_descriptor.optionalintprop2, int | None) +assert_type(test_explicit_descriptor.optionalintprop3, int | None) + +# Check class attributes are typed correctly. +assert_type(TestExplicitDescriptor.intprop1, lt.DataProperty[int]) +assert_type(TestExplicitDescriptor.intprop2, lt.DataProperty[int]) +assert_type(TestExplicitDescriptor.intprop3, lt.DataProperty[int]) + +assert_type(TestExplicitDescriptor.optionalintprop1, lt.DataProperty[int | None]) +assert_type(TestExplicitDescriptor.optionalintprop2, lt.DataProperty[int | None]) +assert_type(TestExplicitDescriptor.optionalintprop3, lt.DataProperty[int | None]) + + +Val = typing.TypeVar("Val") + + +def f_property(getter: typing.Callable[..., Val]) -> FunctionalProperty[Val]: + """A function that returns a FunctionalProperty with a getter.""" + return FunctionalProperty(getter) + + +class TestFunctionalProperty(lt.Thing): + """A Thing that checks our functional property type hints are working.""" + + @lt.property + def intprop1(self) -> int: + """A functional property that should not cause mypy errors.""" + return 0 + + @lt.property + def intprop2(self) -> int: + """This property should not cause mypy errors and is writeable. + + This property has a getter and setter, so it can be read and written + from other code within the Thing. However, we make it read-only for + client code (over HTTP or a DirectThingClient). + """ + return 0 + + @intprop2.setter + def set_intprop2(self, value: int): + """Setter for intprop2.""" + pass + + # Make the property read-only in the Thing Description and for HTTP + # clients, or DirectThingClients. See property documentation on + # readthedocs for examples. + intprop2.readonly = True + + @lt.property + def intprop3(self) -> int: + """This getter is fine, but the setter should fail type checking.""" + return 0 + + @intprop3.setter + def set_intprop3(self, value: str) -> None: + """Setter for intprop3. It's got the wrong type so should fail.""" + pass + + @f_property + def fprop(self) -> int: + """A functional property that should not cause mypy errors. + + This uses a much simpler function than ``lt.property`` to check + the behaviour is the same. + """ + return 0 + + @fprop.setter + def set_fprop(self, value: int) -> None: + """Setter for fprop. Type checking should pass.""" + pass + + @lt.property + def strprop(self) -> str: + """A property with identically named getter/setter.""" + return "Hello world!" + + @strprop.setter # type: ignore[no-redef] + def strprop(self, val: str) -> None: + """A setter with the same name as the getter. + + This is the convention for `builtins.property` but `mypy` does not + allow it for any other property-like decorators. + + This function should raise a ``no-redef`` error. + """ + pass + + +assert_type(TestFunctionalProperty.intprop1, FunctionalProperty[int]) +assert_type(TestFunctionalProperty.intprop2, FunctionalProperty[int]) +assert_type(TestFunctionalProperty.intprop3, FunctionalProperty[int]) +assert_type(TestFunctionalProperty.fprop, FunctionalProperty[int]) +# Don't check ``strprop`` because it caused an error and thus will +# not have the right type, even though the error is ignored. + +test_functional_property = TestFunctionalProperty() +assert_type(test_functional_property.intprop1, int) +assert_type(test_functional_property.intprop2, int) +assert_type(test_functional_property.intprop3, int) +assert_type(test_functional_property.fprop, int) +# ``strprop`` will be ``Any`` because of the ``[no-redef]`` error.