labthings-fastapi 0.0.14__tar.gz → 0.0.16__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 (52) hide show
  1. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/PKG-INFO +2 -1
  2. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/pyproject.toml +5 -1
  3. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/actions.py +90 -66
  4. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/base_descriptor.py +468 -20
  5. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/client/__init__.py +34 -18
  6. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/exceptions.py +24 -0
  7. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/invocations.py +19 -14
  8. labthings_fastapi-0.0.16/src/labthings_fastapi/middleware/__init__.py +1 -0
  9. labthings_fastapi-0.0.16/src/labthings_fastapi/middleware/url_for.py +205 -0
  10. labthings_fastapi-0.0.16/src/labthings_fastapi/outputs/blob.py +955 -0
  11. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/properties.py +237 -66
  12. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/server/__init__.py +33 -4
  13. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/server/config_model.py +12 -0
  14. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/testing.py +18 -0
  15. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/thing.py +134 -60
  16. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/thing_description/_model.py +5 -3
  17. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/thing_server_interface.py +6 -0
  18. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/thing_slots.py +3 -11
  19. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/utilities/__init__.py +17 -2
  20. labthings_fastapi-0.0.14/src/labthings_fastapi/client/outputs.py +0 -77
  21. labthings_fastapi-0.0.14/src/labthings_fastapi/outputs/blob.py +0 -787
  22. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/.gitignore +0 -0
  23. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/LICENSE +0 -0
  24. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/README.md +0 -0
  25. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/__init__.py +0 -0
  26. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/client/in_server.py +0 -0
  27. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/dependencies/__init__.py +0 -0
  28. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/dependencies/blocking_portal.py +0 -0
  29. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/dependencies/invocation.py +0 -0
  30. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/dependencies/metadata.py +0 -0
  31. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/dependencies/raw_thing.py +0 -0
  32. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/dependencies/thing.py +0 -0
  33. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/dependencies/thing_server.py +0 -0
  34. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/deps.py +0 -0
  35. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/endpoints.py +0 -0
  36. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/example_things/__init__.py +0 -0
  37. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/invocation_contexts.py +0 -0
  38. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/logs.py +0 -0
  39. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/notifications.py +0 -0
  40. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/outputs/__init__.py +0 -0
  41. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/outputs/mjpeg_stream.py +0 -0
  42. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/py.typed +0 -0
  43. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/server/cli.py +0 -0
  44. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/server/fallback.html.jinja +0 -0
  45. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/server/fallback.py +0 -0
  46. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/thing_description/__init__.py +0 -0
  47. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/thing_description/td-json-schema-validation.json +0 -0
  48. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/thing_description/validation.py +0 -0
  49. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/types/__init__.py +0 -0
  50. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/types/numpy.py +0 -0
  51. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/src/labthings_fastapi/utilities/introspection.py +0 -0
  52. {labthings_fastapi-0.0.14 → labthings_fastapi-0.0.16}/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.14
3
+ Version: 0.0.16
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
@@ -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: tomli; (python_version < '3.11') and extra == 'dev'
38
39
  Requires-Dist: types-jsonschema; extra == 'dev'
39
40
  Description-Content-Type: text/markdown
40
41
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "labthings-fastapi"
3
- version = "0.0.14"
3
+ version = "0.0.16"
4
4
  authors = [
5
5
  { name="Richard Bowman", email="richard.bowman@cantab.net" },
6
6
  ]
@@ -41,6 +41,7 @@ dev = [
41
41
  "sphinx>=7.2",
42
42
  "sphinx-autoapi",
43
43
  "sphinx-toolbox",
44
+ "tomli; python_version < '3.11'",
44
45
  "codespell",
45
46
  ]
46
47
 
@@ -171,5 +172,8 @@ check-return-types = false
171
172
  check-class-attributes = false # prefer docstrings on the attributes
172
173
  check-yield-types = false # use type annotations instead
173
174
 
175
+ [tool.codespell]
176
+ ignore-words-list = ["ser"]
177
+
174
178
  [project.scripts]
175
179
  labthings-server = "labthings_fastapi.server.cli:serve_from_cli"
@@ -39,18 +39,21 @@ import weakref
39
39
  from fastapi import FastAPI, HTTPException, Request, Body, BackgroundTasks
40
40
  from pydantic import BaseModel, create_model
41
41
 
42
- from .base_descriptor import BaseDescriptor
42
+ from .middleware.url_for import URLFor
43
+ from .base_descriptor import (
44
+ BaseDescriptor,
45
+ BaseDescriptorInfo,
46
+ DescriptorInfoCollection,
47
+ )
43
48
  from .logs import add_thing_log_destination
