labthings-fastapi 0.0.17__tar.gz → 0.1.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 (50) hide show
  1. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/PKG-INFO +3 -3
  2. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/pyproject.toml +3 -3
  3. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/__init__.py +0 -2
  4. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/actions.py +1 -7
  5. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/base_descriptor.py +1 -1
  6. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/logs.py +10 -4
  7. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/outputs/blob.py +1 -36
  8. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/server/__init__.py +10 -5
  9. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/server/cli.py +7 -1
  10. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/utilities/__init__.py +4 -9
  11. labthings_fastapi-0.0.17/src/labthings_fastapi/client/in_server.py +0 -344
  12. labthings_fastapi-0.0.17/src/labthings_fastapi/dependencies/__init__.py +0 -15
  13. labthings_fastapi-0.0.17/src/labthings_fastapi/dependencies/blocking_portal.py +0 -77
  14. labthings_fastapi-0.0.17/src/labthings_fastapi/dependencies/invocation.py +0 -160
  15. labthings_fastapi-0.0.17/src/labthings_fastapi/dependencies/metadata.py +0 -92
  16. labthings_fastapi-0.0.17/src/labthings_fastapi/dependencies/raw_thing.py +0 -130
  17. labthings_fastapi-0.0.17/src/labthings_fastapi/dependencies/thing.py +0 -58
  18. labthings_fastapi-0.0.17/src/labthings_fastapi/dependencies/thing_server.py +0 -96
  19. labthings_fastapi-0.0.17/src/labthings_fastapi/deps.py +0 -30
  20. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/.gitignore +0 -0
  21. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/LICENSE +0 -0
  22. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/README.md +0 -0
  23. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/client/__init__.py +0 -0
  24. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/endpoints.py +0 -0
  25. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/example_things/__init__.py +0 -0
  26. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/exceptions.py +0 -0
  27. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/invocation_contexts.py +0 -0
  28. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/invocations.py +0 -0
  29. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/middleware/__init__.py +0 -0
  30. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/middleware/url_for.py +0 -0
  31. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/notifications.py +0 -0
  32. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/outputs/__init__.py +0 -0
  33. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/outputs/mjpeg_stream.py +0 -0
  34. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/properties.py +0 -0
  35. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/py.typed +0 -0
  36. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/server/config_model.py +0 -0
  37. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/server/fallback.html.jinja +0 -0
  38. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/server/fallback.py +0 -0
  39. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/testing.py +0 -0
  40. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/thing.py +0 -0
  41. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/thing_description/__init__.py +0 -0
  42. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/thing_description/_model.py +0 -0
  43. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/thing_description/td-json-schema-validation.json +0 -0
  44. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/thing_description/validation.py +0 -0
  45. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/thing_server_interface.py +0 -0
  46. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/thing_slots.py +0 -0
  47. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/types/__init__.py +0 -0
  48. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/types/numpy.py +0 -0
  49. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/utilities/introspection.py +0 -0
  50. {labthings_fastapi-0.0.17 → labthings_fastapi-0.1.0}/src/labthings_fastapi/websockets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labthings-fastapi
3
- Version: 0.0.17
3
+ Version: 0.1.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
@@ -11,11 +11,11 @@ Classifier: Operating System :: OS Independent
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: >=3.10
13
13
  Requires-Dist: anyio~=4.0
14
- Requires-Dist: fastapi[all]>=0.115.0
14
+ 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.10.6
18
+ Requires-Dist: pydantic~=2.12
19
19
  Requires-Dist: typing-extensions
20
20
  Requires-Dist: zeroconf>=0.28.0
21
21
  Provides-Extra: dev
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "labthings-fastapi"
3
- version = "0.0.17"
3
+ version = "0.1.0"
4
4
  authors = [
5
5
  { name="Richard Bowman", email="richard.bowman@cantab.net" },
6
6
  ]
@@ -13,13 +13,13 @@ classifiers = [
13
13
  "Operating System :: OS Independent",
14
14
  ]
