Skip to content

Commit a914ac2

Browse files
committed
First pass at splitting up ThingProperty
This commit swaps ThingProperty for two descriptor classes, FunctionalProperty (using getters/setters) and DataProperty (variable-like). This preserves the two useful behaviours out of the six (!) possible behaviours implemented by the old ThingProperty. I've also added a `property` function that provides a single interface to both: it may be used either as a decorator, or as a field specifier. This does not yet pass tests/type checking.
1 parent c84a950 commit a914ac2

6 files changed

Lines changed: 916 additions & 411 deletions

File tree

pyproject.toml

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,15 @@ target-version = "py310"
7676
docstring-code-format = true
7777

7878
[tool.ruff.lint]
79+
external = ["DOC401", "F824"] # used via flake8/pydoclint
7980
select = ["E4", "E7", "E9", "F", "D", "DOC"]
8081
ignore = [
8182
"D203", # incompatible with D204
8283
"D213", # incompatible with D212
83-
"DOC402", # doesn't work with sphinx-style docstrings, use pydoclint
84-
"DOC201", # doesn't work with sphinx-style docstrings, use pydoclint
85-
"DOC501", # doesn't work with sphinx-style docstrings, use pydoclint
86-
"DOC502", # doesn't work with sphinx-style docstrings, use pydoclint
84+
"DOC402", # doesn't work with sphinx-style docstrings, use flake8/pydoclint
85+
"DOC201", # doesn't work with sphinx-style docstrings, use flake8/pydoclint
86+
"DOC501", # doesn't work with sphinx-style docstrings, use flake8/pydoclint
87+
"DOC502", # doesn't work with sphinx-style docstrings, use flake8/pydoclint
8788
]
8889
preview = true
8990

