labthings-fastapi 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/PKG-INFO +3 -2
  2. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/pyproject.toml +15 -2
  3. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/__init__.py +7 -0
  4. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/actions.py +136 -55
  5. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/base_descriptor.py +61 -157
  6. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/client/__init__.py +9 -9
  7. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/endpoints.py +18 -18
  8. labthings_fastapi-0.2.0/src/labthings_fastapi/exceptions.py +383 -0
  9. labthings_fastapi-0.2.0/src/labthings_fastapi/global_lock.py +79 -0
  10. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/invocation_contexts.py +2 -2
  11. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/logs.py +2 -2
  12. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/outputs/blob.py +1 -1
  13. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/outputs/mjpeg_stream.py +10 -10
  14. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/properties.py +498 -95
  15. labthings_fastapi-0.2.0/src/labthings_fastapi/server/__init__.py +530 -0
  16. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/server/cli.py +13 -28
  17. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/server/config_model.py +74 -19
  18. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/testing.py +44 -9
  19. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/thing.py +51 -26
  20. labthings_fastapi-0.2.0/src/labthings_fastapi/thing_class_settings.py +107 -0
  21. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/thing_description/__init__.py +1 -1
  22. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/thing_server_interface.py +70 -8
  23. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/thing_slots.py +48 -48
  24. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/utilities/__init__.py +6 -6
  25. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/utilities/introspection.py +2 -2
  26. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/websockets.py +4 -4
  27. labthings_fastapi-0.1.0/src/labthings_fastapi/exceptions.py +0 -204
  28. labthings_fastapi-0.1.0/src/labthings_fastapi/server/__init__.py +0 -366
  29. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/.gitignore +0 -0
  30. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/LICENSE +0 -0
  31. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/README.md +0 -0
  32. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/example_things/__init__.py +0 -0
  33. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/invocations.py +0 -0
  34. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/middleware/__init__.py +0 -0
  35. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/middleware/url_for.py +0 -0
  36. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/notifications.py +0 -0
  37. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/outputs/__init__.py +0 -0
  38. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/py.typed +0 -0
  39. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/server/fallback.html.jinja +0 -0
  40. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/server/fallback.py +0 -0
  41. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/thing_description/_model.py +0 -0
  42. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/thing_description/td-json-schema-validation.json +0 -0
  43. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/thing_description/validation.py +0 -0
  44. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/types/__init__.py +0 -0
  45. {labthings_fastapi-0.1.0 → labthings_fastapi-0.2.0}/src/labthings_fastapi/types/numpy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labthings-fastapi
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: An implementation of LabThings using FastAPI
5
5
  Project-URL: Homepage, https://github.com/labthings/labthings-fastapi
6
6
  Project-URL: Bug Tracker, https://github.com/labthings/labthings-fastapi/issues
@@ -15,7 +15,7 @@ Requires-Dist: fastapi[all]~=0.135.0
15
15
  Requires-Dist: httpx
16
16
  Requires-Dist: jsonschema
17
17
  Requires-Dist: numpy>=1.20
18
- Requires-Dist: pydantic~=2.12
18
+ Requires-Dist: pydantic~=2.13
19
19
  Requires-Dist: typing-extensions
20
20
  Requires-Dist: zeroconf>=0.28.0
21
21
  Provides-Extra: dev
@@ -35,6 +35,7 @@ Requires-Dist: sphinx-autoapi; extra == 'dev'
35
35
  Requires-Dist: sphinx-rtd-theme; extra == 'dev'
36
36
  Requires-Dist: sphinx-toolbox; extra == 'dev'
37
37
  Requires-Dist: sphinx>=7.2; extra == 'dev'
38
+ Requires-Dist: sphobjinv; extra == 'dev'
38
39
  Requires-Dist: tomli; (python_version < '3.11') and extra == 'dev'
39
40
  Requires-Dist: types-jsonschema; extra == 'dev'
