Skip to content

Commit cc32317

Browse files
committed
Improved docstrings on client module
I've added a `wot_td_` reference, because this needs to be referred to quite frequently.
1 parent c9bd93f commit cc32317

File tree

3 files changed

+176
-30
lines changed

3 files changed

+176
-30
lines changed

docs/source/wot_core_concepts.rst

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,15 @@ Common examples are notifying clients when a Property is changed, or when an Act
4343

4444
A good example of this might be having Things automatically pause data-acquisition Actions upon detection of an overheat or interlock Event from another Thing. Events are not currently implemented in `labthings-fastapi`, but are planned for future releases.
4545

46+
.. _wot_td:
47+
48+
Thing Description
49+
-----------------
50+
51+
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. The WoT_ standard defines the `Thing Description`_ and includes a JSON Schema against which it may be validated.
52+
53+
Thing Description documents are higher-level than OpenAPI_ and focus on the capabilities of the Thing, rather than the HTTP endpoints. In general you would expect more HTTP endpoints than there are interaction affordances, so in principle client code based on a Thing Description should be more meaningful. However, OpenAPI is a much more widely adopted standard and so both forms of documentation are generated by LabThings-FastAPI.
54+
4655
.. _WoT: https://www.w3.org/WoT/
47-
.. _Thing Description: https://www.w3.org/TR/wot-thing-description/
56+
.. _Thing Description: https://www.w3.org/TR/wot-thing-description/
57+
.. _OpenAPI: https://www.openapis.org/

src/labthings_fastapi/actions/invocation_model.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@ class LogRecordModel(BaseModel):
4646
@classmethod
4747
def generate_message(cls, data: Any) -> Any:
4848
"""Ensure LogRecord objects have constructed their message.
49-
49+
5050
:param data: The LogRecord to process.
51-
51+
5252
:return: The LogRecord, with a message constructed.
5353
"""
5454
if not hasattr(data, "message"):

src/labthings_fastapi/client/__init__.py

Lines changed: 163 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
"""A first pass at a client library for LabThings-FastAPI
1+
"""Code to access `.Thing` features over HTTP.
22
3-
This will become its own package if it's any good. The goal is to see if we can
4-
make a client library that produces introspectable Python objects from a Thing
5-
Description.
3+
This module defines a base class for controlling LabThings-FastAPI over HTTP.
4+
It is based on `httpx`, and attempts to create a simple wrapper such that
5+
each Action becomes a method and each Property becomes an attribute.
66
"""
77

88
from __future__ import annotations
@@ -17,27 +17,48 @@
1717

1818
from .outputs import ClientBlobOutput
1919

20-
20+
__all__ = ["ThingClient", "poll_task"]
2121
ACTION_RUNNING_KEYWORDS = ["idle", "pending", "running"]
2222

2323

2424
def get_link(obj: dict, rel: str) -> Mapping:
25-
"""Retrieve a link from an object's `links` list, by its `rel` attribute"""
25+
"""Retrieve a link from an object's `links` list, by its `rel` attribute."""
2626
return next(link for link in obj["links"] if link["rel"] == rel)
2727

2828

2929
def get_link_href(obj: dict, rel: str) -> str:
30-
"""Retrieve the `href` from an object's `links` list, by its `rel` attribute"""
30+
"""Retrieve the `href` from an object's `links` list, by its `rel` attribute."""
3131
return get_link(obj, rel)["href"]
3232

3333

3434
def task_href(t):
35-
"""Extract the endpoint address from a task dictionary"""
35+
"""Extract the endpoint address from a task dictionary."""
3636
return get_link(t, "self")["href"]
3737

3838

39-
def poll_task(client, task, interval=0.5, first_interval=0.05):
40-
"""Poll a task until it finishes, and return the return value"""
39+
def poll_task(
40+
client: httpx.Client,
41+
task: dict,
42+
interval: float = 0.5,
43+
first_interval: float = 0.05,
44+
) -> dict:
45+
"""Poll a task until it finishes, and return the output.
46+
47+
When actions are invoked in a LabThings-FastAPI server, the
48+
initial POST request returns immediately. The returned invocation
49+
includes a link that may be polled to find out when the action
50+
has completed, whether it was successful, and retrieve its
51+
output.
52+
53+
:param client: the `httpx.Client` to use for HTTP requests.
54+
:param task: the dictionary returned from the initial POST request.
55+
:param interval: sets how frequently we poll, in seconds.
56+
:param first_interval: sets how long we wait before the first
57+
polling request. Often, it makes sense for this to be a short
58+
interval, in case the action fails (or returns) immediately.
59+
60+
:return: the completed task as a dictionary.
61+
"""
4162
first_time = True
4263
while task["status"] in ACTION_RUNNING_KEYWORDS:
4364
time.sleep(first_interval if first_time else interval)
@@ -49,30 +70,75 @@ def poll_task(client, task, interval=0.5, first_interval=0.05):
4970