44
49
  from .utilities import model_to_dict, wrap_plain_types_in_rootmodel
45
- from .invocations import InvocationModel, InvocationStatus, LogRecordModel
50
+ from .invocations import InvocationModel, InvocationStatus
46
51
  from .dependencies.invocation import NonWarningInvocationID
47
52
  from .exceptions import (
48
53
  InvocationCancelledError,
49
54
  InvocationError,
50
- NoBlobManagerError,
51
55
  NotConnectedToServerError,
52
56
  )
53
- from .outputs.blob import BlobIOContextDep, blobdata_to_url_ctx
54
57
  from . import invocation_contexts
55
58
  from .utilities.introspection import (
56
59
  EmptyInput,
@@ -149,28 +152,12 @@ class Invocation(Thread):
149
152
 
150
153
  @property
151
154
  def output(self) -> Any:
152
- """Return value of the Action. If the Action is still running, returns None.
153
-
154
- :raise NoBlobManagerError: If this is called in a context where the blob
155
- manager context variables are not available. This stops errors being raised
156
- later once the blob is returned and tries to serialise. If the errors
157
- happen during serialisation the stack-trace will not clearly identify
158
- the route with the missing dependency.
159
- """
160
- try:
161
- blobdata_to_url_ctx.get()
162
- except LookupError as e:
163
- raise NoBlobManagerError(
164
- "An invocation output has been requested from a api route that "
165
- "doesn't have a BlobIOContextDep dependency. This dependency is needed "
166
- " for blobs to identify their url."
167
- ) from e
168
-
155
+ """Return value of the Action. If the Action is still running, returns None."""
169
156
  with self._status_lock:
170
157
  return self._return_value
171
158
 
172
159
  @property
173
- def log(self) -> list[LogRecordModel]:
160
+ def log(self) -> list[logging.LogRecord]:
174
161
  """A list of log items generated by the Action."""
175
162
  with self._status_lock:
176
163
  return list(self._log)
@@ -225,33 +212,28 @@ class Invocation(Thread):
225
212
  """
226
213
  self.cancel_hook.set()
227
214
 
228
- def response(self, request: Optional[Request] = None) -> InvocationModel:
215
+ def response(self) -> InvocationModel:
229
216
  """Generate a representation of the invocation suitable for HTTP.
230
217
 
231
218
  When an invocation is polled, we return a JSON object that includes
232
219
  its status, any log entries, a return value (if completed), and a link
233
220
  to poll for updates.
234
221
 
235
- :param request: is used to generate the ``href`` in the response, which
236
- should retrieve an updated version of this response.
237
-
238
222
  :return: an `.InvocationModel` representing this `.Invocation`.
239
223
  """
240
- if request:
241
- href = str(request.url_for("action_invocation", id=self.id))
242
- else:
243
- href = f"{ACTION_INVOCATIONS_PATH}/{self.id}"
244
224
  links = [
245
- LinkElement(rel="self", href=href),
246
- LinkElement(rel="output", href=href + "/output"),
225
+ LinkElement(rel="self", href=URLFor("action_invocation", id=self.id)),
226
+ LinkElement(
227
+ rel="output", href=URLFor("action_invocation_output", id=self.id)
228
+ ),
247
229
  ]
248
230
  # The line below confuses MyPy because self.action **evaluates to** a Descriptor
249
231
  # object (i.e. we don't call __get__ on the descriptor).
250
- return self.action.invocation_model( # type: ignore[call-overload]
232
+ return self.action.invocation_model( # type: ignore[attr-defined]
251
233
  status=self.status,
252
234
  id=self.id,
253
- action=self.thing.path + self.action.name, # type: ignore[call-overload]
254
- href=href,
235
+ action=self.thing.path + self.action.name, # type: ignore[attr-defined]
236
+ href=URLFor("action_invocation", id=self.id),
255
237
  timeStarted=self._start_time,
256
238
  timeCompleted=self._end_time,
257
239
  timeRequested=self._request_time,
@@ -290,7 +272,7 @@ class Invocation(Thread):
290
272
  """
291
273
  # self.action evaluates to an ActionDescriptor. This confuses mypy,
292
274
  # which thinks we are calling ActionDescriptor.__get__.
293
- action: ActionDescriptor = self.action # type: ignore[call-overload]
275
+ action: ActionDescriptor = self.action # type: ignore[assignment]
294
276
  logger = self.thing.logger
295
277
  # The line below saves records matching our ID to ``self._log``
296
278
  add_thing_log_destination(self.id, self._log)
@@ -442,13 +424,10 @@ class ActionManager:
442
424
  :return: A list of invocations, optionally filtered by Thing and/or Action.
443
425
  """
444
426
  return [
445
- i.response(request=request)
427
+ i.response()
446
428
  for i in self.invocations
447
429
  if thing is None or i.thing == thing
448
- if action is None or i.action == action # type: ignore[call-overload]
449
- # i.action evaluates to an ActionDescriptor, which confuses mypy - it
450
- # thinks we are calling ActionDescriptor.__get__ but this isn't ever
451
- # called.
430
+ if action is None or i.action == action
452
431
  ]
453
432
 
454
433
  def expire_invocations(self) -> None:
@@ -470,25 +449,19 @@ class ActionManager:
470
449
  """
471
450
 
472
451
  @app.get(ACTION_INVOCATIONS_PATH, response_model=list[InvocationModel])
473
- def list_all_invocations(
474
- request: Request, _blob_manager: BlobIOContextDep
475
- ) -> list[InvocationModel]:
452
+ def list_all_invocations(request: Request) -> list[InvocationModel]:
476
453
  return self.list_invocations(request=request)
477
454
 
478
455
  @app.get(
479
456
  ACTION_INVOCATIONS_PATH + "/{id}",
480
457
  responses={404: {"description": "Invocation ID not found"}},
481
458
  )
482
- def action_invocation(
483
- id: uuid.UUID, request: Request, _blob_manager: BlobIOContextDep
484
- ) -> InvocationModel:
459
+ def action_invocation(id: uuid.UUID, request: Request) -> InvocationModel:
485
460
  """Return a description of a specific action.
486
461
 
487
462
  :param id: The action's ID (from the path).
488
463
  :param request: FastAPI dependency for the request object, used to
489
464
  find URLs via ``url_for``.
490
- :param _blob_manager: FastAPI dependency that enables `.Blob` objects
491
- to be serialised.
492
465
 
493
466
  :return: Details of the invocation.
494
467
 
@@ -497,7 +470,7 @@ class ActionManager:
497
470
  """
498
471
  try:
499
472
  with self._invocations_lock:
500
- return self._invocations[id].response(request=request)
473
+ return self._invocations[id].response()
501
474
  except KeyError as e:
502
475
  raise HTTPException(
503
476
  status_code=404,
@@ -518,17 +491,13 @@ class ActionManager:
518
491
  503: {"description": "No result is available for this invocation"},
519
492
  },
520
493
  )