@@ -98,6 +99,8 @@ plugins = ["pydantic.mypy", "numpy.typing.mypy_plugin"]
9899
extend-ignore = [
99100
"DOC301", # allow class + __init__ docstrings
100101
"D202", # conflicts with ruff format
102+
"F401", # already implemented by ruff, which respects "import x as x"
103+
"E501", # leave this to ruff (line length), #noqa may exceed max line length.
101104
]
102105
max-line-length = 88
103106
rst-roles = [

src/labthings_fastapi/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"""
2121

2222
from .thing import Thing
23-
from .descriptors import ThingProperty, ThingSetting
23+
from .thing_property import property, ThingSetting
2424
from .decorators import (
2525
thing_property,
2626
thing_setting,
@@ -42,7 +42,7 @@
4242
# re-export style, we may switch in the future.
4343
__all__ = [
4444
"Thing",
45-
"ThingProperty",
45+
"property",
4646
"ThingSetting",
4747
"thing_property",
4848
"thing_setting",
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""A base class for descriptors in LabThings.
2+
3+
:ref:`descriptors` are used to describe :ref:`wot_affordances` in LabThings-FastAPI.
4+
There is some behaviour common to most of these, and `.BaseDescriptor` centralises
5+
the code that implements it.
6+
"""
7+
8+
from typing import overload, Generic, Self, TypeVar, TYPE_CHECKING
9+
10+
from .utilities.introspection import get_summary
11+
12+
if TYPE_CHECKING:
13+
from .thing import Thing
14+
15+
Value = TypeVar("Value")
16+
"""The value returned by the descriptor, when called on an instance."""
17+
18+
19+
class NameNotSetError(AttributeError):
20+
"""Descriptor name is not yet available.
21+
22+
This error is raised if the name of an affordance is accessed
23+
before ``__set_name__`` has been called on the descriptor.
24+
"""
25+
26+
27+
class BaseDescriptor(Generic[Value]):
28+
r"""A base class for descriptors in LabThings-FastAPI.
29+
30+
This class implements several behaviours common to descriptors in LabThings:
31+
32+
* The descriptor remembers the name it's assigned to in ``name``, for use in
33+
:ref:`gen_docs`\ .
34+
* When called as a class attribute, the descriptor returns itself, as done by
35+
e.g. `property`.
36+
"""
37+
38+
def __init__(self):
39+
"""Initialise a BaseDescriptor."""
40+
self._name: str | None = None
41+
self._title: str | None = None
42+
self._description: str | None = None
43+
# We set the instance __doc__ to None so the descriptor class docstring
44+
# doesn't get picked up by OpenAPI/Thing Description.
45+
self.__doc__ = None
46+
47+
def __set_name__(self, owner: type[Thing], name: str) -> None:
48+
r"""Take note of the name to which the descriptor is assigned.
49+
50+
This is called when the descriptor is assigned to an attribute of a class.
51+
This function just remembers the name, so it can be used in
52+
:ref:`gen_docs`\ .
53+
54+
:param owner: the `.Thing` subclass to which we are being attached.
55+
:param name: the name to which we have been assigned.
56+
"""
57+
# Remember the name to which we're assigned. Accessed by the read only
58+
# property ``name``.
59+
self._name = name
60+
61+
@property
62+
def name(self) -> str:
63+
"""The name of this descriptor.
64+
65+
When the descriptor is assigned to an attribute of a class, we
66+
remember the name of the attribute. There will be some time in
67+
between the descriptor being instantiated and the name being set.
68+
If this is accessed before we know the name, we will raise an exception.
69+
70+
The ``name`` of :ref:`wot_affordances` is used in their URL and in
71+
the :ref:`gen_docs` served by LabThings.
72+
73+
:raises NameNotSetError: if it is accessed before the descriptor
74+
has been notified of its name.
75+
"""
76+
if self._name is None:
77+
raise NameNotSetError
78+
return self._name
79+
80+
@property
81+
def title(self) -> str:
82+
"""A human-readable title for the descriptor.
83+
84+
The :ref:`wot_td` requires a human-readable title for all
85+
:ref:`wot_affordances` described. This property will generate a
86+
suitable string from either the name or the docstring.
87+
88+
The title is either the first line of the docstring, or the name
89+
of the descriptor. Note that, if there's no summary line in the
90+
descriptor's instance docstring, or if ``__set__name__`` has not
91+
yet been called (i.e. if this attribute is accessed before the
92+
class on which the descriptor is defined has been fully set up),
93+
the `.NameNotSetError` from ``self.name`` will propagate, i.e.
94+
this property will either return a string or fail with an
95+
exception.
96+
97+
Note also that, if the docstring for this descriptor is defined
98+
on the class rather than passed in (via a getter function or
99+
action function's docstring), it will also not be available until
100+
after ``__set_name__`` has been called.
101+
"""
102+
if not self._title:
103+
# First, try to retrieve the first line of the docstring.
104+
# This is the preferred option for the title.
105+
self._title = get_summary(self)
106+
if not self._title:
107+
# If there's no docstring, or it doesn't have a summary line,
108+
# use the name of the descriptor instead.
109+
# Note that this will either succeed or raise an exception.
110+
self._title = self.name
111+
return self._title
112+
113+
@property
114+
def description(self) -> str | None:
115+
"""A description of the descriptor for use in documentation.
116+
117+
This property will return the docstring describing the descriptor.
118+
As the first line of the docstring (if present) is used as the
119+
``title`` in :ref:`gen_docs` it will be removed from this property.
120+
"""
121+
122+
# I have ignored D105 (missing docstrings) on the overloads - these should not
123+
# exist on @overload definitions.
124+
@overload
125+
def __get__(self, obj: Thing, type: type | None = None) -> Value: ... # noqa: D105
126+
127+
@overload
128+
def __get__(self, type: type) -> Self: ... # noqa: D105
129+
130+
def __get__(self, obj: Thing | None, type: type | None = None) -> Value | Self:
131+
"""Return the value or the descriptor, as per `property`.
132+
133+
If ``obj`` is ``None`` (i.e. the descriptor is accessed as a class attribute),
134+
we return the descriptor, i.e. ``self``.
135+
136+
If ``obj`` is not ``None``, we return a value. To remove the need for this
137+
boilerplate in every subclass, we will call ``__instance_get__`` to get the
138+
value.
139+
140+
:param obj: the `.Thing` instance to which we are attached.
141+
:param type: the `.Thing` subclass on which we are defined.
142+
143+
:return: the value of the descriptor returned from ``__instance_get__`` when
144+
accessed on an instance, or the descriptor object if accessed on a class.
145+
"""
146+
if obj is not None:
147+
return self.instance_get(obj, type)
148+
return self
149+
150+
def instance_get(self, obj: Thing) -> Value:
151+
"""Return the value of the descriptor.
152+
153+
This method is called from ``__get__`` if the descriptor is accessed as an
154+
instance attribute. This means that ``obj`` is guaranteed to be present.
155+
156+
``__get__`` may be called on either an instance or a class, and if it is
157+
called on the class, the convention is that we should return the descriptor
158+
object (i.e. ``self``), as done by `builtins.property`.
159+
160+
`.BaseDescriptor.__get__` takes care of this logic, so we need only consider
161+
the case where we are called as an instance attribute. This simplifies type
162+
annotations and removes the need for overload definitions in every subclass.
163+
164+
:param obj: is the `.Thing` instance on which this descriptor is being
165+
accessed.
166+
:return: the value of the descriptor (i.e. property value, or bound method).
167+
168+
:raises NotImplementedError: if it is not overridden.
169+
"""
170+
raise NotImplementedError(
171+
"__instance_get__ must be defined on BaseDescriptor subclasses. \n\n"
172+
"See BaseDescriptor.__instance_get__ for details."
173+
)

0 commit comments

Comments
 (0)