5071

5172
class ThingClient:
52-
"""A client for a LabThings-FastAPI Thing
73+
"""A client for a LabThings-FastAPI Thing.
74+
75+
.. note::
76+
ThingClient must be subclassed to add actions/properties,
77+
so this class will be minimally useful on its own.
5378
54-
NB ThingClient must be subclassed to add actions/properties,
55-
so this class will be minimally useful on its own.
79+
The best way to get a client for a particular Thing is
80+
currently `.ThingClient.from_url`, which dynamically
81+
creates a subclass with the right attributes.
5682
"""
5783

5884
def __init__(self, base_url: str, client: Optional[httpx.Client] = None):
85+
"""Create a ThingClient connected to a remote Thing.
86+
87+
:param base_url: the base URL of the Thing. This should be the URL
88+
of the Thing Description document.
89+
:param client: an optional `httpx.Client` object to use for all
90+
HTTP requests. This may be a `fastapi.TestClient` object for
91+
testing purposes.
92+
"""
5993
parsed = urlparse(base_url)
6094
server = f"{parsed.scheme}://{parsed.netloc}"
6195
self.server = server
6296
self.path = parsed.path
6397
self.client = client or httpx.Client(base_url=server)
6498

6599
def get_property(self, path: str) -> Any:
100+
"""Make a GET request to retrieve the value of a property.
101+
102+
:param path: the URI of the ``getproperty`` endpoint, relative
103+
to the ``base_url``.
104+
105+
:return: the property's value, as deserialised from JSON.
106+
"""
66107
r = self.client.get(urljoin(self.path, path))
67108
r.raise_for_status()
68109
return r.json()
69110

70111
def set_property(self, path: str, value: Any):
112+
"""Make a PUT request to set the value of a property.
113+
114+
:param path: the URI of the ``getproperty`` endpoint, relative
115+
to the ``base_url``.
116+
:param value: the property's value. Currently this must be
117+
serialisable to JSON.
118+
"""
71119
r = self.client.put(urljoin(self.path, path), json=value)
72120
r.raise_for_status()
73121

74122
def invoke_action(self, path: str, **kwargs):
75-
"Invoke an action on the Thing"
123+
"""Invoke an action on the Thing.
124+
125+
This method will make the initial POST request to invoke an action,
126+
then poll the resulting invocation until it completes. If successful,
127+
the action's output will be returned directly.
128+
129+
While the action is running, log messages will be re-logged locally.
130+
If you have enabled logging to the console, these should be visible.
131+
132+
:param path: the URI of the ``invokeaction`` endpoint, relative to the
133+
``base_url``
134+
:param **kwargs: Additional arguments will be combined into the JSON
135+
body of the ``POST`` request and sent as input to the action.
136+
These will be validated on the server.
137+
138+
:return: the output value of the action.
139+
140+
:raises RuntimeError: is raised if the action does not complete successfully.
141+
"""
76142
for k in kwargs.keys():
77143
if isinstance(kwargs[k], ClientBlobOutput):
78144
kwargs[k] = {"href": kwargs[k].href, "media_type": kwargs[k].media_type}
@@ -95,33 +161,55 @@ def invoke_action(self, path: str, **kwargs):
95161
raise RuntimeError(f"Action did not complete successfully: {task}")
96162

97163
def follow_link(self, response: dict, rel: str) -> httpx.Response:
98-
"""Follow a link in a response object, by its `rel` attribute"""
164+
"""Follow a link in a response object, by its `rel` attribute.
165+
166+
:param response: is the dictionary returned by e.g. `.poll_task`.
167+
:param rel: picks the link to follow by matching its ``rel``
168+
item.
169+
170+
:return: the response to making a ``GET`` request to the link.
171+
"""
99172
href = get_link_href(response, rel)
100173
r = self.client.get(href)
101174
r.raise_for_status()
102175
return r
103176

104177
@classmethod
105-
def from_url(
106-
cls, thing_url: str, client: Optional[httpx.Client] = None, **kwargs
107-
) -> Self:
108-
"""Create a ThingClient from a URL
178+
def from_url(cls, thing_url: str, client: Optional[httpx.Client] = None) -> Self:
179+
"""Create a ThingClient from a URL.
109180
110181
This will dynamically create a subclass with properties and actions,
111182
and return an instance of that subclass pointing at the Thing URL.
112183
113-
Additional `kwargs` will be passed to the subclass constructor, in
114-
particular you may pass a `client` object (useful for testing).
184+
:param thing_url: The base URL of the Thing, which should also be the
185+
URL of its Thing Description.
186+
:param client: is an optional `httpx.Client` object. If not present,
187+
one will be created. This is particularly useful if you need to
188+
set HTTP options, or if you want to work with a local server
189+
object for testing purposes (see `fastapi.TestClient`).
190+
191+
:return: a `.ThingClient` subclass with properties and methods that
192+
match the retrieved Thing Description (see :ref:`wot_thing`).
115193
"""
116194
td_client = client or httpx
117195
r = td_client.get(thing_url)
118196
r.raise_for_status()
119197
subclass = cls.subclass_from_td(r.json())
120-
return subclass(thing_url, client=client, **kwargs)
198+
return subclass(thing_url, client=client)
121199

