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. `.BaseDescriptor` provides consistent handling of name, title, and description, as well as implementing the convention that descriptors return themselves when accessed as class attributes. It also provides `.BaseDescriptor.descriptor_info` to return an object that may be used to refer to the descriptor (see later). `.FieldTypedBaseDescriptor` is a subclass of `.BaseDescriptor` that adds "field typing", i.e. the ability to determine the type of the descriptor's value from a type annotation on the class attribute. This is particularly important for :ref:`properties`\ . `.BaseDescriptorInfo` is a class that describes a descriptor, optionally bound to an instance. This allows us to pass around references to descriptors without confusing type checkers, and without needing to separately pass the instance along with the descriptor. `.DescriptorInfoCollection` is a mapping of descriptor names to `.BaseDescriptorInfo` objects, and may be used to retrieve all descriptors of a particular type on a `~lt.Thing`\ . Attributes ---------- .. autoapisummary:: labthings_fastapi.base_descriptor.Value labthings_fastapi.base_descriptor.Owner labthings_fastapi.base_descriptor.Descriptor labthings_fastapi.base_descriptor.FTDescriptorT labthings_fastapi.base_descriptor.DescriptorInfoT labthings_fastapi.base_descriptor.OptionallyBoundInfoT labthings_fastapi.base_descriptor._class_attribute_docstring_cache Classes ------- .. autoapisummary:: labthings_fastapi.base_descriptor.OptionallyBoundInfo labthings_fastapi.base_descriptor.BaseDescriptorInfo labthings_fastapi.base_descriptor.BaseDescriptor labthings_fastapi.base_descriptor.FieldTypedBaseDescriptorInfo labthings_fastapi.base_descriptor.FieldTypedBaseDescriptor labthings_fastapi.base_descriptor.DescriptorInfoCollection labthings_fastapi.base_descriptor.OptionallyBoundDescriptor 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:data:: Owner A Thing subclass that owns a descriptor. .. py:data:: Descriptor The type of a descriptor that's referred to by a `BaseDescriptorInfo` object. .. py:data:: FTDescriptorT The type of a field typed descriptor. .. py:data:: DescriptorInfoT The type of `.BaseDescriptorInfo` returned by a descriptor .. py:data:: OptionallyBoundInfoT The type of `OptionallyBoundInfo` returned by a descriptor. .. py:class:: OptionallyBoundInfo(obj: Owner | None, cls: type[Owner] | None = None) Bases: :py:obj:`Generic`\ [\ :py:obj:`Owner`\ ] A class that may be bound to an owning object or to a class. Initialise an `OptionallyBoundInfo` object. This initialises the object, optionally binding it to `obj` if it is not `None`\ . :param obj: The object to which this info object is bound. If it is `None` (default), the object will be unbound and will refer to the descriptor as attached to the class. This may mean that some methods are unavailable. :param cls: The class to which this info object refers. May be omitted if `obj` is supplied. :raises ValueError: if neither `obj` nor `cls` is supplied. :raises TypeError: if `obj` and `cls` are both supplied, but `obj` is not an instance of `cls`. Note that `cls` does not have to be equal to ``obj.__class__``\ , it just has to pass `isinstance`\ . .. py:attribute:: _descriptor_cls :value: None .. py:attribute:: _bound_to_obj .. py:property:: owning_class :type: type[Owner] Retrieve the class this info object is describing. .. py:property:: owning_object :type: Owner | None Retrieve the object to which this info object is bound, if present. .. py:property:: is_bound :type: bool Whether this info object is bound to an instance. If this property is `False` then this object refers only to a class. If it is `True` then we are describing a particular instance. .. py:method:: owning_object_or_error() -> Owner Return the `~lt.Thing` instance to which we are bound, or raise an error. This is mostly a convenience function that saves type-checking boilerplate. :return: the owning object. :raises NotBoundToInstanceError: if this object is not bound. .. py:class:: BaseDescriptorInfo(descriptor: Descriptor, obj: Owner | None, cls: type[Owner] | None = None) Bases: :py:obj:`OptionallyBoundInfo`\ [\ :py:obj:`Owner`\ ], :py:obj:`Generic`\ [\ :py:obj:`Descriptor`\ , :py:obj:`Owner`\ , :py:obj:`Value`\ ] A class that describes a `BaseDescriptor`\ . This class is used internally by LabThings to describe :ref:`properties`\ , :ref:`actions`\ , and other attributes of a `~lt.Thing`\ . It's not usually encountered directly by someone using LabThings, except as a base class for `~.actions.Action`\ , `~.properties.BaseProperty` and others. LabThings uses descriptors to represent the :ref:`wot_affordances` of a `~lt.Thing`\ . However, passing descriptors around isn't very elegant for two reasons: * Holding references to Descriptor objects can confuse static type checkers. * Descriptors are attached to a *class* but do not know which *object* they are defined on. This class allows the attributes of a descriptor to be accessed, and holds a reference to the underlying descriptor and its owning class. It may optionally hold a reference to a `~lt.Thing` instance, in which case it is said to be "bound". This means there's no need to separately pass the `~lt.Thing` along with the descriptor, which should help keep things simple in several places in the code. Initialise an `OptionallyBoundInfo` object. This sets up a BaseDescriptorInfo object, describing ``descriptor`` and optionally bound to ``obj``\ . :param descriptor: The descriptor that this object will describe. :param obj: The object to which this `.BaseDescriptorInfo` is bound. If it is `None` (default), the object will be unbound and will refer to the descriptor as attached to the class. This may mean that some methods are unavailable. :param cls: The class to which we are bound. Only required if ``obj`` is `None`\ . :raises ValueError: if both ``obj`` and ``cls`` are `None`\ . .. py:attribute:: _descriptor_ref .. py:attribute:: _descriptor_cls :value: None .. py:attribute:: _bound_to_obj .. py:method:: get_descriptor() -> Descriptor Retrieve the descriptor object. :return: The descriptor object :raises RuntimeError: if the descriptor was garbage collected. This should never happen. .. py:property:: name :type: str The name of the descriptor. This should be the same as the name of the attribute in Python. .. py:property:: title :type: str The title of the descriptor. .. py:property:: description :type: str | None A description (usually the docstring) of the descriptor. .. py:method:: get() -> Value Get the value of the descriptor. This method only works on a bound info object, it will raise an error if called via a class rather than a `~lt.Thing` instance. :return: the value of the descriptor. :raises NotBoundToInstanceError: if called on an unbound object. .. py:method:: __eq__(other: Any) -> bool Determine if this object is equal to another one. :param other: the object we're comparing to. :return: whether the two objects are equal. .. py:method:: __repr__() -> str Represent the DescriptorInfo object as a string. :return: a string representing the info object. .. py:class:: BaseDescriptor Bases: :py:obj:`Generic`\ [\ :py:obj:`Owner`\ , :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") `.BaseDescriptor` is a "non-data descriptor" (meaning it doesn't implement ``__set__``). This allows it to be overwritten by assigning to an object's attribute, which can be useful in test code. This can easily be changed in subclasses by implementing ``__set__``\ . 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[Owner], 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 `~lt.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:: owning_class :type: type[Owner] The class on which this descriptor is defined. :raises DescriptorNotAddedToClassError: if the owning class is not set. :raises UnexpectedGarbageCollectionError: if the owning class has been finalized. .. 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: Owner, type: BaseDescriptor.__get__.type | None = None) -> Value __get__(obj: None, type: BaseDescriptor.__get__.type) -> 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 `~lt.Thing` instance to which we are attached. :param type: the `~lt.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: Owner) -> 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 `~lt.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:method:: _descriptor_info(info_class: type[DescriptorInfoT], obj: Owner | None = None) -> DescriptorInfoT Return a `BaseDescriptorInfo` object for this descriptor. The return value of this function is an object that may be passed around without confusing type checkers, but still allows access to all of its functionality. Essentially, it just misses out ``__get__`` so that it is no longer a Descriptor. If ``owner`` is supplied, the returned object is bound to a particular object, and if not it is unbound, i.e. knows only about the class. :param info_class: the `.BaseDescriptorInfo` subclass to return. :param obj: The `~lt.Thing` instance to which the return value is bound. :return: An object that may be used to refer to this descriptor. .. py:method:: descriptor_info(owner: Owner | None = None) -> BaseDescriptorInfo[Self, Owner, Value] Return a `BaseDescriptorInfo` object for this descriptor. This generates an object that refers to the descriptor, optionally bound to a particular object. It's intended to make it easier to pass around references to particular affordances, without needing to retrieve and store Descriptor objects directly (which gets confusing). If ``owner`` is supplied, the returned object is bound to a particular object, and if not it is unbound, i.e. knows only about the class. :param owner: The `~lt.Thing` instance to which the return value is bound. :return: An object that may be used to refer to this descriptor. .. py:class:: FieldTypedBaseDescriptorInfo(descriptor: Descriptor, obj: Owner | None, cls: type[Owner] | None = None) Bases: :py:obj:`BaseDescriptorInfo`\ [\ :py:obj:`FTDescriptorT`\ , :py:obj:`Owner`\ , :py:obj:`Value`\ ], :py:obj:`Generic`\ [\ :py:obj:`FTDescriptorT`\ , :py:obj:`Owner`\ , :py:obj:`Value`\ ] A description of a `.FieldTypedBaseDescriptor`\ . This adds `value_type` to `.BaseDescriptorInfo` so we can fully describe a `.FieldTypedBaseDescriptor`\ . Initialise an `OptionallyBoundInfo` object. This sets up a BaseDescriptorInfo object, describing ``descriptor`` and optionally bound to ``obj``\ . :param descriptor: The descriptor that this object will describe. :param obj: The object to which this `.BaseDescriptorInfo` is bound. If it is `None` (default), the object will be unbound and will refer to the descriptor as attached to the class. This may mean that some methods are unavailable. :param cls: The class to which we are bound. Only required if ``obj`` is `None`\ . :raises ValueError: if both ``obj`` and ``cls`` are `None`\ . .. py:property:: value_type :type: type[Value] The type of the descriptor's value. .. py:method:: set(value: Value) -> None Set the value of the descriptor. This method may only be called if the DescriptorInfo object is bound to a `~lt.Thing` instance. It will raise an error if called on a class. :param value: the new value. :raises NotBoundToInstanceError: if called on an unbound info object. .. py:class:: FieldTypedBaseDescriptor Bases: :py:obj:`Generic`\ [\ :py:obj:`Owner`\ , :py:obj:`Value`\ ], :py:obj:`BaseDescriptor`\ [\ :py:obj:`Owner`\ , :py:obj:`Value`\ ] A `.BaseDescriptor` that determines its type like a dataclass field. This adds two things to `.BaseDescriptor`\ : 1. Descriptors inheriting from this class will inspect the type annotations of their owning class when determining ``value_type``\ . 2. This class and its children will be "data descriptors" because there is a stub implementation of ``__set__``\ . This means that the attribute may not be assigned to (unless ``__set__`` is overridden). This is the behaviour that `builtins.property` has. 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:method:: __set_name__(owner: type[Owner], 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 `~lt.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:method:: descriptor_info(owner: Owner | None = None) -> FieldTypedBaseDescriptorInfo[Self, Owner, Value] Return a `BaseDescriptorInfo` object for this descriptor. This generates an object that refers to the descriptor, optionally bound to a particular object. It's intended to make it easier to pass around references to particular affordances, without needing to retrieve and store Descriptor objects directly (which gets confusing). If ``owner`` is supplied, the returned object is bound to a particular object, and if not it is unbound, i.e. knows only about the class. :param owner: The `~lt.Thing` instance to which the return value is bound. :return: An object that may be used to refer to this descriptor. .. py:method:: __set__(obj: Owner, value: Value) -> None Mark the `BaseDescriptor` as a data descriptor. Even for read-only descriptors, it's important to define a ``__set__`` method. The presence of this method prevents Python overwriting the descriptor when a value is assigned. This base implementation returns an `AttributeError` to signal that the descriptor is read-only. Overriding it with a method that does not raise an exception will allow the descriptor to be written to. :param obj: The object on which to set the value. :param value: The value to set the descriptor to. :raises AttributeError: always, as this is read-only by default. .. py:class:: DescriptorInfoCollection(obj: Owner | None, cls: type[Owner] | None = None) Bases: :py:obj:`Mapping`\ [\ :py:obj:`str`\ , :py:obj:`DescriptorInfoT`\ ], :py:obj:`OptionallyBoundInfo`\ [\ :py:obj:`Owner`\ ], :py:obj:`Generic`\ [\ :py:obj:`Owner`\ , :py:obj:`DescriptorInfoT`\ ] Easy access to DescriptorInfo objects of a particular type. This class works as a Mapping, so you can retrieve individual `.DescriptorInfo` objects by name, or iterate over the names of the descriptors. It may be initialised with an object, in which case the contained `.DescriptorInfo` objects will be bound to that object. If initialised without an object, the contained `.DescriptorInfo` objects will be unbound, i.e. referring only to the class. This class is subclassed by each of the LabThings descriptors (Properties, Actions, etc.) and generated by a corresponding `.OptionallyBoundDescriptor` on `~lt.Thing` for convenience. Initialise the DescriptorInfoCollection. This initialises the object, optionally binding it to `obj` if it is not `None`\ . :param obj: The object to which this info object is bound. If it is `None` (default), the object will be unbound and will refer to the descriptor as attached to the class. This may mean that some methods are unavailable. :param cls: The class to which this info object refers. May be omitted if `obj` is supplied. .. py:attribute:: _descriptorinfo_class :type: type[DescriptorInfoT] The class of DescriptorInfo objects contained in this collection. This class attribute must be set in subclasses. .. py:property:: descriptorinfo_class :type: type[DescriptorInfoT] The class of DescriptorInfo objects contained in this collection. .. py:method:: __getitem__(key: str) -> DescriptorInfoT Retrieve a DescriptorInfo object given the name of the descriptor. :param key: The name of the descriptor whose info object is required. :return: The DescriptorInfo object for the named descriptor. :raises KeyError: if the key does not refer to a descriptor of the right type. .. py:method:: __iter__() -> collections.abc.Iterator[str] Iterate over the names of the descriptors of the specified type. :yield: The names of the descriptors. .. py:method:: __len__() -> int Return the number of descriptors of the specified type. :return: The number of descriptors of the specified type. .. py:class:: OptionallyBoundDescriptor(cls: type[OptionallyBoundInfoT]) Bases: :py:obj:`Generic`\ [\ :py:obj:`Owner`\ , :py:obj:`OptionallyBoundInfoT`\ ] A descriptor that will return an OptionallyBoundInfo object. This descriptor will return an instance of a particular class, initialised with either the object, or its class, depending on how it is accessed. This is useful for returning collections of `.BaseDescriptorInfo` objects from a `~lt.Thing` subclass. Initialise the descriptor. :param cls: The class of `.OptionallyBoundInfo` objects that this descriptor will return. .. py:attribute:: _cls .. py:method:: __get__(obj: Owner | None, cls: type[Owner] | None = None) -> OptionallyBoundInfoT Return an OptionallyBoundInfo object. :param obj: The object to which the info is bound, or `None` if unbound. :param cls: The class on which the info is defined. :return: An `OptionallyBoundInfo` object. .. 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.