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
88from __future__ import annotations
1717
1818from .outputs import ClientBlobOutput
1919
20-
20+ __all__ = [ "ThingClient" , "poll_task" ]
2121ACTION_RUNNING_KEYWORDS = ["idle" , "pending" , "running" ]
2222
2323
2424def 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
2929def 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
3434def 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
5172class 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
142230class 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
199322def 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