521
- def action_invocation_output(
522
- id: uuid.UUID, _blob_manager: BlobIOContextDep
523
- ) -> Any:
494
+ def action_invocation_output(id: uuid.UUID) -> Any:
524
495
  """Get the output of an action invocation.
525
496
 
526
497
  This returns just the "output" component of the action invocation. If the
527
498
  output is a file, it will return the file.
528
499
 
529
500
  :param id: The action's ID (from the path).
530
- :param _blob_manager: FastAPI dependency that enables `.Blob` objects
531
- to be serialised.
532
501
 
533
502
  :return: The output of the invocation, as a `pydantic.BaseModel`
534
503
  instance. If this is a `.Blob`, it may be returned directly.
@@ -625,8 +594,56 @@ ActionReturn = TypeVar("ActionReturn")
625
594
  OwnerT = TypeVar("OwnerT", bound="Thing")
626
595
 
627
596
 
597
+ class ActionInfo(
598
+ BaseDescriptorInfo[
599
+ "ActionDescriptor", OwnerT, Callable[ActionParams, ActionReturn]
600
+ ],
601
+ Generic[OwnerT, ActionParams, ActionReturn],
602
+ ):
603
+ """Convenient access to the metadata of an action."""
604
+
605
+ @property
606
+ def response_timeout(self) -> float:
607
+ """The time to wait before replying to the HTTP request initiating an action."""
608
+ return self.get_descriptor().response_timeout
609
+
610
+ @property
611
+ def retention_time(self) -> float:
612
+ """How long to retain the action's output for, in seconds."""
613
+ return self.get_descriptor().retention_time
614
+
615
+ @property
616
+ def input_model(self) -> type[BaseModel]:
617
+ """A Pydantic model for the input parameters of an Action."""
618
+ return self.get_descriptor().input_model
619
+
620
+ @property
621
+ def output_model(self) -> type[BaseModel]:
622
+ """A Pydantic model for the output parameters of an Action."""
623
+ return self.get_descriptor().output_model
624
+
625
+ @property
626
+ def invocation_model(self) -> type[BaseModel]:
627
+ """A Pydantic model for an invocation of this action."""
628
+ return self.get_descriptor().invocation_model
629
+
630
+ @property
631
+ def func(self) -> Callable[Concatenate[OwnerT, ActionParams], ActionReturn]:
632
+ """The function that runs the action."""
633
+ return self.get_descriptor().func
634
+
635
+
636
+ class ActionCollection(
637
+ DescriptorInfoCollection[OwnerT, ActionInfo],
638
+ Generic[OwnerT],
639
+ ):
640
+ """Access to the metadata of each Action."""
641
+
642
+ _descriptorinfo_class = ActionInfo
643
+
644
+
628
645
  class ActionDescriptor(
629
- BaseDescriptor[Callable[ActionParams, ActionReturn]],
646
+ BaseDescriptor[OwnerT, Callable[ActionParams, ActionReturn]],
630
647
  Generic[ActionParams, ActionReturn, OwnerT],
631
648
  ):