122200
@classmethod
123201
def subclass_from_td(cls, thing_description: dict) -> type[Self]:
124-
"""Create a ThingClient subclass from a Thing Description"""
202+
"""Create a ThingClient subclass from a Thing Description.
203+
204+
Dynamically subclass `.ThingClient` to add properties and
205+
methods for each property and action in the Thing Description.
206+
207+
:param thing_description: A wot_td_ as a dictionary, which will
208+
be used to construct the class.
209+
210+
:return: a `.ThingClient` subclass with the right properties and
211+
methods.
212+
"""
125213
my_thing_description = thing_description
126214

127215
class Client(cls): # type: ignore[valid-type, misc]
@@ -140,6 +228,8 @@ class Client(cls): # type: ignore[valid-type, misc]
140228

141229

142230
class PropertyClientDescriptor:
231+
"""A base class for properties on `.ThingClient` objects."""
232+
143233
pass
144234

145235

@@ -151,7 +241,28 @@ def property_descriptor(
151241
writeable: bool = True,
152242
property_path: Optional[str] = None,
153243
) -> PropertyClientDescriptor:
154-
"""Create a correctly-typed descriptor that gets and/or sets a property"""
244+
"""Create a correctly-typed descriptor that gets and/or sets a property.
245+
246+
The returned `.PropertyClientDescriptor` will have ``__get__`` and
247+
(optionally) ``__set__`` methods that are typed according to the
248+
supplied ``model``. The descriptor should be added to a `.ThingClient`
249+
subclass and used to access the relevant property via
250+
`.ThingClient.get_property` and `.ThingClient.set_property`.
251+
252+
:param property_name: should be the name of the property (i.e. the
253+
name it takes in the thing description, and also the name it is
254+
assigned to in the class).
255+
:param model: the Python ``type`` or a ``pydantic.BaseModel`` that
256+
represents the datatype of the property.
257+
:param description: text to use for a docstring.
258+
:param readable: whether the property may be read (i.e. has ``__get__``).
259+
:param writeable: whether the property may be written to.
260+
:param property_path: the URL of the ``getproperty`` and ``setproperty``
261+
HTTP endpoints. Currently these must both be the same. These are
262+
relative to the ``base_url``, i.e. the URL of the Thing Description.
263+
264+
:return: a descriptor allowing access to the specified property.
265+
"""
155266

156267
class P(PropertyClientDescriptor):
157268
name = property_name
@@ -183,8 +294,20 @@ def __set__(self, obj: ThingClient, value: Any):
183294
return P()
184295

185296

186-
def add_action(cls: type[ThingClient], action_name: str, action: dict):
187-
"""Add an action to a ThingClient subclass"""
297+
def add_action(cls: type[ThingClient], action_name: str, action: dict) -> None:
298+
"""Add an action to a ThingClient subclass.
299+
300+
A method will be added to the class that calls the provided action.
301+
Currently, this will have a return type hint but no argument names
302+
or type hints.
303+
304+
:param cls: the `.ThingClient` subclass to which we are adding the
305+
action.
306+
:param action_name: is both the name we assign the method to, and
307+
the name of the action in the Thing Description.
308+
:param action: a dictionary representing the action, in wot_td_
309+
format.
310+
"""
188311

189312
def action_method(self, **kwargs):
190313
return self.invoke_action(action_name, **kwargs)
@@ -197,7 +320,20 @@ def action_method(self, **kwargs):
197320

198321

199322
def add_property(cls: type[ThingClient], property_name: str, property: dict):
200-
"""Add a property to a ThingClient subclass"""
323+
"""Add a property to a ThingClient subclass.
324+
325+
A descriptor will be added to the provided class that makes the
326+
attribute ``property_name`` get and/or set the property described
327+
by the ``property`` dictionary.
328+
329+
330+
:param cls: the `.ThingClient` subclass to which we are adding the
331+
property.
332+
:param property_name: is both the name we assign the descriptor to, and
333+
the name of the property in the Thing Description.
334+
:param property: a dictionary representing the property, in wot_td_
335+
format.
336+
"""
201337
setattr(
202338
cls,
203339
property_name,

0 commit comments

Comments
 (0)