15
15
  dependencies = [
16
- "pydantic ~= 2.10.6",
16
+ "pydantic ~= 2.12",
17
17
  "numpy>=1.20",
18
18
  "jsonschema",
19
19
  "typing_extensions",
20
20
  "anyio ~=4.0",
21
21
  "httpx",
22
- "fastapi[all]>=0.115.0",
22
+ "fastapi[all]~=0.135.0",
23
23
  "zeroconf >=0.28.0",
24
24
  ]
25
25
 
@@ -25,7 +25,6 @@ from .thing_server_interface import ThingServerInterface
25
25
  from .properties import property, setting, DataProperty, DataSetting
26
26
  from .actions import action
27
27
  from .endpoints import endpoint
28
- from . import deps
29
28
  from . import outputs
30
29
  from .outputs import blob
31
30
  from .server import ThingServer, cli
@@ -53,7 +52,6 @@ __all__ = [
53
52
  "action",
54
53
  "thing_slot",
55
54
  "endpoint",
56
- "deps",
57
55
  "outputs",
58
56
  "blob",
59
57
  "ThingServer",
@@ -48,7 +48,6 @@ from .base_descriptor import (
48
48
  from .logs import add_thing_log_destination
49
49
  from .utilities import model_to_dict, wrap_plain_types_in_rootmodel
50
50
  from .invocations import InvocationModel, InvocationStatus
51
- from .dependencies.invocation import NonWarningInvocationID
52
51
  from .exceptions import (
53
52
  InvocationCancelledError,
54
53
  InvocationError,
@@ -352,7 +351,6 @@ class ActionManager:
352
351
  self,
353
352
  action: ActionDescriptor,
354
353
  thing: Thing,
355
- id: uuid.UUID,
356
354
  input: Any,
357
355
  dependencies: dict[str, Any],
358
356
  ) -> Invocation:
@@ -366,8 +364,6 @@ class ActionManager:
366
364
  :param thing: is the object on which we are running the ``action``, i.e.
367
365
  it is supplied to the function wrapped by ``action`` as the ``self``
368
366
  argument.
369
- :param id: is a `uuid.UUID` used to identify the invocation, for example
370
- when polling its status via HTTP.
371
367
  :param input: is a `pydantic.BaseModel` representing the body of the HTTP
372
368
  request that invoked the action. It is supplied to the function as
373
369
  keyword arguments.
@@ -381,7 +377,7 @@ class ActionManager:
381
377
  thing=thing,
382
378
  input=input,
383
379
  dependencies=dependencies,
384
- id=id,
380
+ id=uuid.uuid4(),
385
381
  )
386
382
  self.append_invocation(thread)
387
383
  thread.start()
@@ -821,7 +817,6 @@ class ActionDescriptor(
821
817
  # the function to the decorator.
822
818
  def start_action(
823
819
  body: Any, # This annotation will be overwritten below.
824
- id: NonWarningInvocationID,
825
820
  background_tasks: BackgroundTasks,
826
821
  **dependencies: Any,
827
822
  ) -> InvocationModel:
@@ -831,7 +826,6 @@ class ActionDescriptor(
831
826
  thing=thing,
832
827
  input=body,
833
828
  dependencies=dependencies,
834
- id=id,
835
829
  )
836
830
  background_tasks.add_task(action_manager.expire_invocations)
837
831
  return action.response()
@@ -247,7 +247,7 @@ class BaseDescriptorInfo(
247
247
  encountered directly by someone using LabThings, except as a base class for
248
248
  `.Action`\ , `.Property` and others.
249
249
 
250
- LabThings uses descriptors to represent the :ref:`affordances` of a `.Thing`\ .
250
+ LabThings uses descriptors to represent the :ref:`wot_affordances` of a `.Thing`\ .
251
251
  However, passing descriptors around isn't very elegant for two reasons:
252
252
 
253
253
  * Holding references to Descriptor objects can confuse static type checkers.
@@ -79,11 +79,11 @@ class DequeByInvocationIDHandler(logging.Handler):
79
79
  pass # If there's no destination for a particular log, ignore it.
80
80
 
81
81
 
82
- def configure_thing_logger() -> None:
82
+ def configure_thing_logger(level: int | None = None) -> None:
83
83
  """Set up the logger for thing instances.
84
84
 
85
- We always set the logger for thing instances to level INFO, as this
86
- is currently used to relay progress to the client.
85
+ We always set the logger for thing instances to level INFO by default,
86
+ as this is currently used to relay progress to the client.
87
87
 
88
88
  This function will collect logs on a per-invocation
89
89
  basis by adding a `.DequeByInvocationIDHandler` to the log. Only one
@@ -93,8 +93,14 @@ def configure_thing_logger() -> None:
93
93
  a filter to add invocation ID is not possible. Instead, we attach a filter to
94
94
  the handler, which filters all the records that propagate to it (i.e. anything
95
95
  that starts with ``labthings_fastapi.things``).
96
+
97
+ :param level: the logging level to use. If not specified, we use INFO.
96
98
  """
97
- THING_LOGGER.setLevel(logging.INFO)
99
+ if level is not None:
100
+ THING_LOGGER.setLevel(level)
101
+ else:
102
+ THING_LOGGER.setLevel(logging.INFO)
103
+
98
104
  if not any(
99
105
  isinstance(h, DequeByInvocationIDHandler) for h in THING_LOGGER.handlers
100
106
  ):
@@ -93,7 +93,6 @@ from typing import (
93
93
  Literal,
94
94
  Mapping,
95
95
  )
96
- from warnings import warn
97
96
  from weakref import WeakValueDictionary
98
97
  from tempfile import TemporaryDirectory
99
98
  import uuid
@@ -615,7 +614,7 @@ class Blob:
615
614
  """
616
615
  return core_schema.no_info_wrap_validator_function(
617
616
  cls._validate,
618
- BlobModel.__get_pydantic_core_schema__(BlobModel, handler),
617
+ BlobModel.__pydantic_core_schema__,
619
618
  serialization=core_schema.wrap_serializer_function_ser_schema(
620
619
  cls._serialize,
621
620
  is_field_serializer=False,
@@ -881,40 +880,6 @@ class Blob:
881
880
  )
882
881
 
883
882
 
884
- def blob_type(media_type: str) -> type[Blob]:
885
- r"""Create a `.Blob` subclass for a given media type.
886
-
887
- This convenience function may confuse static type checkers, so it is usually
888
- clearer to make a subclass instead, e.g.:
889
-
890
- .. code-block:: python
891
-
892
- class MyImageBlob(Blob):
893
- media_type = "image/png"
894
-
895
- :param media_type: the media type that the new `.Blob` subclass will use.
896
-
897
- :return: a subclass of `.Blob` with the specified media type.
898
-
899
- :raise ValueError: if the media type contains ``'`` or ``\``.
900
- """
901
- warn(
902
- "`blob_type` is deprecated and will be removed in v0.1.0. "
903
- "Create a subclass of `Blob` instead.",
904
- DeprecationWarning,
905
- stacklevel=2,
906
- )
907
- if "'" in media_type or "\\" in media_type:
908
- raise ValueError("media_type must not contain single quotes or backslashes")
909
- return type(
910
- f"{media_type.replace('/', '_')}_blob",
911
- (Blob,),
912
- {
913
- "media_type": media_type,
914
- },
915
- )
916
-
917
-
918
883
  router = APIRouter()
919
884
  """A FastAPI router for BlobData download endpoints."""
920
885
 
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
  from typing import Any, AsyncGenerator, Optional, TypeVar
11
11
  from typing_extensions import Self
12
12
  import os
13
+ import logging
13
14
 
14
15
  from fastapi import FastAPI, Request
15
16
  from fastapi.middleware.cors import CORSMiddleware
@@ -27,7 +28,6 @@ from ..logs import configure_thing_logger
27
28
  from ..thing import Thing
28
29
  from ..thing_server_interface import ThingServerInterface
29
30
  from ..thing_description._model import ThingDescription
30
- from ..dependencies.thing_server import _thing_servers # noqa: F401
31
31
  from .config_model import (
32
32
  ThingsConfig,
33
33
  ThingServerConfig,
@@ -66,6 +66,7 @@ class ThingServer:
66
66
  things: ThingsConfig,
67
67
  settings_folder: Optional[str] = None,
68
68
  application_config: Optional[Mapping[str, Any]] = None,
69
+ debug: bool = False,
69
70
  ) -> None:
70
71
  r"""Initialise a LabThings server.
71
72
 
@@ -86,9 +87,12 @@ class ThingServer:
86
87
  application. This is not processed by LabThings. Each `.Thing` can access
87
88
  application. This is not processed by LabThings. Each `.Thing` can access
88
89
  this via the Thing-Server interface.
90
+ :param debug: If ``True``, set the log level for `.Thing` instances to
91
+ DEBUG.
89
92
  """
90
93
  self.startup_failure: dict | None = None
91
- configure_thing_logger() # Note: this is safe to call multiple times.
94
+ # Note: this is safe to call multiple times.
95
+ configure_thing_logger(logging.DEBUG if debug else None)
92
96
  self._config = ThingServerConfig(
93
97
  things=things,
94
98
  settings_folder=settings_folder,
@@ -105,22 +109,23 @@ class ThingServer:
105
109
  self.blocking_portal: Optional[BlockingPortal] = None
106
110
  self.startup_status: dict[str, str | dict] = {"things": {}}
107
111
  global _thing_servers # noqa: F824
108
- _thing_servers.add(self)
109
112
  # The function calls below create and set up the Things.
110
113
  self._things = self._create_things()
111
114
  self._connect_things()
112
115
  self._attach_things_to_server()
113
116
 
114
117
  @classmethod
115
- def from_config(cls, config: ThingServerConfig) -> Self:
118
+ def from_config(cls, config: ThingServerConfig, debug: bool = False) -> Self:
116
119
  r"""Create a ThingServer from a configuration model.
117
120
 
118
121
  This is equivalent to ``ThingServer(**dict(config))``\ .
119
122
 
120
123
  :param config: The configuration parameters for the server.
124
+ :param debug: If ``True``, set the log level for `.Thing` instances to
125
+ DEBUG.
121
126
  :return: A `.ThingServer` configured as per the model.
122
127
  """
123
- return cls(**dict(config))
128
+ return cls(**dict(config), debug=debug)
124
129
 
125
130
  def _set_cors_middleware(self) -> None:
126
131
  """Configure the server to allow requests from other origins.
@@ -56,6 +56,11 @@ def get_default_parser() -> ArgumentParser:
56
56
  default=5000,
57
57
  help="Bind socket to this port. If 0, an available port will be picked.",
58
58
  )
59
+ parser.add_argument(
60
+ "--debug",
61
+ action="store_true",
62
+ help="Enable debug logging.",
63
+ )
59
64
  return parser
60
65
 
61
66
 
@@ -149,10 +154,11 @@ def serve_from_cli(
149
154
  option is not specified.
150
155
  """
151
156
  args = parse_args(argv)
157
+
152
158
  try:
153
159
  config, server = None, None
154
160
  config = config_from_args(args)
155
- server = ThingServer.from_config(config)
161
+ server = ThingServer.from_config(config, True if args.debug else False)
156
162
  if dry_run:
157
163
  return server
158
164
  uvicorn.run(server.app, host=args.host, port=args.port)
@@ -6,7 +6,6 @@ from typing import Any, Dict, Generic, Iterable, TYPE_CHECKING, Optional, TypeVa
6
6
  from weakref import WeakSet
7
7
  from pydantic import BaseModel, ConfigDict, Field, RootModel, create_model
8
8
  from pydantic.dataclasses import dataclass
9
- from pydantic_core import SchemaError
10
9
 
11
10
  from labthings_fastapi.exceptions import UnsupportedConstraintError
12
11
  from .introspection import EmptyObject
@@ -129,7 +128,7 @@ def wrap_plain_types_in_rootmodel(
129
128
  :raises UnsupportedConstraintError: if constraints are provided for an
130
129
  unsuitable type, for example `allow_inf_nan` for an `int` property, or
131
130
  any constraints for a `BaseModel` subclass.
132
- :raises SchemaError: if other errors prevent Pydantic from creating a schema
131
+ :raises RuntimeError: if other errors prevent Pydantic from creating a schema
133
132
  for the generated model.
134
133
  """
135
134
  try: # This needs to be a `try` as basic types are not classes
@@ -148,13 +147,9 @@ def wrap_plain_types_in_rootmodel(
148
147
  root=(model, Field(**constraints)),
149
148
  __base__=LabThingsRootModelWrapper,
150
149
  )
151
- except SchemaError as e:
152
- for error in e.errors():
153
- if error["loc"][-1] in constraints:
154
- key = error["loc"][-1]
155
- raise UnsupportedConstraintError(
156
- f"Constraint {key} is not supported for type {model!r}."
157
- ) from e
150
+ except RuntimeError as e:
151
+ if "Unable to apply constraint" in str(e):
152
+ raise UnsupportedConstraintError(str(e)) from e
158
153
  raise e
159
154
 
160
155
 
@@ -1,344 +0,0 @@
1
- """A mock client that uses a Thing directly.
2
-
3
- When `.Thing` objects interact on the server, it can be very useful to
4
- use an interface that is identical to the `.ThingClient` used to access
5
- the same `.Thing` remotely. This means that code can run either on the
6
- server or on a client, e.g. in a Jupyter notebook where it is much
7
- easier to debug. See :ref:`things_from_things` for more detail.
8
-
9
- Currently `.DirectThingClient` is not a subclass of `.ThingClient`,
10
- that may need to change. It's a good idea to create a
11
- `.DirectThingClient` at module level, so that type hints work.
12
-
13
-
14
- """
15
-
16
- from __future__ import annotations
17
- from functools import wraps
18
- import inspect
19
- import logging
20
- from typing import Any, Mapping, Optional, Union
21
- from warnings import warn
22
- from pydantic import BaseModel
23
- from ..actions import ActionDescriptor
24
-
25
- from ..properties import BaseProperty
26
- from ..utilities import attributes
27
- from . import PropertyClientDescriptor
28
- from ..thing import Thing
29
- from ..dependencies.thing_server import find_thing_server
30
- from fastapi import Request
31
-
32
-
33
- __all__ = ["DirectThingClient", "direct_thing_client_class"]
34
-
35
-
36
- class DirectThingClient:
37
- """A wrapper for `.Thing` that is a work-a-like for `.ThingClient`.
38
-
39
- This class is used to create a class that works like `.ThingClient`
40
- but does not communicate over HTTP. Instead, it wraps a `.Thing` object
41
- and calls its methods directly.
42
-
43
- It is not yet 100% identical to `.ThingClient`, in particular `.ThingClient`
44
- returns a lot of data directly as deserialised from JSON, while this class
45
- generally returns `pydantic.BaseModel` instances, without serialisation.
46
-
47
- `.DirectThingClient` is generally not used on its own, but is subclassed
48
- (often dynamically) to add the actions and properties of a particular
49
- `.Thing`.
50
- """
51
-
52
- __globals__ = globals() # "bake in" globals so dependency injection works
53
- thing_class: type[Thing]
54
- """The class of the underlying `.Thing` we are wrapping."""
55
- thing_name: str
56
- """The name of the Thing on the server."""
57
-
58
- def __init__(self, request: Request, **dependencies: Mapping[str, Any]) -> None:
59
- r"""Wrap a `.Thing` so it works like a `.ThingClient`.
60
-
61
- This class is designed to be used as a FastAPI dependency, and will
62
- retrieve a `.Thing` based on its ``thing_path`` attribute.
63
- Finding the Thing by class may also be an option in the future.
64
-
65
- :param request: This is a FastAPI dependency to access the
66
- `fastapi.Request` object, allowing access to various resources.
67
- :param \**dependencies: Further arguments will be added
68
- dynamically by subclasses, by duplicating this method and
69
- manipulating its signature. Adding arguments with annotated
70
- type hints instructs FastAPI to inject dependency arguments,
71
- such as access to other `.Things`.
72
- """
73
- warn(
74
- "`DirectThingClient` is deprecated and will be removed in v0.1.0. Use "
75
- "`lt.thing_slot` instead.",
76
- DeprecationWarning,
77
- stacklevel=2,
78
- )
79
- server = find_thing_server(request.app)
80
- self._wrapped_thing = server.things[self.thing_name]
81
- self._request = request
82
- self._dependencies = dependencies
83
-
84
-
85
- def property_descriptor(
86
- property_name: str,
87
- model: Union[type, BaseModel],
88
- description: Optional[str] = None,
89
- readable: bool = True,
90
- writeable: bool = True,
91
- property_path: Optional[str] = None,
92
- ) -> PropertyClientDescriptor:
93
- """Create a correctly-typed descriptor that gets and/or sets a property.
94
-
95
- .. todo::
96
- This is copy-pasted from labthings_fastapi.client.__init__.property_descriptor
97
- TODO: refactor this into a shared function.
98
-
99
- Create a descriptor object that wraps a property. This is for use on
100
- a `.DirectThingClient` subclass.
101
-
102
- :param property_name: should be the name of the property (i.e. the
103
- name it takes in the thing description, and also the name it is
104
- assigned to in the class).
105
- :param model: the Python ``type`` or a ``pydantic.BaseModel`` that
106
- represents the datatype of the property.
107
- :param description: text to use for a docstring.
108
- :param readable: whether the property may be read (i.e. has ``__get__``).
109
- :param writeable: whether the property may be written to.
110
- :param property_path: the URL of the ``getproperty`` and ``setproperty``
111
- HTTP endpoints. Currently these must both be the same. These are
112
- relative to the ``base_url``, i.e. the URL of the Thing Description.
113
-
114
- :return: a descriptor allowing access to the specified property.
115
- """
116
-
117
- class P(PropertyClientDescriptor):
118
- name = property_name
119
- type = model
120
- path = property_path or property_name
121
-
122
- def __get__(
123
- self: PropertyClientDescriptor,
124
- obj: Optional[DirectThingClient] = None,
125
- _objtype: Optional[type[DirectThingClient]] = None,
126
- ) -> Any:
127
- if obj is None:
128
- return self
129
- return getattr(obj._wrapped_thing, self.name)
130
-
131
- def __set__(
132
- self: PropertyClientDescriptor, obj: DirectThingClient, value: Any
133
- ) -> None:
134
- setattr(obj._wrapped_thing, self.name, value)
135
-
136
- def set_readonly(
137
- self: PropertyClientDescriptor, obj: DirectThingClient, value: Any
138
- ) -> None:
139
- raise AttributeError("This property is read-only.")
140
-
141
- if readable:
142
- __get__.__annotations__["return"] = model
143
- P.__get__ = __get__ # type: ignore[attr-defined]
144
- if writeable:
145
- __set__.__annotations__["value"] = model
146
- P.__set__ = __set__ # type: ignore[attr-defined]
147
- else:
148
- set_readonly.__annotations__["value"] = model
149
- P.__set__ = set_readonly # type: ignore[attr-defined]
150
- if description:
151
- P.__doc__ = description
152
- return P()
153
-
154
-
155
- class DependencyNameClashError(KeyError):
156
- """A dependency argument name is used inconsistently.
157
-
158
- A current limitation of `.DirectThingClient` is that the dependency
159
- arguments (see :ref:`dependencies`) are collected together in a single
160
- dictionary. This makes the assumption that, if a name is reused, it is
161
- reused for the same dependency.
162
-
163
- When names are reused, we check if the values match. If not, this
164
- exception is raised.
165
- """
166
-
167
- def __init__(self, name: str, existing: type, new: type) -> None:
168
- """Create a DependencyNameClashError.
169
-
170
- See class docstring for an explanation of the error.
171
-
172
- :param name: the name of the clashing dependencies.
173
- :param existing: the dependency type annotation in the dictionary.
174
- :param new: the clashing type annotation.
175
- """
176
- super().__init__(
177
- f"{self.__doc__}\n\n"
178
- f"This clash is with name: {name}.\n"
179
- f"Its value is currently {existing}, which clashes with {new}."
180
- )
181
-
182
-
183
- def add_action(
184
- attrs: dict[str, Any],
185
- dependencies: list[inspect.Parameter],
186
- name: str,
187
- action: ActionDescriptor,
188
- ) -> None:
189
- """Generate an action method and adds it to an attrs dict.
190
-
191
- FastAPI Dependencies are appended to the `dependencies` list.
192
- This list should later be converted to type hints on the class
193
- initialiser, so that FastAPI supplies the dependencies when
194
- the `.DirectThingClient` is initialised.
195
-
196
- :param attrs: the attributes of a soon-to-be-created `.DirectThingClient`
197
- subclass. This will be passed to `type()` to create the subclass.
198
- We will add the action method to this dictionary.
199
- :param dependencies: lists the dependency parameters that will be
200
- injected by FastAPI as arguments to the class ``__init__``.
201
- Any dependency parameters of the supplied ``action`` should be
202
- added to this list.
203
- :param name: the name of the action. Should be the name of the
204
- attribute, i.e. we will set ``attrs[name]``, and also match
205
- the ``name`` in the supplied action descriptor.
206
- :param action: an `.ActionDescriptor` to be wrapped.
207
-
208
- :raise DependencyNameClashError: if dependencies are inconsistent.
209
- """
210
-
211
- @wraps(action.func)
212
- def action_method(self: DirectThingClient, **kwargs: Any) -> Any:
213
- dependency_kwargs = {
214
- param.name: self._dependencies[param.name]
215
- for param in action.dependency_params
216
- }
217
- kwargs_and_deps = {**kwargs, **dependency_kwargs}
218
- return getattr(self._wrapped_thing, name)(**kwargs_and_deps)
219
-
220
- attrs[name] = action_method
221
- # We collect up all the dependencies, so that we can
222
- # resolve them when we create the client.
223
- for param in action.dependency_params:
224
- included = False
225
- for existing_param in dependencies:
226
- if existing_param.name == param.name:
227
- # Currently, each name may only have one annotation, across
228
- # all actions - this is a limitation we should fix.
229
- if existing_param.annotation != param.annotation:
230
- raise DependencyNameClashError(
231
- param.name, existing_param.annotation, param.annotation
232
- )
233
- included = True
234
- if not included:
235
- dependencies.append(param)
236
-
237
-
238
- def add_property(
239
- attrs: dict[str, Any], property_name: str, property: BaseProperty
240
- ) -> None:
241
- """Add a property to a DirectThingClient subclass.
242
-
243
- We create a new descriptor using `.property_descriptor` and add it
244
- to the ``attrs`` dictionary as ``property_name``.
245
-
246
- :param attrs: the attributes of a soon-to-be-created `.DirectThingClient`
247
- subclass. This will be passed to `type()` to create the subclass.
248
- We will add the property to this dictionary.
249
- :param property_name: the name of the property. Should be the name of the
250
- attribute, i.e. we will set ``attrs[name]``.
251
- :param property: a `.PropertyDescriptor` to be wrapped.
252
- """
253
- attrs[property_name] = property_descriptor(
254
- property_name,
255
- property.model,
256
- description=property.description,
257
- writeable=not property.readonly,
258
- readable=True,
259
- )
260
-
261
-
262
- def direct_thing_client_class(
263
- thing_class: type[Thing],
264
- thing_name: str,
265
- actions: Optional[list[str]] = None,
266
- ) -> type[DirectThingClient]:
267
- r"""Create a DirectThingClient from a Thing class and a path.
268
-
269
- This is a class, not an instance: it's designed to be a FastAPI dependency.
270
-
271
- :param thing_class: The `.Thing` subclass that will be wrapped.
272
- :param thing_name: The name of the `.Thing` on the server.
273
- :param actions: An optional list giving a subset of actions that will be
274
- accessed. If this is specified, it may reduce the number of FastAPI
275
- dependencies we need.
276
-
277
- :return: a subclass of `DirectThingClient` with attributes that match the
278
- properties and actions of ``thing_class``. The ``__init__`` method
279
- will have annotations that instruct FastAPI to supply all the
280
- dependencies needed by its actions.
281
-
282
- This class may be used as a FastAPI dependency: see :ref:`things_from_things`.
283
- """
284
- warn(
285
- "`direct_thing_client_class` is deprecated and will be removed in v0.1.0. "
286
- "Use `lt.thing_slot` instead.",
287
- DeprecationWarning,
288
- stacklevel=3, # This is called from `direct_thing_client_dependency` so we
289
- # need stacklevel=3 to point to user code.
290
- )
291
-
292
- def init_proxy(
293
- self: DirectThingClient, request: Request, **dependencies: Mapping[str, Any]
294
- ) -> None:
295
- r"""Initialise a DirectThingClient (this docstring will be replaced).
296
-
297
- :param self: The DirectThingClient instance we're initialising.
298
- :param request: a FastAPI Request option (will be supplied by FastAPI).
299
- :param \**dependencies: Other keyword arguments will be saved as
300
- dependencies. FastAPI will look at the signature (which we will
301
- manipulate below) to determine these.
302
- """
303
- # NB this definition isimportant, as we must modify its signature.
304
- # Inheriting __init__ means we'll accidentally modify the signature
305
- # of `DirectThingClient` with bad results.
306
- DirectThingClient.__init__(self, request, **dependencies)
307
-
308
- init_proxy.__doc__ = f"""Initialise a client for {thing_class}"""
309
-
310
- # Using a class definition gets confused by the scope of the function
311
- # arguments - this is equivalent to a class definition but all the
312
- # arguments are evaluated in the right scope.
313
- client_attrs = {
314
- "thing_class": thing_class,
315
- "thing_name": thing_name,
316
- "__doc__": f"A client for {thing_class} named {thing_name}",
317
- "__init__": init_proxy,
318
- }
319
- dependencies: list[inspect.Parameter] = []
320
- for name, item in attributes(thing_class):
321
- if isinstance(item, BaseProperty):
322
- add_property(client_attrs, name, item)
323
- elif isinstance(item, ActionDescriptor):
324
- if actions is None or name in actions:
325
- add_action(client_attrs, dependencies, name, item)
326
- else:
327
- continue # Ignore actions that aren't in the list
328
- else:
329
- for affordance in ["property", "action", "event"]:
330
- if hasattr(item, f"{affordance}_affordance"):
331
- logging.warning(
332
- f"DirectThingClient doesn't support custom affordances, "
333
- f"ignoring {name}"
334
- )
335
- # This block of code makes dependencies show up in __init__ so
336
- # they get resolved. It's more or less copied from the `action` descriptor.
337
- sig = inspect.signature(init_proxy)
338
- params = [p for p in sig.parameters.values() if p.name != "dependencies"]
339
- init_proxy.__signature__ = sig.replace( # type: ignore[attr-defined]
340
- parameters=params + dependencies
341
- )
342
- return type(
343
- f"{thing_class.__name__}DirectClient", (DirectThingClient,), client_attrs
344
- )