labthings_fastapi.base_descriptor ================================= .. py:module:: labthings_fastapi.base_descriptor .. autoapi-nested-parse:: 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. Attributes ---------- .. autoapisummary:: labthings_fastapi.base_descriptor.Value labthings_fastapi.base_descriptor._class_attribute_docstring_cache Exceptions ---------- .. autoapisummary:: labthings_fastapi.base_descriptor.DescriptorNotAddedToClassError labthings_fastapi.base_descriptor.DescriptorAddedToClassTwiceError Classes ------- .. autoapisummary:: labthings_fastapi.base_descriptor.BaseDescriptor labthings_fastapi.base_descriptor.FieldTypedBaseDescriptor Functions --------- .. autoapisummary:: labthings_fastapi.base_descriptor.get_class_attribute_docstrings Module Contents --------------- .. py:data:: Value The value returned by the descriptor, when called on an instance. .. py:exception:: DescriptorNotAddedToClassError Bases: :py:obj:`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 Initialize self. See help(type(self)) for accurate signature. .. py:exception:: DescriptorAddedToClassTwiceError Bases: :py:obj:`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. Initialize self. See help(type(self)) for accurate signature. .. py:class:: BaseDescriptor Bases: :py:obj:`Generic`\ [\ :py:obj:`Value`\ ] 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") Initialise a BaseDescriptor. .. py:attribute:: _name :type: str | None :value: None .. py:attribute:: _title :type: str | None :value: None .. py:attribute:: _description :type: str | None :value: None .. py:attribute:: __doc__ :value: None .. py:attribute:: _set_name_called :type: bool :value: False .. py:attribute:: _owner_name :type: str :value: '' .. py:method:: __set_name__(owner: type[labthings_fastapi.thing.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. 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. .. py:method:: assert_set_name_called() -> None Raise an exception if ``__set_name__`` has not yet been called. :raises DescriptorNotAddedToClassError: if ``__set_name__`` has not yet been called. .. py:property:: name :type: 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. :raises DescriptorNotAddedToClassError: if ``__set_name__`` has not yet been called. .. py:property:: title :type: 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. .. py:property:: description :type: 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. .. py:method:: __get__(obj: labthings_fastapi.thing.Thing, type: BaseDescriptor.__get__.type | None = None) -> Value __get__(obj: None, type: BaseDescriptor.__get__.type) -> typing_extensions.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. .. py:method:: instance_get(obj: labthings_fastapi.thing.Thing) -> Value :abstractmethod: 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. .. py:class:: FieldTypedBaseDescriptor Bases: :py:obj:`Generic`\ [\ :py:obj:`Value`\ ], :py:obj:`BaseDescriptor`\ [\ :py:obj:`Value`\ ] A BaseDescriptor that determines its type like a dataclass field. Initialise the FieldTypedBaseDescriptor. Very little happens at initialisation time: most of the type determination happens in ``__set_name__`` and ``value_type`` so that type hints can be lazily evaluated. .. py:attribute:: _type :type: type | None :value: None .. py:attribute:: _unevaluated_type_hint :type: str | None :value: None .. py:attribute:: _owner :type: weakref.ReferenceType[type] | None :value: None .. py:method:: __set_name__(owner: type[labthings_fastapi.thing.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 the descriptor or by annotating the attribute. This example is for ``DataProperty`` as this class is not intended to be used directly. .. code-block:: python class MyThing(Thing): subscripted_property = DataProperty[int](default=0) annotated_property: int = DataProperty(default=0) The second form often works better with autocompletion, though it is usually called via a function to avoid type checking errors. 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. For subscripted types (i.e. the first form above), we use `typing.get_args` to retrieve the value type. This will be evaluated immediately, resolving any forward references. We use `typing.get_type_hints` to resolve type hints on the owning class. This takes care of a lot of subtleties like un-stringifying forward references. In order to support forward references, we only check for the existence of a type hint during ``__set_name__`` and will evaluate it fully during ``value_type``\ . :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. .. py:method:: value_type() -> type[Value] The type of this descriptor's value. This is only available after ``__set_name__`` has been called, which happens at the end of the class definition. If it is called too early, a `.DescriptorNotAddedToClassError` will be raised. Accessing this property will attempt to resolve forward references, i.e. type annotations that are strings. If there is an error resolving the forward reference, a `.MissingTypeError` will be raised. :return: the type of the descriptor's value. :raises MissingTypeError: if the type is None, not resolvable, or not specified. .. py:data:: _class_attribute_docstring_cache :type: weakref.WeakKeyDictionary[type, Mapping[str, str]] .. py:function:: 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.