40
41
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "labthings-fastapi"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  authors = [
5
5
  { name="Richard Bowman", email="richard.bowman@cantab.net" },
6
6
  ]
@@ -13,7 +13,7 @@ classifiers = [
13
13
  "Operating System :: OS Independent",
14
14
  ]
15
15
  dependencies = [
16
- "pydantic ~= 2.12",
16
+ "pydantic ~= 2.13",
17
17
  "numpy>=1.20",
18
18
  "jsonschema",
19
19
  "typing_extensions",
@@ -41,6 +41,7 @@ dev = [
41
41
  "sphinx>=7.2",
42
42
  "sphinx-autoapi",
43
43
  "sphinx-toolbox",
44
+ "sphobjinv",
44
45
  "tomli; python_version < '3.11'",
45
46
  "codespell",
46
47
  ]
@@ -73,6 +74,18 @@ addopts = [
73
74
  markers = [
74
75
  "slow: marks tests as slow (deselect with '-m \"not slow\"')",
75
76
  ]
77
+ filterwarnings = [
78
+ # This deliberately doesn't filter by class, because doing so confuses
79
+ # coverage - it results in labthings_fastapi being imported before
80
+ # the coverage plugin.
81
+ # Instead, we simply filter by message.
82
+ #
83
+ # There are many, many small Thing definitions in the test suite.
84
+ # These don't all opt in to new features for reasons of brevity: the
85
+ # DeprecationWarnings this would generate are unhelpful, so we suppress them
86
+ # with the line below.
87
+ "ignore:.get_validate_properties_on_set. will become .True. by default:DeprecationWarning",
88
+ ]
76
89
 
77
90
  [tool.ruff]
78
91
  target-version = "py310"
@@ -13,15 +13,21 @@ imports and is intended to be imported using:
13
13
 
14
14
  import labthings_fastapi as lt
15
15
 
16
+
17
+ The most important symbols are described in `lt` with links to the full API
18
+ documentation as appropriate.
19
+
16
20
  The example code elsewhere in the documentation generally follows this
17
21
  convention. Symbols in the top-level module mostly exist elsewhere in
18
22
  the package, but should be imported from here as a preference, to ensure
19
23
  code does not break if modules are rearranged.
24
+
20
25
  """
21
26
 
22
27
  from .thing import Thing
23
28
  from .thing_slots import thing_slot
24
29
  from .thing_server_interface import ThingServerInterface
30
+ from .thing_class_settings import ThingClassSettings
25
31
  from .properties import property, setting, DataProperty, DataSetting
26
32
  from .actions import action
27
33
  from .endpoints import endpoint
@@ -45,6 +51,7 @@ from .invocation_contexts import (
45
51
  __all__ = [
46
52
  "Thing",
47
53
  "ThingServerInterface",
54
+ "ThingClassSettings",
48
55
  "property",
49
56
  "setting",
50
57
  "DataProperty",
@@ -1,6 +1,6 @@
1
1
  """Actions module.
2
2
 
3
- :ref:`actions` are represented by methods, decorated with the `.action`
3
+ :ref:`actions` are represented by methods, decorated with the `lt.action`
4
4
  decorator.
5
5
 
6
6
  See the :ref:`actions` documentation for a top-level overview of actions in
@@ -9,16 +9,18 @@ LabThings-FastAPI.
9
9
  Developer notes
10
10
  ---------------
11
11
 
12
- Currently much of the code related to Actions is in `.action` and the
12
+ Currently much of the code related to Actions is in `lt.action` and the
13
13
  underlying `.ActionDescriptor`. This is likely to be refactored in the near
14
14
  future.
15
15
  """
16
16
 
17
17
  from __future__ import annotations
18
+ from collections.abc import Iterator
19
+ from contextlib import contextmanager
18
20
  import datetime
19
21
  import logging
20
22
  from collections import deque
21
- from functools import partial
23
+ from functools import partial, wraps
22
24
  import inspect
23
25
  from threading import Thread, Lock
24
26
  import uuid
@@ -29,6 +31,7 @@ from typing import (
29
31
  Callable,
30
32
  Concatenate,
31
33
  Generic,
34
+ Literal,
32
35
  Optional,
33
36
  ParamSpec,
34
37
  TypeVar,
@@ -36,9 +39,10 @@ from typing import (
36
39
  )
37
40
  from weakref import WeakSet
38
41
  import weakref
39
- from fastapi import FastAPI, HTTPException, Request, Body, BackgroundTasks
42
+ from fastapi import APIRouter, FastAPI, HTTPException, Request, Body, BackgroundTasks
40
43
  from pydantic import BaseModel, create_model
41
44
 
45
+
42
46
  from .middleware.url_for import URLFor
43
47
  from .base_descriptor import (
44
48
  BaseDescriptor,
@@ -49,6 +53,7 @@ from .logs import add_thing_log_destination
49
53
  from .utilities import model_to_dict, wrap_plain_types_in_rootmodel
50
54
  from .invocations import InvocationModel, InvocationStatus
51
55
  from .exceptions import (
56
+ GlobalLockBusyError,
52
57
  InvocationCancelledError,
53
58
  InvocationError,
54
59
  NotConnectedToServerError,
@@ -71,7 +76,7 @@ if TYPE_CHECKING:
71
76
  from .thing import Thing
72
77
 
73
78
 
74
- __all__ = ["ACTION_INVOCATIONS_PATH", "Invocation", "ActionManager"]
79
+ __all__ = ["Invocation", "ActionManager"]
75
80
 
76
81
 
77
82
  ACTION_INVOCATIONS_PATH = "/action_invocations"
@@ -84,7 +89,7 @@ class Invocation(Thread):
84
89
  `.Invocation` threads add several bits of functionality compared to the base
85
90
  `threading.Thread`.
86
91
 
87
- * They are instantiated with an `.ActionDescriptor` and a `.Thing`
92
+ * They are instantiated with an `.ActionDescriptor` and a `~lt.Thing`
88
93
  rather than a target function (see ``__init__``).
89
94
  * Each invocation is assigned a unique ``ID`` to allow it to be polled
90
95
  over HTTP.
@@ -105,7 +110,7 @@ class Invocation(Thread):
105
110
 
106
111
  :param action: provides the function that we run, as well as metadata
107
112
  and type information. The descriptor is not bound to an object, so we
108
- supply the `.Thing` it's bound to when the function is run.
113
+ supply the `~lt.Thing` it's bound to when the function is run.
109
114
  :param thing: is the object on which we are running the ``action``, i.e.
110
115
  it is supplied to the function wrapped by ``action`` as the ``self``
111
116
  argument.
@@ -187,7 +192,7 @@ class Invocation(Thread):
187
192
 
188
193
  @property
189
194
  def thing(self) -> Thing:
190
- """The `.Thing` to which the action is bound, i.e. this is ``self``.
195
+ """The `~lt.Thing` to which the action is bound, i.e. this is ``self``.
191
196
 
192
197
  :raises RuntimeError: if the Thing no longer exists.
193
198
  """
@@ -250,7 +255,7 @@ class Invocation(Thread):
250
255
 
251
256
  The code to be run is the function wrapped in the `.ActionDescriptor`
252
257
  that is passed in as ``action``. Its arguments are the associated
253
- `.Thing` (the first argument, i.e. ``self``), the ``input`` model
258
+ `~lt.Thing` (the first argument, i.e. ``self``), the ``input`` model
254
259
  (split into keyword arguments for each field), and any ``dependencies``
255
260
  (also as keyword arguments).
256
261
 
@@ -287,19 +292,22 @@ class Invocation(Thread):
287
292
  # occur.
288
293
  raise RuntimeError("Cannot start an invocation without a Thing.")
289
294
 
290
- with self._status_lock:
291
- self._status = InvocationStatus.RUNNING
292
- self._start_time = datetime.datetime.now()
293
- action.emit_changed_event(self.thing, self._status.value)
295
+ # The action's `context_for_func` context manager will acquire the
296
+ # global lock if needed.
297
+ with action.context_for_func(thing):
298
+ with self._status_lock:
299
+ self._status = InvocationStatus.RUNNING
300
+ self._start_time = datetime.datetime.now()
301
+ action.emit_changed_event(self.thing, self._status.value)
294
302
 
295
- bound_method = action.__get__(thing)
296
- # Actually run the action
297
- ret = bound_method(**kwargs, **self.dependencies)
303
+ # Actually run the action
304
+ ret = action.func(thing, **kwargs, **self.dependencies)
305
+
306
+ with self._status_lock:
307
+ self._return_value = ret
308
+ self._status = InvocationStatus.COMPLETED
309
+ action.emit_changed_event(self.thing, self._status.value)
298
310
 
299
- with self._status_lock:
300
- self._return_value = ret
301
- self._status = InvocationStatus.COMPLETED
302
- action.emit_changed_event(self.thing, self._status.value)
303
311
  except InvocationCancelledError:
304
312
  logger.info(f"Invocation {self.id} was cancelled.")
305
313
  with self._status_lock:
@@ -308,9 +316,17 @@ class Invocation(Thread):
308
316
  except Exception as e: # skipcq: PYL-W0703
309
317
  # First log
310
318
  if isinstance(e, InvocationError):
311
- # Log without traceback
319
+ # Log without traceback for anticipated errors
312
320
  logger.error(e)
321
+ elif (
322
+ isinstance(e, GlobalLockBusyError)
323
+ and self._status == InvocationStatus.PENDING
324
+ ):
325
+ # The global lock timed out before the function started.
326
+ # In this case, don't print a traceback.
327
+ logger.warning(f"Global lock was busy: didn't run {action.name}.")
313
328
  else:
329
+ # Other exceptions show up in the log with a traceback
314
330
  logger.exception(e)
315
331
  # Then set status
316
332
  with self._status_lock:
@@ -360,7 +376,7 @@ class ActionManager:
360
376
 
361
377
  :param action: provides the function that we run, as well as metadata
362
378
  and type information. The descriptor is not bound to an object, so we
363
- supply the `.Thing` it's bound to when the function is run.
379
+ supply the `~lt.Thing` it's bound to when the function is run.
364
380
  :param thing: is the object on which we are running the ``action``, i.e.
365
381
  it is supplied to the function wrapped by ``action`` as the ``self``
366
382
  argument.
@@ -407,8 +423,8 @@ class ActionManager:
407
423
  :param action: filters out only the invocations of a particular
408
424
  `.ActionDescriptor`. Note that if there are two Things
409
425
  of the same subclass, filtering by action will return invocations
410
- on either `.Thing`.
411
- :param thing: returns only invocations of actions on a particular `.Thing`.
426
+ on either `~lt.Thing`.
427
+ :param thing: returns only invocations of actions on a particular `~lt.Thing`.
412
428
  This will often be combined with filtering by ``action`` to give the
413
429
  list of invocations returned by a GET request on an action endpoint.
414
430
  :param request: is used to pass a `fastapi.Request` object to the
@@ -438,17 +454,18 @@ class ActionManager:
438
454
  for k in to_delete:
439
455
  del self._invocations[k]
440
456
 
441
- def attach_to_app(self, app: FastAPI) -> None:
442
- """Add /action_invocations and /action_invocation/{id} endpoints to FastAPI.
457
+ def router(self) -> APIRouter:
458
+ """Create a FastAPI Router with action-related endpoints.
443
459
 
444
- :param app: The `fastapi.FastAPI` application to which we add the endpoints.
460
+ :return: a Router with all action-related endpoints.
445
461
  """
462
+ router = APIRouter()
446
463
 
447
- @app.get(ACTION_INVOCATIONS_PATH, response_model=list[InvocationModel])
464
+ @router.get(ACTION_INVOCATIONS_PATH, response_model=list[InvocationModel])
448
465
  def list_all_invocations(request: Request) -> list[InvocationModel]:
449
466
  return self.list_invocations(request=request)
450
467
 
451
- @app.get(
468
+ @router.get(
452
469
  ACTION_INVOCATIONS_PATH + "/{id}",
453
470
  responses={404: {"description": "Invocation ID not found"}},
454
471
  )
@@ -473,7 +490,7 @@ class ActionManager:
473
490
  detail="No action invocation found with ID {id}",
474
491
  ) from e
475
492
 
476
- @app.get(
493
+ @router.get(
477
494
  ACTION_INVOCATIONS_PATH + "/{id}/output",
478
495
  response_model=Any,
479
496
  responses={
@@ -521,7 +538,7 @@ class ActionManager:
521
538
  return invocation.output.response()
522
539
  return invocation.output
523
540
 
524
- @app.delete(
541
+ @router.delete(
525
542
  ACTION_INVOCATIONS_PATH + "/{id}",
526
543
  response_model=None,
527
544
  responses={
@@ -561,6 +578,8 @@ class ActionManager:
561
578
  )
562
579
  invocation.cancel()
563
580
 
581
+ return router
582
+
564
583
 
565
584
  ACTION_POST_NOTICE = """
566
585
  ## Important note
@@ -651,9 +670,9 @@ class ActionDescriptor(
651
670
  .. note::
652
671
  Descriptors are instantiated once per class. This means that we cannot
653
672
  assume there is only one action corresponding to this descriptor: there
654
- may be multiple `.Thing` instances with the same descriptor. That is
655
- why the host `.Thing` must be passed to many functions as an argument,
656
- and why observers, for example, must be keyed by the `.Thing` rather
673
+ may be multiple `~lt.Thing` instances with the same descriptor. That is
674
+ why the host `~lt.Thing` must be passed to many functions as an argument,
675
+ and why observers, for example, must be keyed by the `~lt.Thing` rather
657
676
  than kept as a property of ``self``.
658
677
  """
659
678
 
@@ -662,10 +681,11 @@ class ActionDescriptor(
662
681
  func: Callable[Concatenate[OwnerT, ActionParams], ActionReturn],
663
682
  response_timeout: float = 1,
664
683
  retention_time: float = 300,
684
+ use_global_lock: Literal[False] | None = None,
665
685
  ) -> None:
666
- """Create a new action descriptor.
686
+ r"""Create a new action descriptor.
667
687
 
668
- The action descriptor wraps a method of a `.Thing`. It may still be
688
+ The action descriptor wraps a method of a `~lt.Thing`. It may still be
669
689
  called from Python in the same way, but it will also be added to the
670
690
  HTTP API and automatic documentation.
671
691
 
@@ -680,6 +700,16 @@ class ActionDescriptor(
680
700
  of the action.
681
701
  :param retention_time: how long, in seconds, the action should be kept
682
702
  for after it has completed.
703
+ :param use_global_lock: If the global lock is enabled,
704
+ this parameter may be used to opt out. See :ref:`global_locking`
705
+ for details of how the global lock is implemented.
706
+
707
+ If this parameter is `False` then the lock will not be acquired, even
708
+ if global locking is enabled. That is appropriate if the action does
709
+ not have side effects that would cause problems for other actions, or
710
+ if more nuanced locking behaviour is required meaning the lock is
711
+ acquired directly in the action code, for example using
712
+ `~lt.ThingServerInterface.hold_global_lock`\ .
683
713
  """
684
714
  super().__init__()
685
715
  self.func = func
@@ -689,6 +719,7 @@ class ActionDescriptor(
689
719
  name = func.__name__ # this is checked in __set_name__
690
720
  self.response_timeout = response_timeout
691
721
  self.retention_time = retention_time
722
+ self.use_global_lock = use_global_lock
692
723
  self.dependency_params = fastapi_dependency_params(func)
693
724
  self.input_model = input_model_from_signature(
694
725
  func,
@@ -722,28 +753,78 @@ class ActionDescriptor(
722
753
  f"'{self.func.__name__}'",
723
754
  )
724
755
 
756
+ @contextmanager
757
+ def context_for_func(self, obj: OwnerT) -> Iterator[None]:
758
+ """Create the context in which ``func`` runs.
759
+
760
+ Currently, if global locking is enabled and this action hasn't opted out,
761
+ this context manager will hold the global lock for the duration of the
762
+ action.
763
+
764
+ This method is intended to create a hook for pre-run set-up and post-run
765
+ clean-up code that may be customised by `Thing` implementations in the future,
766
+ such as acquiring locks or other resources.
767
+
768
+ When an action is run from Python code as ``thing.action()`` this context
769
+ manager is entered before executing `func` bound to the `Thing` instance.
770
+
771
+ When an action is run from HTTP, this context manager is entered while the
772
+ action's status is ``pending`` and the status changes to ``running`` just
773
+ before `func` (the function decorated by `~lt.action`) runs. This allows
774
+ some slightly nicer error handling, for example not cluttering the log with
775
+ stack traces if an action can't start because the global lock is in use.
776
+
777
+ :param obj: The object on which the method is being called.
778
+ :return: the function, wrapped if necessary.
779
+ """
780
+ with obj._thing_server_interface._optionally_hold_global_lock(
781
+ self.use_global_lock
782
+ ):
783
+ yield
784
+
725
785
  def instance_get(self, obj: OwnerT) -> Callable[ActionParams, ActionReturn]:
726
- """Return the function, bound to an object as for a normal method.
786
+ """Return the function, bound to an object and wrapped in a context manager.
787
+
788
+ Accessing a regular Python method returns the method bound to the instance,
789
+ i.e. the `self` argument is supplied.
727
790
 
728
- This currently doesn't validate the arguments, though it may do so
729
- in future. In its present form, this is equivalent to a regular
730
- Python method, i.e. all we do is supply the first argument, `self`.
791
+ LabThings Actions work the same way, but they also wrap the function in a
792
+ context manager. Currently, this context manager will handle acquiring the
793
+ global lock if required.
731
794
 
732
- :param obj: the `.Thing` to which we are attached. This will be
795
+ If locking is disabled, the context manager does nothing.
796
+ If locking is enabled, we return a wrapped function that holds the
797
+ global lock while the action runs.
798
+
799
+ .. note::
800
+
801
+ The returned function will hold a reference to both `obj` and `self`
802
+ (this descriptor). Given that accessing ``instance.method`` returns
803
+ a function that's already bound to the instance, this shouldn't cause
804
+ any problems.
805
+
806
+ :param obj: the `~lt.Thing` to which we are attached. This will be
733
807
  the first argument supplied to the function wrapped by this
734
808
  descriptor.
735
809
  :return: the action function, bound to ``obj``.
736
810
  """
737
- return partial(self.func, obj)
811
+
812
+ @wraps(self.func)
813
+ def wrapped(*args: Any, **kwargs: Any) -> Any: # noqa: DOC
814
+ """Acquire the lock then run `func` with supplied arguments."""
815
+ with self.context_for_func(obj):
816
+ return self.func(*args, **kwargs)
817
+
818
+ return partial(wrapped, obj)
738
819
 
739
820
  def _observers_set(self, obj: Thing) -> WeakSet:
740
821
  """Return a set used to notify changes.
741
822
 
742
- Note that we need to supply the `.Thing` we are looking at, as in
823
+ Note that we need to supply the `~lt.Thing` we are looking at, as in
743
824
  general there may be more than one object of the same type, and
744
825
  descriptor instances are shared between all instances of their class.
745
826
 
746
- :param obj: The `.Thing` on which the action is being observed.
827
+ :param obj: The `~lt.Thing` on which the action is being observed.
747
828
 
748
829
  :return: a weak set of callables to notify on changes to the action.
749
830
  This is used by websocket endpoints.
@@ -762,7 +843,7 @@ class ActionDescriptor(
762
843
  portal. Async code must not use the blocking portal as it can deadlock
763
844
  the event loop.
764
845
 
765
- :param obj: The `.Thing` on which the action is being observed.
846
+ :param obj: The `~lt.Thing` on which the action is being observed.
766
847
  :param status: The status of the action, to be sent to observers.
767
848
  """
768
849
  obj._thing_server_interface.start_async_task_soon(
@@ -778,10 +859,10 @@ class ActionDescriptor(
778
859
  It will send messages to each observer to notify them that something
779
860
  has changed.
780
861
 
781
- :param obj: The `.Thing` on which the action is defined.
862
+ :param obj: The `~lt.Thing` on which the action is defined.
782
863
  `.ActionDescriptor` objects are unique to the class, but there may
783
- be more than one `.Thing` attached to a server with the same class.
784
- We use ``obj`` to look up the observers of the current `.Thing`.
864
+ be more than one `~lt.Thing` attached to a server with the same class.
865
+ We use ``obj`` to look up the observers of the current `~lt.Thing`.
785
866
  :param value: The action status to communicate to the observers.
786
867
  """
787
868
  action_name = self.name
@@ -801,12 +882,12 @@ class ActionDescriptor(
801
882
  application.
802
883
 
803
884
  :param app: The `fastapi.FastAPI` app to add the endpoint to.
804
- :param thing: The `.Thing` to which the action is attached. Bear in
805
- mind that the descriptor may be used by more than one `.Thing`,
885
+ :param thing: The `~lt.Thing` to which the action is attached. Bear in
886
+ mind that the descriptor may be used by more than one `~lt.Thing`,
806
887
  so this can't be a property of the descriptor.
807
888
 
808
889
  :raises NotConnectedToServerError: if the function is run before the
809
- ``thing`` has a ``path`` property. This is assigned when the `.Thing`
890
+ ``thing`` has a ``path`` property. This is assigned when the `~lt.Thing`
810
891
  is added to a server.
811
892
  """
812
893
 
@@ -901,15 +982,15 @@ class ActionDescriptor(
901
982
 
902
983
  This function describes the Action in :ref:`wot_td` format.
903
984
 
904
- :param thing: The `.Thing` to which the action is attached.
985
+ :param thing: The `~lt.Thing` to which the action is attached.
905
986
  :param path: The prefix applied to all endpoints associated with the
906
- `.Thing`. This is the URL for the Thing Description. If it is
987
+ `~lt.Thing`. This is the URL for the Thing Description. If it is
907
988
  omitted, we use the ``path`` property of the ``thing``.
908
989
 
909
990
  :return: An `.ActionAffordance` describing this action.
910
991
 
911
992
  :raises NotConnectedToServerError: if the function is run before the
912
- ``thing`` has a ``path`` property. This is assigned when the `.Thing`
993
+ ``thing`` has a ``path`` property. This is assigned when the `~lt.Thing`
913
994
  is added to a server.
914
995
  """
915
996
  path = path or thing.path
@@ -968,7 +1049,7 @@ def action(
968
1049
  ActionDescriptor[ActionParams, ActionReturn, OwnerT],
969
1050
  ]
970
1051
  ):
971
- r"""Mark a method of a `.Thing` as a LabThings Action.
1052
+ r"""Mark a method of a `~lt.Thing` as a LabThings Action.
972
1053
 
973
1054
  Methods decorated with :deco:`action` will be available to call
974
1055
  over HTTP as actions. See :ref:`actions` for an introduction to the concept