632
649
  """Wrap actions to enable them to be run over HTTP.
@@ -691,7 +708,7 @@ class ActionDescriptor(
691
708
  )
692
709
  self.invocation_model.__name__ = f"{name}_invocation"
693
710
 
694
- def __set_name__(self, owner: type[Thing], name: str) -> None:
711
+ def __set_name__(self, owner: type[OwnerT], name: str) -> None:
695
712
  """Ensure the action name matches the function name.
696
713
 
697
714
  It's assumed in a few places that the function name and the
@@ -709,7 +726,7 @@ class ActionDescriptor(
709
726
  f"'{self.func.__name__}'",
710
727
  )
711
728
 
712
- def instance_get(self, obj: Thing) -> Callable[ActionParams, ActionReturn]:
729
+ def instance_get(self, obj: OwnerT) -> Callable[ActionParams, ActionReturn]:
713
730
  """Return the function, bound to an object as for a normal method.
714
731
 
715
732
  This currently doesn't validate the arguments, though it may do so
@@ -721,10 +738,7 @@ class ActionDescriptor(
721
738
  descriptor.
722
739
  :return: the action function, bound to ``obj``.
723
740
  """
724
- # `obj` should be of type `OwnerT`, but `BaseDescriptor` currently
725
- # isn't generic in the type of the owning Thing, so we can't express
726
- # that here.
727
- return partial(self.func, obj) # type: ignore[arg-type]
741
+ return partial(self.func, obj)
728
742
 
729
743
  def _observers_set(self, obj: Thing) -> WeakSet:
730
744
  """Return a set used to notify changes.
@@ -806,8 +820,6 @@ class ActionDescriptor(
806
820
  # The solution below is to manually add the annotation, before passing
807
821
  # the function to the decorator.
808
822
  def start_action(
809
- _blob_manager: BlobIOContextDep,
810
- request: Request,
811
823
  body: Any, # This annotation will be overwritten below.
812
824
  id: NonWarningInvocationID,
813
825
  background_tasks: BackgroundTasks,
@@ -822,7 +834,7 @@ class ActionDescriptor(
822
834
  id=id,
823
835
  )
824
836
  background_tasks.add_task(action_manager.expire_invocations)
825
- return action.response(request=request)
837
+ return action.response()
826
838
 
827
839
  if issubclass(self.input_model, EmptyInput):
828
840
  annotation = Body(default_factory=StrictEmptyInput)
@@ -884,7 +896,7 @@ class ActionDescriptor(
884
896
  ),
885
897
  summary=f"All invocations of {self.name}.",
886
898
  )
887
- def list_invocations(_blob_manager: BlobIOContextDep) -> list[InvocationModel]:
899
+ def list_invocations() -> list[InvocationModel]:
888
900
  action_manager = thing._thing_server_interface._action_manager
889
901
  return action_manager.list_invocations(self, thing)
890
902
 
@@ -920,6 +932,18 @@ class ActionDescriptor(
920
932
  output=type_to_dataschema(self.output_model, title=f"{self.name}_output"),
921
933
  )
922
934
 
935
+ def descriptor_info(self, owner: OwnerT | None = None) -> ActionInfo:
936
+ r"""Return an `.ActionInfo` object describing this action.
937
+
938
+ The returned object will either refer to the class, or be bound to a particular
939
+ instance. If it is bound, more properties will be available - e.g. we will be
940
+ able to get the bound function.
941
+
942
+ :param owner: The owning object, or `None` to return an unbound `.ActionInfo`\ .
943
+ :return: An `.ActionInfo` object describing this Action.
944
+ """
945
+ return self._descriptor_info(ActionInfo, owner)
946
+
923
947
 
924
948
  @overload
925
949
  def action(