diff --git a/docs/source/actions.rst b/docs/source/actions.rst index e91074d9..d17b6644 100644 --- a/docs/source/actions.rst +++ b/docs/source/actions.rst @@ -51,12 +51,8 @@ parameter is the function's return value. Type hints on both arguments and return value are used to document the action in the OpenAPI description and the Thing Description, so it is important to use them consistently. -There are some function arguments that are not considered input parameters. -The first is ``self`` (the first positional argument), which is always the -`.Thing` on which the argument is defined. The other special arguments are -:ref:`dependencies`, which use annotated type hints to tell LabThings to -supply resources needed by the action. Most often, this is a way of accessing -other `.Things` on the same server. +The ``self`` parameter of action methods is not an input: this is a standard +Python construct giving access to the object on which the action is defined. .. _action_logging: @@ -112,6 +108,6 @@ If an action raises an unhandled exception, the action will terminate with an Er status and LabThings will log the error and the traceback. In the case where the error has been handled, but the job needs to terminate the action -should raise an InvocationError (or a error which subclasses this). The message from +should raise an `.InvocationError` (or a error which subclasses this). The message from this exceptions will be logged, but the full traceback will not be logged as this error has been handled. diff --git a/docs/source/conf.py b/docs/source/conf.py index 18c4025a..ed4bad66 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -68,7 +68,6 @@ convenience_modules = { "labthings_fastapi": labthings_fastapi.__all__, - "labthings_fastapi.deps": labthings_fastapi.deps.__all__, } canonical_fq_names = [ "labthings_fastapi.descriptors.action.ActionDescriptor", @@ -79,7 +78,6 @@ "labthings_fastapi.outputs.blob.BlobIOContextDep", "labthings_fastapi.actions.ActionManager", "labthings_fastapi.descriptors.endpoint.EndpointDescriptor", - "labthings_fastapi.dependencies.invocation.invocation_logger", "labthings_fastapi.utilities.introspection.EmptyObject", ] diff --git a/docs/source/dependencies/dependencies.rst b/docs/source/dependencies/dependencies.rst deleted file mode 100644 index 4e4823b2..00000000 --- a/docs/source/dependencies/dependencies.rst +++ /dev/null @@ -1,60 +0,0 @@ -.. _dependencies: - -Dependencies -============ - -.. warning:: - - The use of dependencies is now deprecated. See :ref:`thing_slots` and `.ThingServerInterface` for a more intuitive way to access that functionality. - -LabThings makes use of the powerful "dependency injection" mechanism in FastAPI. You can see the `FastAPI documentation`_ for more information. In brief, FastAPI dependencies are annotated types that instruct FastAPI to supply certain function arguments automatically. This removes the need to set up resources at the start of a function, and ensures everything the function needs is declared and typed clearly. The most common use for dependencies in LabThings is where an action needs to make use of another `.Thing` on the same `.ThingServer`. - -Inter-Thing dependencies ------------------------- - -.. warning:: - - These dependencies are deprecated - see :ref:`thing_slots` instead. - -Simple actions depend only on their input parameters and the `.Thing` on which they are defined. However, it's quite common to need something else, for example accessing another `.Thing` instance on the same LabThings server. There are two important principles to bear in mind here: - -* Other `.Thing` instances should be accessed using a `.DirectThingClient` subclass if possible. This creates a wrapper object that should work like a `.ThingClient`, meaning your code should work either on the server or in a client script. This makes the code much easier to debug. -* LabThings uses the FastAPI "dependency injection" mechanism, where you specify what's needed with type hints, and the argument is supplied automatically at run-time. You can see the `FastAPI documentation`_ for more information. - -In order to use on `.Thing` from another there are three steps, all shown in the example below. - -#. Create a `.DirectThingClient` subclass for your target `.Thing`. This can be done using the `.direct_thing_client_class` function, which takes a `.Thing` subclass and a path as arguments: these should match the configuration of your LabThings server. -#. Annotate your client class with `fastapi.Depends()` to mark it as a dependency. You may assign this annotated type to a name, which is much neater when you are using it several times. -#. Use the annotated type as a type hint on one of your action's arguments. - -.. literalinclude:: example.py - :language: python - -In the example above, the ``increment_counter`` action on ``TestThing`` takes a ``MyThingClient`` as an argument. When the action is called, the ``my_thing`` argument is supplied automatically. The argument is not the ``MyThing`` instance, instead it is a wrapper class ``MyThingClient`` (this is a dynamically generated `.DirectThingClient` subclass). The wrapper should have the same signature as a `.ThingClient` connected to ``MyThing``. This means any dependencies of actions on the ``MyThing`` are automatically supplied, so you only need to worry about the arguments that are not dependencies. The aim of this is to ensure that the code you write for your `.Thing` is as similar as possible to the code you'd write if you were using it through the Python client module. - -.. note:: - - LabThings provides a shortcut to create the annotated type needed to declare a dependency on another `.Thing`, with the function `.direct_thing_client_dependency`. This generates a type annotation that you can use when you define your actions. - This shortcut may not work well with type checkers or linters, however, so we now recommend you declare an annotated type instead, as shown in the example. - -Dependencies are added recursively - so if you depend on another Thing, and some of its actions have their own dependencies, those dependencies are also added to your action. Using the ``actions`` argument means you only need the dependencies of the actions you are going to use, which is more efficient. - -If you need access to the actual Python object (e.g. you need to access methods that are not decorated as actions), you can use the :func:`~labthings_fastapi.dependencies.raw_thing.raw_thing_dependency` function instead. This will give you the actual Python object, but you will need to supply all the arguments of the actions, including dependencies, yourself. - -Non-Thing dependencies ----------------------- - -LabThings provides several other dependencies, which can usually be imported directly as annotated types. For example, if your action needs to display messages as it runs, you may use an `.InvocationLogger`: - -.. code-block:: python - - import labthings_fastapi as lt - - class NoisyCounter(lt.Thing): - def count_in_logs(self, logger: lt.deps.InvocationLogger): - for i in range(10): - logger.info(f"Counter is now {i}") - -Most common dependencies can be found within `labthings_fastapi.deps`. - -.. _`FastAPI documentation`: https://fastapi.tiangolo.com/tutorial/dependencies/ \ No newline at end of file diff --git a/docs/source/dependencies/example.py b/docs/source/dependencies/example.py deleted file mode 100644 index e676f124..00000000 --- a/docs/source/dependencies/example.py +++ /dev/null @@ -1,31 +0,0 @@ -"""An example of how Things can use other Things via dependencies.""" - -from typing import Annotated -from fastapi import Depends -import labthings_fastapi as lt -from labthings_fastapi.example_things import MyThing - -MyThingClient = lt.deps.direct_thing_client_class(MyThing, "mything") -MyThingDep = Annotated[MyThingClient, Depends()] - - -class TestThing(lt.Thing): - """A test thing with a counter property and a couple of actions.""" - - @lt.action - def increment_counter(self, my_thing: MyThingDep) -> None: - """Increment the counter on another thing.""" - my_thing.increment_counter() - - -server = lt.ThingServer( - { - "mything": MyThing, - "testthing": TestThing, - } -) - -if __name__ == "__main__": - import uvicorn - - uvicorn.run(server.app, port=5000) diff --git a/docs/source/index.rst b/docs/source/index.rst index 9cbcb8c4..e0b99b85 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -12,13 +12,13 @@ Documentation for LabThings-FastAPI properties.rst documentation.rst thing_slots.rst - dependencies/dependencies.rst blobs.rst concurrency.rst using_things.rst see_also.rst examples.rst wot_core_concepts.rst + removed_features.rst autoapi/index developer_notes/index.rst diff --git a/docs/source/removed_features.rst b/docs/source/removed_features.rst new file mode 100644 index 00000000..e6af2574 --- /dev/null +++ b/docs/source/removed_features.rst @@ -0,0 +1,8 @@ +Removed Features +================ + +.. _dependencies: + +Dependencies +------------ +The use of dependencies for inter-`.Thing` communication was removed in version 0.1. See :ref:`thing_slots` and `.ThingServerInterface` for a more intuitive way to access that functionality. \ No newline at end of file diff --git a/src/labthings_fastapi/__init__.py b/src/labthings_fastapi/__init__.py index 4f50df2d..d9a2b0a6 100644 --- a/src/labthings_fastapi/__init__.py +++ b/src/labthings_fastapi/__init__.py @@ -25,7 +25,6 @@ from .properties import property, setting, DataProperty, DataSetting from .actions import action from .endpoints import endpoint -from . import deps from . import outputs from .outputs import blob from .server import ThingServer, cli @@ -53,7 +52,6 @@ "action", "thing_slot", "endpoint", - "deps", "outputs", "blob", "ThingServer", diff --git a/src/labthings_fastapi/actions.py b/src/labthings_fastapi/actions.py index 433a56b8..0aaf760a 100644 --- a/src/labthings_fastapi/actions.py +++ b/src/labthings_fastapi/actions.py @@ -48,7 +48,6 @@ from .logs import add_thing_log_destination from .utilities import model_to_dict, wrap_plain_types_in_rootmodel from .invocations import InvocationModel, InvocationStatus -from .dependencies.invocation import NonWarningInvocationID from .exceptions import ( InvocationCancelledError, InvocationError, @@ -352,7 +351,6 @@ def invoke_action( self, action: ActionDescriptor, thing: Thing, - id: uuid.UUID, input: Any, dependencies: dict[str, Any], ) -> Invocation: @@ -366,8 +364,6 @@ def invoke_action( :param thing: is the object on which we are running the ``action``, i.e. it is supplied to the function wrapped by ``action`` as the ``self`` argument. - :param id: is a `uuid.UUID` used to identify the invocation, for example - when polling its status via HTTP. :param input: is a `pydantic.BaseModel` representing the body of the HTTP request that invoked the action. It is supplied to the function as keyword arguments. @@ -381,7 +377,7 @@ def invoke_action( thing=thing, input=input, dependencies=dependencies, - id=id, + id=uuid.uuid4(), ) self.append_invocation(thread) thread.start() @@ -821,7 +817,6 @@ def add_to_fastapi(self, app: FastAPI, thing: Thing) -> None: # the function to the decorator. def start_action( body: Any, # This annotation will be overwritten below. - id: NonWarningInvocationID, background_tasks: BackgroundTasks, **dependencies: Any, ) -> InvocationModel: @@ -831,7 +826,6 @@ def start_action( thing=thing, input=body, dependencies=dependencies, - id=id, ) background_tasks.add_task(action_manager.expire_invocations) return action.response() diff --git a/src/labthings_fastapi/base_descriptor.py b/src/labthings_fastapi/base_descriptor.py index 3502852c..43624986 100644 --- a/src/labthings_fastapi/base_descriptor.py +++ b/src/labthings_fastapi/base_descriptor.py @@ -247,7 +247,7 @@ class BaseDescriptorInfo( encountered directly by someone using LabThings, except as a base class for `.Action`\ , `.Property` and others. - LabThings uses descriptors to represent the :ref:`affordances` of a `.Thing`\ . + LabThings uses descriptors to represent the :ref:`wot_affordances` of a `.Thing`\ . However, passing descriptors around isn't very elegant for two reasons: * Holding references to Descriptor objects can confuse static type checkers. diff --git a/src/labthings_fastapi/client/in_server.py b/src/labthings_fastapi/client/in_server.py deleted file mode 100644 index 3215fe9f..00000000 --- a/src/labthings_fastapi/client/in_server.py +++ /dev/null @@ -1,344 +0,0 @@ -"""A mock client that uses a Thing directly. - -When `.Thing` objects interact on the server, it can be very useful to -use an interface that is identical to the `.ThingClient` used to access -the same `.Thing` remotely. This means that code can run either on the -server or on a client, e.g. in a Jupyter notebook where it is much -easier to debug. See :ref:`things_from_things` for more detail. - -Currently `.DirectThingClient` is not a subclass of `.ThingClient`, -that may need to change. It's a good idea to create a -`.DirectThingClient` at module level, so that type hints work. - - -""" - -from __future__ import annotations -from functools import wraps -import inspect -import logging -from typing import Any, Mapping, Optional, Union -from warnings import warn -from pydantic import BaseModel -from ..actions import ActionDescriptor - -from ..properties import BaseProperty -from ..utilities import attributes -from . import PropertyClientDescriptor -from ..thing import Thing -from ..dependencies.thing_server import find_thing_server -from fastapi import Request - - -__all__ = ["DirectThingClient", "direct_thing_client_class"] - - -class DirectThingClient: - """A wrapper for `.Thing` that is a work-a-like for `.ThingClient`. - - This class is used to create a class that works like `.ThingClient` - but does not communicate over HTTP. Instead, it wraps a `.Thing` object - and calls its methods directly. - - It is not yet 100% identical to `.ThingClient`, in particular `.ThingClient` - returns a lot of data directly as deserialised from JSON, while this class - generally returns `pydantic.BaseModel` instances, without serialisation. - - `.DirectThingClient` is generally not used on its own, but is subclassed - (often dynamically) to add the actions and properties of a particular - `.Thing`. - """ - - __globals__ = globals() # "bake in" globals so dependency injection works - thing_class: type[Thing] - """The class of the underlying `.Thing` we are wrapping.""" - thing_name: str - """The name of the Thing on the server.""" - - def __init__(self, request: Request, **dependencies: Mapping[str, Any]) -> None: - r"""Wrap a `.Thing` so it works like a `.ThingClient`. - - This class is designed to be used as a FastAPI dependency, and will - retrieve a `.Thing` based on its ``thing_path`` attribute. - Finding the Thing by class may also be an option in the future. - - :param request: This is a FastAPI dependency to access the - `fastapi.Request` object, allowing access to various resources. - :param \**dependencies: Further arguments will be added - dynamically by subclasses, by duplicating this method and - manipulating its signature. Adding arguments with annotated - type hints instructs FastAPI to inject dependency arguments, - such as access to other `.Things`. - """ - warn( - "`DirectThingClient` is deprecated and will be removed in v0.1.0. Use " - "`lt.thing_slot` instead.", - DeprecationWarning, - stacklevel=2, - ) - server = find_thing_server(request.app) - self._wrapped_thing = server.things[self.thing_name] - self._request = request - self._dependencies = dependencies - - -def property_descriptor( - property_name: str, - model: Union[type, BaseModel], - description: Optional[str] = None, - readable: bool = True, - writeable: bool = True, - property_path: Optional[str] = None, -) -> PropertyClientDescriptor: - """Create a correctly-typed descriptor that gets and/or sets a property. - - .. todo:: - This is copy-pasted from labthings_fastapi.client.__init__.property_descriptor - TODO: refactor this into a shared function. - - Create a descriptor object that wraps a property. This is for use on - a `.DirectThingClient` subclass. - - :param property_name: should be the name of the property (i.e. the - name it takes in the thing description, and also the name it is - assigned to in the class). - :param model: the Python ``type`` or a ``pydantic.BaseModel`` that - represents the datatype of the property. - :param description: text to use for a docstring. - :param readable: whether the property may be read (i.e. has ``__get__``). - :param writeable: whether the property may be written to. - :param property_path: the URL of the ``getproperty`` and ``setproperty`` - HTTP endpoints. Currently these must both be the same. These are - relative to the ``base_url``, i.e. the URL of the Thing Description. - - :return: a descriptor allowing access to the specified property. - """ - - class P(PropertyClientDescriptor): - name = property_name - type = model - path = property_path or property_name - - def __get__( - self: PropertyClientDescriptor, - obj: Optional[DirectThingClient] = None, - _objtype: Optional[type[DirectThingClient]] = None, - ) -> Any: - if obj is None: - return self - return getattr(obj._wrapped_thing, self.name) - - def __set__( - self: PropertyClientDescriptor, obj: DirectThingClient, value: Any - ) -> None: - setattr(obj._wrapped_thing, self.name, value) - - def set_readonly( - self: PropertyClientDescriptor, obj: DirectThingClient, value: Any - ) -> None: - raise AttributeError("This property is read-only.") - - if readable: - __get__.__annotations__["return"] = model - P.__get__ = __get__ # type: ignore[attr-defined] - if writeable: - __set__.__annotations__["value"] = model - P.__set__ = __set__ # type: ignore[attr-defined] - else: - set_readonly.__annotations__["value"] = model - P.__set__ = set_readonly # type: ignore[attr-defined] - if description: - P.__doc__ = description - return P() - - -class DependencyNameClashError(KeyError): - """A dependency argument name is used inconsistently. - - A current limitation of `.DirectThingClient` is that the dependency - arguments (see :ref:`dependencies`) are collected together in a single - dictionary. This makes the assumption that, if a name is reused, it is - reused for the same dependency. - - When names are reused, we check if the values match. If not, this - exception is raised. - """ - - def __init__(self, name: str, existing: type, new: type) -> None: - """Create a DependencyNameClashError. - - See class docstring for an explanation of the error. - - :param name: the name of the clashing dependencies. - :param existing: the dependency type annotation in the dictionary. - :param new: the clashing type annotation. - """ - super().__init__( - f"{self.__doc__}\n\n" - f"This clash is with name: {name}.\n" - f"Its value is currently {existing}, which clashes with {new}." - ) - - -def add_action( - attrs: dict[str, Any], - dependencies: list[inspect.Parameter], - name: str, - action: ActionDescriptor, -) -> None: - """Generate an action method and adds it to an attrs dict. - - FastAPI Dependencies are appended to the `dependencies` list. - This list should later be converted to type hints on the class - initialiser, so that FastAPI supplies the dependencies when - the `.DirectThingClient` is initialised. - - :param attrs: the attributes of a soon-to-be-created `.DirectThingClient` - subclass. This will be passed to `type()` to create the subclass. - We will add the action method to this dictionary. - :param dependencies: lists the dependency parameters that will be - injected by FastAPI as arguments to the class ``__init__``. - Any dependency parameters of the supplied ``action`` should be - added to this list. - :param name: the name of the action. Should be the name of the - attribute, i.e. we will set ``attrs[name]``, and also match - the ``name`` in the supplied action descriptor. - :param action: an `.ActionDescriptor` to be wrapped. - - :raise DependencyNameClashError: if dependencies are inconsistent. - """ - - @wraps(action.func) - def action_method(self: DirectThingClient, **kwargs: Any) -> Any: - dependency_kwargs = { - param.name: self._dependencies[param.name] - for param in action.dependency_params - } - kwargs_and_deps = {**kwargs, **dependency_kwargs} - return getattr(self._wrapped_thing, name)(**kwargs_and_deps) - - attrs[name] = action_method - # We collect up all the dependencies, so that we can - # resolve them when we create the client. - for param in action.dependency_params: - included = False - for existing_param in dependencies: - if existing_param.name == param.name: - # Currently, each name may only have one annotation, across - # all actions - this is a limitation we should fix. - if existing_param.annotation != param.annotation: - raise DependencyNameClashError( - param.name, existing_param.annotation, param.annotation - ) - included = True - if not included: - dependencies.append(param) - - -def add_property( - attrs: dict[str, Any], property_name: str, property: BaseProperty -) -> None: - """Add a property to a DirectThingClient subclass. - - We create a new descriptor using `.property_descriptor` and add it - to the ``attrs`` dictionary as ``property_name``. - - :param attrs: the attributes of a soon-to-be-created `.DirectThingClient` - subclass. This will be passed to `type()` to create the subclass. - We will add the property to this dictionary. - :param property_name: the name of the property. Should be the name of the - attribute, i.e. we will set ``attrs[name]``. - :param property: a `.PropertyDescriptor` to be wrapped. - """ - attrs[property_name] = property_descriptor( - property_name, - property.model, - description=property.description, - writeable=not property.readonly, - readable=True, - ) - - -def direct_thing_client_class( - thing_class: type[Thing], - thing_name: str, - actions: Optional[list[str]] = None, -) -> type[DirectThingClient]: - r"""Create a DirectThingClient from a Thing class and a path. - - This is a class, not an instance: it's designed to be a FastAPI dependency. - - :param thing_class: The `.Thing` subclass that will be wrapped. - :param thing_name: The name of the `.Thing` on the server. - :param actions: An optional list giving a subset of actions that will be - accessed. If this is specified, it may reduce the number of FastAPI - dependencies we need. - - :return: a subclass of `DirectThingClient` with attributes that match the - properties and actions of ``thing_class``. The ``__init__`` method - will have annotations that instruct FastAPI to supply all the - dependencies needed by its actions. - - This class may be used as a FastAPI dependency: see :ref:`things_from_things`. - """ - warn( - "`direct_thing_client_class` is deprecated and will be removed in v0.1.0. " - "Use `lt.thing_slot` instead.", - DeprecationWarning, - stacklevel=3, # This is called from `direct_thing_client_dependency` so we - # need stacklevel=3 to point to user code. - ) - - def init_proxy( - self: DirectThingClient, request: Request, **dependencies: Mapping[str, Any] - ) -> None: - r"""Initialise a DirectThingClient (this docstring will be replaced). - - :param self: The DirectThingClient instance we're initialising. - :param request: a FastAPI Request option (will be supplied by FastAPI). - :param \**dependencies: Other keyword arguments will be saved as - dependencies. FastAPI will look at the signature (which we will - manipulate below) to determine these. - """ - # NB this definition isimportant, as we must modify its signature. - # Inheriting __init__ means we'll accidentally modify the signature - # of `DirectThingClient` with bad results. - DirectThingClient.__init__(self, request, **dependencies) - - init_proxy.__doc__ = f"""Initialise a client for {thing_class}""" - - # Using a class definition gets confused by the scope of the function - # arguments - this is equivalent to a class definition but all the - # arguments are evaluated in the right scope. - client_attrs = { - "thing_class": thing_class, - "thing_name": thing_name, - "__doc__": f"A client for {thing_class} named {thing_name}", - "__init__": init_proxy, - } - dependencies: list[inspect.Parameter] = [] - for name, item in attributes(thing_class): - if isinstance(item, BaseProperty): - add_property(client_attrs, name, item) - elif isinstance(item, ActionDescriptor): - if actions is None or name in actions: - add_action(client_attrs, dependencies, name, item) - else: - continue # Ignore actions that aren't in the list - else: - for affordance in ["property", "action", "event"]: - if hasattr(item, f"{affordance}_affordance"): - logging.warning( - f"DirectThingClient doesn't support custom affordances, " - f"ignoring {name}" - ) - # This block of code makes dependencies show up in __init__ so - # they get resolved. It's more or less copied from the `action` descriptor. - sig = inspect.signature(init_proxy) - params = [p for p in sig.parameters.values() if p.name != "dependencies"] - init_proxy.__signature__ = sig.replace( # type: ignore[attr-defined] - parameters=params + dependencies - ) - return type( - f"{thing_class.__name__}DirectClient", (DirectThingClient,), client_attrs - ) diff --git a/src/labthings_fastapi/dependencies/__init__.py b/src/labthings_fastapi/dependencies/__init__.py deleted file mode 100644 index cfd52214..00000000 --- a/src/labthings_fastapi/dependencies/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -r"""Resources that may be requested using annotated types. - -:ref:`actions` often need to access resources outside of the host `.Thing`\ , for -example invoking actions or accessing properties on other `.Thing`\ s or -calling methods provided by the server. - -:ref:`dependencies` are a `FastAPI concept`_ that is reused in LabThings to allow -:ref:`actions` to request resources in a way that plays nicely with type hints -and is easy to intercept for testing. - -There is more documentation at :ref:`dependencies` for how this works within -LabThings. - -.. _`FastAPI concept`: https://fastapi.tiangolo.com/tutorial/dependencies/ -""" diff --git a/src/labthings_fastapi/dependencies/blocking_portal.py b/src/labthings_fastapi/dependencies/blocking_portal.py deleted file mode 100644 index d0e57521..00000000 --- a/src/labthings_fastapi/dependencies/blocking_portal.py +++ /dev/null @@ -1,77 +0,0 @@ -"""FastAPI dependency for a blocking portal. - -This allows dependencies that are called by threaded code to send things back -to the async event loop. See :ref:`concurrency` for more details. - -Threaded code can call asynchronous code in the `anyio` event loop used by -`fastapi`, if an `anyio.BlockingPortal` is used. - -The `.ThingServer` sets up an `anyio.from_thread.BlockingPortal` when the server starts -(in `.ThingServer.lifespan`). This may be accessed from an action using the -`.BlockingPortal` dependency in this module. - -.. note:: - - The blocking portal is accessed via a dependency to ensure we only ever - use the blocking portal attached to the server handling the current - request. - - This may be simplified in the future, as a `.Thing` can only ever be - attached to one `.ThingServer`, and each `.ThingServer` corresponds - to exactly one event loop. That means a mechanism may be introduced in - the future that allows `.Thing` code to access a blocking portal without - the need for a dependency. -""" - -from __future__ import annotations -from typing import Annotated -from warnings import warn -from fastapi import Depends, Request -from anyio.from_thread import BlockingPortal as RealBlockingPortal -from .thing_server import find_thing_server -from ..exceptions import ServerNotRunningError - - -def blocking_portal_from_thing_server(request: Request) -> RealBlockingPortal: - r"""Return the blocking portal from our ThingServer. - - This is for use as a FastAPI dependency, to allow threaded code to call - async code. See the module-level docstring for :mod:`.blocking_portal`. - - :param request: The `fastapi.Request` object, supplied by the :ref:`dependencies` - mechanism. - - :return: the `anyio.from_thread.BlockingPortal` allowing access to the - `.ThingServer`\ 's event loop. - - :raises ServerNotRunningError: if the server does not have an available - blocking portal. This should not normally happen, as dependencies - are only evaluated while the server is running. - """ - warn( - "The blocking portal dependency is deprecated and will be removed in v0.1.0. " - "Use `Thing.thing_server_interface` instead.", - DeprecationWarning, - stacklevel=2, - ) - portal = find_thing_server(request.app).blocking_portal - if portal is None: # pragma: no cover - raise ServerNotRunningError( - "Could not get the blocking portal from the server." - # This should never happen, as the blocking portal is added - # and removed in `.ThingServer.lifecycle`. - # As dependencies are only evaluated while the server is running, - # this error should never be raised. - ) - return portal - - -BlockingPortal = Annotated[ - RealBlockingPortal, Depends(blocking_portal_from_thing_server) -] -""" -A ready-made dependency type for a blocking portal. If you use an argument with -type `.BlockingPortal`, FastAPI will automatically inject the blocking portal. -This is simply shorthand for `anyio.from_thread.BlockingPortal` annotated with -``Depends(blocking_portal_from_thing_server)``. -""" diff --git a/src/labthings_fastapi/dependencies/invocation.py b/src/labthings_fastapi/dependencies/invocation.py deleted file mode 100644 index 0b432f18..00000000 --- a/src/labthings_fastapi/dependencies/invocation.py +++ /dev/null @@ -1,160 +0,0 @@ -"""FastAPI dependencies for invocation-specific resources. - -There are a number of LabThings-FastAPI features that are specific to each -invocation of an action. These may be accessed using the :ref:`dependencies` in -this module. - -It's important to understand how FastAPI handles dependencies when looking -at the code in this module. Each dependency (i.e. each callable passed as -the argument to `fastapi.Depends` in an annotated type) will be evaluated -only once per HTTP request. This means that we don't need to cache -`.InvocationID` and pass it between the functions, because the same ID -will be passed to every dependency that has an argument with the annotated -type `.InvocationID`. - -When an action is invoked with a ``POST`` request, the endpoint function -responsible always has dependencies for the `.InvocationID` and -`.CancelHook`. These are added to the `.Invocation` thread that is created. -If the action declares dependencies with these types, it will receive the -same objects. This avoids the need for the action to be aware of its -`.Invocation`. - -.. note:: - - Currently, `.invocation_logger` is called from `.Invocation.run` with the - invocation ID as an argument, and is not a direct dependency of the action's - ``POST`` endpoint. - - This doesn't duplicate the returned logger object, as - `logging.getLogger` may be called multiple - times and will return the same `logging.Logger` object provided it is - called with the same name. -""" - -from __future__ import annotations -import uuid -from typing import Annotated -from warnings import warn -from fastapi import Depends -import logging -from ..invocation_contexts import CancelEvent -from ..logs import THING_LOGGER - - -def invocation_id_internal() -> uuid.UUID: - """Generate a UUID for an action invocation. - - This is for use as a FastAPI dependency (see :ref:`dependencies`). - - Because `fastapi` only evaluates each dependency once per HTTP - request, the `.UUID` we generate here is available to all of - the dependencies declared by the ``POST`` endpoint that starts - an action. - - Any dependency that has a parameter with the type hint - `.InvocationID` will be supplied with the ID we generate - here, it will be consistent within one HTTP request, and will - be unique for each request (i.e. for each invocation of the - action). - - This dependency is used by the `.InvocationLogger`, `.CancelHook` - and other resources to ensure they all have the same ID, even - before the `.Invocation` object has been created. - - :return: A unique ID for the current HTTP request, i.e. for this - invocation of an action. - """ - return uuid.uuid4() - - -NonWarningInvocationID = Annotated[uuid.UUID, Depends(invocation_id_internal)] -"""A FastAPI dependency that supplies the invocation ID. - -This is equivalent to `.InvocationID`, but does not raise a deprecation -warning. It should only be used by internal LabThings functions. -""" - - -def invocation_id(id: NonWarningInvocationID) -> uuid.UUID: - """Wrap the invocation ID dependency. - - This exists to provide a deprecation warning, and calls `.invocation_id`. - - :param id: The invocation ID, supplied by FastAPI. - - :return: The same invocation ID. - """ - warn( - "The invocation ID dependency is deprecated and will be removed in v0.1.0. " - "Use `Thing.invocation_id` instead.", - DeprecationWarning, - stacklevel=3, - ) - return id - - -InvocationID = Annotated[uuid.UUID, Depends(invocation_id)] -"""A FastAPI dependency that supplies the invocation ID. - -This calls :func:`.invocation_id` to generate a new `.UUID`. It is used -to supply the invocation ID when an action is invoked. - -Any dependency of an action may access the invocation ID by -using this dependency. -""" - - -def invocation_logger(id: NonWarningInvocationID) -> logging.Logger: - """Make a logger object for an action invocation. - - This function should be used as a dependency for an action, and - will supply a logger that's specific to each invocation of that - action. This is how `.Invocation.log` is generated. - - :param id: The Invocation ID, supplied as a FastAPI dependency. - - :return: A `logging.Logger` object specific to this invocation. - """ - warn( - "The invocation logger dependency is deprecated and will be removed in " - "v0.1.0. Use `Thing.logger` instead.", - DeprecationWarning, - stacklevel=3, - ) - return THING_LOGGER.getChild("OLD_DEPENDENCY_LOGGER") - - -InvocationLogger = Annotated[logging.Logger, Depends(invocation_logger)] -"""A FastAPI dependency supplying a logger for the action invocation. - -This calls `.invocation_logger` to generate a logger for the current -invocation. For details of how to use dependencies, see :ref:`dependencies`. -""" - - -def invocation_cancel_hook(id: NonWarningInvocationID) -> CancelHook: - """Make a cancel hook for a particular invocation. - - This is for use as a FastAPI dependency, and will create a - `.CancelEvent` for use with a particular `.Invocation`. - - :param id: The invocation ID, supplied by FastAPI. - - :return: a `.CancelHook` event. - """ - warn( - "The cancel hook dependency is deprecated and will be removed in v0.1.0. " - "Use `lt.cancellable_sleep` or `lt.raise_if_cancelled` instead.", - DeprecationWarning, - stacklevel=3, - ) - return CancelEvent(id) - - -CancelHook = Annotated[CancelEvent, Depends(invocation_cancel_hook)] -"""FastAPI dependency for an event that allows invocations to be cancelled. - -This is an annotated type that returns a `.CancelEvent`, which can be used -to raise `.InvocationCancelledError` exceptions if the invocation is -cancelled, usually by a ``DELETE`` request to the invocation's URL. -""" diff --git a/src/labthings_fastapi/dependencies/metadata.py b/src/labthings_fastapi/dependencies/metadata.py deleted file mode 100644 index b9f4686d..00000000 --- a/src/labthings_fastapi/dependencies/metadata.py +++ /dev/null @@ -1,92 +0,0 @@ -"""FastAPI dependency to get metadata from all Things. - -This module defines a FastAPI dependency (see :ref:`dependencies`) that will -retrieve metadata from every `.Thing` on the server. This is intended to -simplify the task of adding metadata to data collected by `.Thing` instances. -""" - -from __future__ import annotations -from typing import Annotated, Any, Callable -from collections.abc import Mapping -from warnings import warn - -from fastapi import Depends, Request - -from .thing_server import find_thing_server - - -def thing_states_getter(request: Request) -> Callable[[], Mapping[str, Any]]: - """Generate a function to retrieve metadata from all Things in this server. - - .. warning:: - - This function is deprecated in favour of the `.ThingServerInterface`, which - is available as a property of every Thing. - See `.ThingServerInterface.get_thing_states` for more information. - - This is intended to make it easy for a `.Thing` to summarise the other - `.Things` in the same server, as is often appropriate when embedding metadata - in data files. For example, it's used to populate the ``UserComment`` - EXIF field in images saved by the OpenFlexure Microscope. - - This is intended for use as a FastAPI dependency, so the ``request`` argument - will be supplied automatically. - - This function does not collect the metadata when it is run. Instead, we - return a function that will collect the metadata when it is called. This - delays collection of metadata until it is needed. - - Delaying collection of metadata is useful because FastAPI dependencies are - evaluated only once, before the action starts. If we collect metadata then, - there is no way for it to change during an action, so the metadata may be - out of date. - - For example, if we take - a Z stack of microscope images, we need to collect metadata after each image - in order to ensure the recorded position of the stage is up to date. - - Bear in mind that actions may call other actions, so even if you have - a very simple or short action that will not cause metadata to change, it - may be called by a longer action where that isn't true. Dependencies will be - evaluated before the calling action starts, so stale metadata is still a - possibility in very short actions. - - :param request: the `fastapi.Request` object, supplied automatically when - used as a dependency. See :ref:`dependencies`. - - :return: a function that returns a dictionary of metadata. - """ - warn( - "The `GetThingStates` dependency is deprecated and will be removed in v0.1.0. " - "Use `Thing.thing_server_interface.get_thing_states` instead.", - DeprecationWarning, - stacklevel=2, - ) - server = find_thing_server(request.app) - - def get_metadata() -> dict[str, Any]: - """Retrieve metadata from all Things on the server. - - :return: a dictionary of metadata, with the `.Thing` names as keys. - """ - return {k: v.thing_state for k, v in server.things.items()} - - return get_metadata - - -GetThingStates = Annotated[ - Callable[[], Mapping[str, Any]], Depends(thing_states_getter) -] -r"""A ready-made FastAPI dependency, returning a function to collect metadata. - -.. warning:: - - This dependency is deprecated in favour of the `.ThingServerInterface`\ . - -This calls `.thing_states_getter` to provide a function that supplies a -dictionary of metadata. It describes the state of all `.Thing` instances on -the current `.ThingServer` as reported by their ``thing_state`` property. - -Use this wherever you need to collect summary metadata to embed in data -files. -""" diff --git a/src/labthings_fastapi/dependencies/raw_thing.py b/src/labthings_fastapi/dependencies/raw_thing.py deleted file mode 100644 index f1957212..00000000 --- a/src/labthings_fastapi/dependencies/raw_thing.py +++ /dev/null @@ -1,130 +0,0 @@ -"""FastAPI dependency to obtain a `.Thing` directly. - -This module allows actions to obtain a `.Thing` instance without the -`.DirectThingClient` wrapper. As a rule, it is best to use `.DirectThingClient` -where possible. -""" - -from __future__ import annotations -from typing import Annotated, Callable, TypeVar -from warnings import warn - -from fastapi import Depends, Request - -from ..thing import Thing -from .thing_server import find_thing_server - - -ThingInstance = TypeVar("ThingInstance", bound=Thing) - - -def find_raw_thing_by_class( - cls: type[ThingInstance], -) -> Callable[[Request], ThingInstance]: - """Generate a function that locates the instance of a Thing subclass. - - .. warning:: - - Using a `.Thing` directly can be tricky: unless you really need to, it is - usually better to use a `.DirectThingClient`, which provides an interface - that should be identical to the HTTP thing client in Python. This is safer, - and means code should be easier to translate between server and client-side. - - - In order to access the instance of ``OtherThing`` attached to your thing server, - declare your argument type as: - - .. code-block:: python - - OtherThingDep = Annotated[ - OtherThing, Depends(find_raw_thing_by_class(OtherThing)) - ] - - - def endpoint(other_thing: OtherThingDep): - pass - - LabThings will supply this argument automatically through the :ref:`dependencies` - mechanism. - - Note that this function *returns* a dependency - it should be called with - arguments inside `fastapi.Depends`. - - :param cls: is the `.Thing` subclass that will be returned by the dependency. - - :return: a dependency suitable for use with `fastapi.Depends` (see example). - """ - - def find_raw_thing(request: Request) -> ThingInstance: - """Locate a Thing based on its class. - - This function is generated by `.find_raw_thing_by_class`, see - the documentation there. - - :param request: is supplied by FastAPI - - :return: an instance of the `.Thing` subclass specified when the - dependency was created. - """ - warn( - "`find_raw_thing` is deprecated and will be removed in v0.1.0. " - "Use `lt.thing_slot` instead.", - DeprecationWarning, - stacklevel=2, - ) - server = find_thing_server(request.app) - return server.thing_by_class(cls) - - return find_raw_thing - - -def raw_thing_dependency(cls: type[ThingInstance]) -> type[ThingInstance]: - """Generate a dependency that will supply a particular Thing at runtime. - - .. warning:: - - If it is possible to use a `.direct_thing_client_dependency` instead, - that is preferable. The current function supplies a `.Thing` directly - and does not supply dependency parameters or enforce the public API. - - This function should make it possible for an action to obtain a `.Thing` - object directly. If you declare a type alias using this function, it will - include an annotation that prompts FastAPI to supply the instance of the - class. - - .. warning:: - - Most linters and type checkers will not accept the result of a function - call as a valid type. It may be preferable to use - `.find_raw_thing_by_class` directly, even though it is slightly more - verbose. - - Usage: - - .. code-block:: python - - from my_other_thing import MyOtherThing as MyOtherThingClass - - MyOtherThing = raw_thing_dependency(MyOtherThingClass) - - - class MyThing(Thing): - @action - def do_something(self, other_thing: MyOtherThing) -> None: - "This action needs no arguments" - other_thing.function_only_available_in_python() - - :param cls: The class of the Thing that will be supplied - - :return: An annotated type that works as a dependency to supply an - instance of ``cls`` at runtime. - """ - warn( - "`raw_thing_dependency` is deprecated and will be removed in v0.1.0. " - "Use `lt.thing_slot` instead.", - DeprecationWarning, - stacklevel=2, - ) - return Annotated[ # type: ignore[return-value] - cls, Depends(find_raw_thing_by_class(cls)) - ] diff --git a/src/labthings_fastapi/dependencies/thing.py b/src/labthings_fastapi/dependencies/thing.py deleted file mode 100644 index 9b7abf87..00000000 --- a/src/labthings_fastapi/dependencies/thing.py +++ /dev/null @@ -1,58 +0,0 @@ -r"""FastAPI dependency to allow `.Thing`\ s to depend on each other. - -This module defines a mechanism to obtain a `.DirectThingClient` that -wraps another `.Thing` on the same server. See :ref:`things_from_things` and -:ref:`dependencies` for more detail. - -.. note:: - - `.direct_thing_client_dependency` may confuse linters and type - checkers, as types should not be the result of a function call. - You may wish to manually create an annotated type using - `.direct_thing_client_class`. -""" - -from __future__ import annotations -from typing import Annotated, Optional - -from fastapi import Depends - -from ..thing import Thing -from ..client.in_server import direct_thing_client_class - - -def direct_thing_client_dependency( - thing_class: type[Thing], - thing_path: str, - actions: Optional[list[str]] = None, -) -> type[Thing]: - """Make an annotated type to allow Things to depend on each other. - - This function returns an annotated type that may be used as a FastAPI - dependency. The dependency will return a `.DirectThingClient` that - wraps the specified `.Thing`. This should be a drop-in replacement for - `.ThingClient` so that code is consistent whether run in an action, or - in a script or notebook on a remote computer. - - See :ref:`things_from_things` and :ref:`dependencies`. - - .. note:: - - `.direct_thing_client_dependency` may confuse linters and type - checkers, as types should not be the result of a function call. - You may wish to manually create an annotated type using - `.direct_thing_client_class`. - - :param thing_class: The class of the thing to connect to - :param thing_path: The path to the thing on the server - :param actions: The actions that the client should be able to perform. - If this is specified, only those actions will be available. If it is - `None` (default), all actions will be available. - - Note that the dependencies of all available actions will be added to - your endpoint - so it is best to only specify the actions you need, in - order to avoid spurious extra dependencies. - :return: A type annotation that will cause FastAPI to supply a direct thing client - """ - C = direct_thing_client_class(thing_class, thing_path, actions=actions) - return Annotated[C, Depends()] # type: ignore[return-value] diff --git a/src/labthings_fastapi/dependencies/thing_server.py b/src/labthings_fastapi/dependencies/thing_server.py deleted file mode 100644 index a89e3ef6..00000000 --- a/src/labthings_fastapi/dependencies/thing_server.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Retrieve the `.ThingServer` object. - -This module provides a function that will retrieve the `.ThingServer` -based on the `fastapi.Request` object. It may be used as a dependency with -``Annotated[ThingServer, Depends(thing_server_from_request)]``. - -See :ref:`dependencies` for more information on the dependency mechanism, -and :ref:`things_from_things` for more on how `.Things` interact. - -.. note:: - - This module does not provide a ready-made annotated type to use as a - dependency. Doing so would mean this module has a hard dependency on - `.ThingServer` and cause circular references. See above for the - annotated type, which you may define in any code that needs it. - -.. note:: - - The rationale for this function is that we want to make sure `.Thing` - instances only access the server associated with the current request. - This means that we use the `fastapi.Request` to look up the - `fastapi.FastAPI` app, and then use the app to look up the `.ThingServer`. - - As each `.Thing` is connected to exactly one `.ThingServer`, this may - become unnecessary in the future as the server could be exposed as a - property of the `.Thing`. -""" - -from __future__ import annotations -from weakref import WeakSet -from typing import TYPE_CHECKING -from warnings import warn -from fastapi import FastAPI, Request - -if TYPE_CHECKING: - from ..server import ThingServer - -_thing_servers: WeakSet[ThingServer] = WeakSet() - - -def find_thing_server(app: FastAPI) -> ThingServer: - """Find the ThingServer associated with an app. - - This function will return the `.ThingServer` object that contains - a particular `fastapi.FastAPI` app. The app is available as part - of the `fastapi.Request` object, so this makes it possible to - get the `.ThingServer` in dependency functions. - - This function will not work as a dependency, but - `.thing_server_from_request` will. - - :param app: The `fastapi.FastAPI` application that implements the - `.ThingServer`, i.e. this is ``thing_server.app``. - - :return: the `.ThingServer` that owns the ``app``. - - :raise RuntimeError: if there is no `.ThingServer` associated - with the current FastAPI application. This should only happen - if this function is called on a `fastapi.FastAPI` instance - that was not created by a `.ThingServer`. - """ - warn( - "`find_thing_server` and `thing_server_from_request` are deprecated " - "and will be removed in v0.1.0. Use `Thing.thing_server_interface` " - "instead.", - DeprecationWarning, - stacklevel=2, - ) - for server in _thing_servers: - if server.app == app: - return server - raise RuntimeError("No ThingServer found for this app") - - -def thing_server_from_request(request: Request) -> ThingServer: - """Retrieve the `.ThingServer` from a request. - - This is for use as a FastAPI dependency, so the thing server is - retrieved from the request object. See `.find_thing_server`. - - It may be used as a dependency with: - - .. code-block:: python - - ServerDep = Annotated[ThingServer, Depends(thing_server_from_request)] - - This is not provided as a ready-made annotated type because it would - introduce a hard dependency on the :mod:`.server` module and cause circular - references. - - :param request: is supplied automatically by FastAPI when used - as a dependency. - - :return: the `.ThingServer` handling the current request. - """ - return find_thing_server(request.app) diff --git a/src/labthings_fastapi/deps.py b/src/labthings_fastapi/deps.py deleted file mode 100644 index 2b540ce9..00000000 --- a/src/labthings_fastapi/deps.py +++ /dev/null @@ -1,30 +0,0 @@ -"""FastAPI dependencies for LabThings. - -The symbols in this module are type annotations that can be used in -the arguments of Action methods (or FastAPI endpoints) to -automatically supply the required dependencies. - -See the documentation on :ref:`dependencies` for more details of how to use -these. -""" - -from .dependencies.blocking_portal import BlockingPortal -from .dependencies.invocation import InvocationID, InvocationLogger, CancelHook -from .dependencies.metadata import GetThingStates -from .dependencies.raw_thing import raw_thing_dependency -from .dependencies.thing import direct_thing_client_dependency -from .client.in_server import direct_thing_client_class, DirectThingClient - -# The symbols in __all__ are part of our public API. See note -# in src/labthings_fastapi/__init__.py for more details. -__all__ = [ - "BlockingPortal", - "InvocationID", - "InvocationLogger", - "CancelHook", - "GetThingStates", - "raw_thing_dependency", - "direct_thing_client_dependency", - "direct_thing_client_class", - "DirectThingClient", -] diff --git a/src/labthings_fastapi/outputs/blob.py b/src/labthings_fastapi/outputs/blob.py index fc9b46f5..03dc570a 100644 --- a/src/labthings_fastapi/outputs/blob.py +++ b/src/labthings_fastapi/outputs/blob.py @@ -93,7 +93,6 @@ def get_image(self) -> MyImageBlob: Literal, Mapping, ) -from warnings import warn from weakref import WeakValueDictionary from tempfile import TemporaryDirectory import uuid @@ -881,40 +880,6 @@ def response(self) -> Response: ) -def blob_type(media_type: str) -> type[Blob]: - r"""Create a `.Blob` subclass for a given media type. - - This convenience function may confuse static type checkers, so it is usually - clearer to make a subclass instead, e.g.: - - .. code-block:: python - - class MyImageBlob(Blob): - media_type = "image/png" - - :param media_type: the media type that the new `.Blob` subclass will use. - - :return: a subclass of `.Blob` with the specified media type. - - :raise ValueError: if the media type contains ``'`` or ``\``. - """ - warn( - "`blob_type` is deprecated and will be removed in v0.1.0. " - "Create a subclass of `Blob` instead.", - DeprecationWarning, - stacklevel=2, - ) - if "'" in media_type or "\\" in media_type: - raise ValueError("media_type must not contain single quotes or backslashes") - return type( - f"{media_type.replace('/', '_')}_blob", - (Blob,), - { - "media_type": media_type, - }, - ) - - router = APIRouter() """A FastAPI router for BlobData download endpoints.""" diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index 01c7f73d..09ca85f3 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -27,7 +27,6 @@ from ..thing import Thing from ..thing_server_interface import ThingServerInterface from ..thing_description._model import ThingDescription -from ..dependencies.thing_server import _thing_servers # noqa: F401 from .config_model import ( ThingsConfig, ThingServerConfig, @@ -105,7 +104,6 @@ def __init__( self.blocking_portal: Optional[BlockingPortal] = None self.startup_status: dict[str, str | dict] = {"things": {}} global _thing_servers # noqa: F824 - _thing_servers.add(self) # The function calls below create and set up the Things. self._things = self._create_things() self._connect_things() diff --git a/tests/old_dependency_tests/README.md b/tests/old_dependency_tests/README.md deleted file mode 100644 index 0c5f50d9..00000000 --- a/tests/old_dependency_tests/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Old-style dependency tests - -The test files in this folder use the old (pre-v0.0.12) dependency mechanism. This will be removed in v0.1.0, and these tests are preserved here to ensure they work until then. Test files of the same name exist in the parent module, but they have been migrated to use the newer syntax (i.e. not to use dependencies). As of v0.1.0, this folder will be deleted, and the duplication will go away. - -It felt cleaner to duplicate the tests temporarily, rather than try to test two different forms of the syntax in the same file. This way, we keep the old syntax out of the test suite, preserving enough to check it's not broken until it moves from deprecated to gone. diff --git a/tests/old_dependency_tests/__init__.py b/tests/old_dependency_tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/old_dependency_tests/module_with_deps.py b/tests/old_dependency_tests/module_with_deps.py deleted file mode 100644 index 95d7a9c7..00000000 --- a/tests/old_dependency_tests/module_with_deps.py +++ /dev/null @@ -1,37 +0,0 @@ -"""A module for testing dependencies. - -This module provides some classes that are used as dependencies by unit tests. -Note that `from __future__ import annotations` is not used here. If it is used, -we would need to add the following to the classes: - -.. code-block:: python - - class Whatever: - __globals__ = globals() # "bake in" globals so dependency injection works - -This relates to the way FastAPI resolves annotations to objects. There's an issue -thread that discusses the work-around above explicitly, but it's part of a bigger -issue discussed here: - -https://github.com/pydantic/pydantic/issues/2678 - -""" - -from dataclasses import dataclass -from typing import Annotated -from fastapi import Depends, Request - - -class FancyID: - def __init__(self, r: Request): - self.id = 1234 - - -FancyIDDep = Annotated[FancyID, Depends()] - - -@dataclass -class ClassDependsOnFancyID: - """A dataclass that will request a FancyID when used as a Dependency.""" - - sub: FancyIDDep diff --git a/tests/old_dependency_tests/test_action_cancel.py b/tests/old_dependency_tests/test_action_cancel.py deleted file mode 100644 index 1633076b..00000000 --- a/tests/old_dependency_tests/test_action_cancel.py +++ /dev/null @@ -1,208 +0,0 @@ -""" -This tests that actions may be cancelled. -""" - -import uuid -import pytest -from fastapi.testclient import TestClient -from ..temp_client import poll_task, task_href -import labthings_fastapi as lt -import time - - -pytestmark = pytest.mark.filterwarnings( - "ignore:.*removed in v0.1.0.*:DeprecationWarning" -) - - -class CancellableCountingThing(lt.Thing): - counter: int = lt.property(default=0) - check: bool = lt.property(default=False) - """Whether the count has been cancelled. - - This variable is used to check that the action can detect a cancel event - and react by performing another task, in this case, setting this variable. - """ - - @lt.action - def count_slowly(self, cancel: lt.deps.CancelHook, n: int = 10): - for _i in range(n): - try: - cancel.sleep(0.1) - except lt.exceptions.InvocationCancelledError as e: - # Set check to true to show that cancel was called. - self.check = True - raise (e) - self.counter += 1 - - @lt.action - def count_slowly_but_ignore_cancel(self, cancel: lt.deps.CancelHook, n: int = 10): - """ - Used to check that cancellation alter task behaviour - """ - counting_increment = 1 - for _i in range(n): - try: - cancel.sleep(0.1) - except lt.exceptions.InvocationCancelledError: - # Rather than cancel, this disobedient task just counts faster - counting_increment = 3 - self.counter += counting_increment - - @lt.action - def count_and_only_cancel_if_asked_twice( - self, cancel: lt.deps.CancelHook, n: int = 10 - ): - """ - A task that changes behaviour on cancel, but if asked a second time will cancel - """ - cancelled_once = False - counting_increment = 1 - for _i in range(n): - try: - cancel.sleep(0.1) - except lt.exceptions.InvocationCancelledError as e: - # If this is the second time, this is called actually cancel. - if cancelled_once: - raise (e) - # If not, remember that this cancel event happened. - cancelled_once = True - # Reset the CancelHook - cancel.clear() - # Count backwards instead! - counting_increment = -1 - self.counter += counting_increment - - -@pytest.fixture -def server(): - """Create a server with a CancellableCountingThing added.""" - server = lt.ThingServer({"counting_thing": CancellableCountingThing}) - return server - - -@pytest.fixture -def counting_thing(server): - """Retrieve the CancellableCountingThing from the server.""" - return server.things["counting_thing"] - - -@pytest.fixture -def client(server): - with TestClient(server.app) as client: - yield client - - -def test_invocation_cancel(counting_thing, client): - """ - Test that an invocation can be cancelled and the associated - exception handled correctly. - """ - assert counting_thing.counter == 0 - assert not counting_thing.check - response = client.post("/counting_thing/count_slowly", json={}) - response.raise_for_status() - # Use `client.delete` to cancel the task! - cancel_response = client.delete(task_href(response.json())) - # Raise an exception is this isn't a 2xx response - cancel_response.raise_for_status() - invocation = poll_task(client, response.json()) - assert invocation["status"] == "cancelled" - assert counting_thing.counter < 9 - # Check that error handling worked - assert counting_thing.check - - -def test_invocation_that_refuses_to_cancel(counting_thing, client): - """ - Test that an invocation can detect a cancel request but choose - to modify behaviour. - """ - assert counting_thing.counter == 0 - response = client.post( - "/counting_thing/count_slowly_but_ignore_cancel", json={"n": 5} - ) - response.raise_for_status() - # Use `client.delete` to try to cancel the task! - cancel_response = client.delete(task_href(response.json())) - # Raise an exception is this isn't a 2xx response - cancel_response.raise_for_status() - invocation = poll_task(client, response.json()) - # As the task ignored the cancel. It should return completed - assert invocation["status"] == "completed" - # Counter should be greater than 5 as it counts faster if cancelled! - assert counting_thing.counter > 5 - - -def test_invocation_that_needs_cancel_twice(counting_thing, client): - """ - Test that an invocation can interpret cancel to change behaviour, but - can really cancel if requested a second time - """ - # First cancel only once: - assert counting_thing.counter == 0 - response = client.post( - "/counting_thing/count_and_only_cancel_if_asked_twice", json={"n": 5} - ) - response.raise_for_status() - # Use `client.delete` to try to cancel the task! - cancel_response = client.delete(task_href(response.json())) - # Raise an exception is this isn't a 2xx response - cancel_response.raise_for_status() - invocation = poll_task(client, response.json()) - # As the task ignored the cancel. It should return completed - assert invocation["status"] == "completed" - # Counter should be less than 0 as it should started counting backwards - # almost immediately. - assert counting_thing.counter < 0 - - # Next cancel twice. - counting_thing.counter = 0 - assert counting_thing.counter == 0 - response = client.post( - "/counting_thing/count_and_only_cancel_if_asked_twice", json={"n": 5} - ) - response.raise_for_status() - # Use `client.delete` to try to cancel the task! - cancel_response = client.delete(task_href(response.json())) - # Raise an exception is this isn't a 2xx response - cancel_response.raise_for_status() - # Cancel again - cancel_response2 = client.delete(task_href(response.json())) - # Raise an exception is this isn't a 2xx response - cancel_response2.raise_for_status() - invocation = poll_task(client, response.json()) - # As the task ignored the cancel. It should return completed - assert invocation["status"] == "cancelled" - # Counter should be less than 0 as it should started counting backwards - # almost immediately. - assert counting_thing.counter < 0 - - -def test_late_invocation_cancel_responds_503(counting_thing, client): - """ - Test that cancelling an invocation after it completes returns a 503 response. - """ - assert counting_thing.counter == 0 - assert not counting_thing.check - response = client.post("/counting_thing/count_slowly", json={"n": 1}) - response.raise_for_status() - # Sleep long enough that task completes. - time.sleep(0.3) - poll_task(client, response.json()) - # Use `client.delete` to cancel the task! - cancel_response = client.delete(task_href(response.json())) - # Check a 503 code is returned - assert cancel_response.status_code == 503 - # Check counter reached it's target - assert counting_thing.counter == 1 - # Check that error handling wasn't called - assert not counting_thing.check - - -def test_cancel_unknown_task(counting_thing, client): - """ - Test that cancelling an unknown invocation returns a 404 response - """ - cancel_response = client.delete(f"/invocations/{uuid.uuid4()}") - assert cancel_response.status_code == 404 diff --git a/tests/old_dependency_tests/test_action_logging.py b/tests/old_dependency_tests/test_action_logging.py deleted file mode 100644 index c91c122a..00000000 --- a/tests/old_dependency_tests/test_action_logging.py +++ /dev/null @@ -1,132 +0,0 @@ -""" -This tests the log that is returned in an action invocation -""" - -import logging -from fastapi.testclient import TestClient -import pytest -from ..temp_client import poll_task -import labthings_fastapi as lt -from labthings_fastapi.invocations import LogRecordModel -from labthings_fastapi.logs import THING_LOGGER - - -pytestmark = pytest.mark.filterwarnings( - "ignore:.*removed in v0.1.0.*:DeprecationWarning" -) - - -class ThingThatLogsAndErrors(lt.Thing): - LOG_MESSAGES = [ - "message 1", - "message 2", - ] - - @lt.action - def action_that_logs(self, logger: lt.deps.InvocationLogger): - for m in self.LOG_MESSAGES: - logger.info(m) - - @lt.action - def action_with_unhandled_error(self, logger: lt.deps.InvocationLogger): - raise RuntimeError("I was asked to raise this error.") - - @lt.action - def action_with_invocation_error(self, logger: lt.deps.InvocationLogger): - raise lt.exceptions.InvocationError("This is an error, but I handled it!") - - -@pytest.fixture -def client(): - """Set up a Thing Server and yield a client to it.""" - server = lt.ThingServer({"log_and_error_thing": ThingThatLogsAndErrors}) - with TestClient(server.app) as client: - yield client - - -def test_invocation_logging(caplog, client): - """Check the expected items appear in the log when an action is invoked.""" - with caplog.at_level(logging.INFO, logger=THING_LOGGER.name): - r = client.post("/log_and_error_thing/action_that_logs") - r.raise_for_status() - invocation = poll_task(client, r.json()) - assert invocation["status"] == "completed" - assert len(invocation["log"]) == len(ThingThatLogsAndErrors.LOG_MESSAGES) - assert len(invocation["log"]) == len(caplog.records) - for expected, entry in zip( - ThingThatLogsAndErrors.LOG_MESSAGES, invocation["log"], strict=True - ): - assert entry["message"] == expected - - -def test_unhandled_error_logs(caplog, client): - """Check that a log with a traceback is raised if there is an unhandled error.""" - with caplog.at_level(logging.INFO, logger=THING_LOGGER.name): - r = client.post("/log_and_error_thing/action_with_unhandled_error") - r.raise_for_status() - invocation = poll_task(client, r.json()) - assert invocation["status"] == "error" - assert len(invocation["log"]) == len(caplog.records) == 1 - assert caplog.records[0].levelname == "ERROR" - # There is a traceback - assert caplog.records[0].exc_info is not None - - -def test_invocation_error_logs(caplog, client): - """Check that expected errors are logged without a traceback.""" - with caplog.at_level(logging.INFO, logger=THING_LOGGER.name): - r = client.post("/log_and_error_thing/action_with_invocation_error") - r.raise_for_status() - invocation = poll_task(client, r.json()) - assert invocation["status"] == "error" - assert len(invocation["log"]) == len(caplog.records) == 1 - assert caplog.records[0].levelname == "ERROR" - # There is not a traceback - assert caplog.records[0].exc_info is None - - -def test_logrecordmodel(): - record = logging.LogRecord( - name="recordName", - level=logging.INFO, - pathname="dummy/path", - lineno=0, - msg="a string message", - args=None, - exc_info=None, - ) - m = LogRecordModel.model_validate(record, from_attributes=True) - assert m.levelname == record.levelname - - -def test_logrecord_args(): - record = logging.LogRecord( - name="recordName", - level=logging.INFO, - pathname="dummy/path", - lineno=0, - msg="mambo number %d", - args=(5,), - exc_info=None, - ) - m = LogRecordModel.model_validate(record, from_attributes=True) - assert m.message == record.getMessage() - - -def test_logrecord_too_many_args(): - """Calling getMessage() will raise an error - but it should still validate - - If it doesn't validate, it will cause every subsequent call to the action's - invocation record to return a 500 error. - """ - record = logging.LogRecord( - name="recordName", - level=logging.INFO, - pathname="dummy/path", - lineno=0, - msg="mambo number %d", - args=(5, 6), - exc_info=None, - ) - m = LogRecordModel.model_validate(record, from_attributes=True) - assert m.message.startswith("Error") diff --git a/tests/old_dependency_tests/test_dependencies.py b/tests/old_dependency_tests/test_dependencies.py deleted file mode 100644 index 2c564297..00000000 --- a/tests/old_dependency_tests/test_dependencies.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Tests for the dependency classes. - -NB see test_thing_dependencies for tests of the dependency-injection mechanism -for actions. -""" - -from dataclasses import dataclass -from typing import Annotated -from fastapi import Depends, FastAPI, Request -from labthings_fastapi.deps import InvocationID -from fastapi.testclient import TestClient -import pytest -from .module_with_deps import FancyIDDep - - -pytestmark = pytest.mark.filterwarnings( - "ignore:.*removed in v0.1.0.*:DeprecationWarning" -) - - -def test_invocation_id(): - """Test our InvocationID dependency doesn't cause an error""" - app = FastAPI() - - @app.post("/invoke") - def invoke(id: InvocationID) -> bool: - return True - - with TestClient(app) as client: - r = client.post("/invoke") - assert r.status_code == 200 - - -def test_fancy_id(): - """Test a stub dependency from another file, using a type alias""" - # TODO: can probably delete this, it's tested by test_invocation_id - app = FastAPI() - - @app.post("/invoke_fancy") - def invoke_fancy(id: FancyIDDep) -> bool: - return True - - with TestClient(app) as client: - r = client.post("/invoke_fancy") - assert r.status_code == 200 - - -def test_dependency_needing_request(): - """Test a dependency that requires Request object""" - app = FastAPI() - - @dataclass - class DepClass: - r"""A class that has a dependency in its __init__. - - This is a dataclass, so __init__ is generated automatically and - will have an argument `sub` with type `Request`\ . - """ - - sub: Request - - @app.post("/dep") - def endpoint(id: Annotated[DepClass, Depends()]) -> bool: - return True - - with TestClient(app) as client: - r = client.post("/dep") - assert r.status_code == 200 - invocation = r.json() - assert invocation is True diff --git a/tests/old_dependency_tests/test_dependencies_2.py b/tests/old_dependency_tests/test_dependencies_2.py deleted file mode 100644 index 4ca515be..00000000 --- a/tests/old_dependency_tests/test_dependencies_2.py +++ /dev/null @@ -1,176 +0,0 @@ -"""MWE of a pydantic/FastAPI problem, kept for safety - -Class-based dependencies in modules with `from __future__ import annotations` -fail if they have sub-dependencies, because the global namespace is not found by -pydantic. The work-around was to add a line to each class definition: -``` -__globals__ = globals() -``` -This bakes in the global namespace of the module, and allows FastAPI to correctly -traverse the dependency tree. - -This is related to https://github.com/fastapi/fastapi/issues/4557 and may have -been fixed upstream in FastAPI. - -The tests in this module were written while I was figuring this out: they mostly -test things from FastAPI that obviously work, but I will leave them in here as -mitigation against something changing in the future. -""" - -from dataclasses import dataclass -from typing import Annotated -from fastapi import Depends, FastAPI -from fastapi.testclient import TestClient -import pytest -from .module_with_deps import FancyIDDep, FancyID, ClassDependsOnFancyID -import labthings_fastapi as lt - - -pytestmark = pytest.mark.filterwarnings( - "ignore:.*removed in v0.1.0.*:DeprecationWarning" -) - - -def test_dep_from_module(): - """Add an endpoint that uses a dependency from another file""" - app = FastAPI() - - @app.post("/invoke_fancy") - def invoke_fancy(id: Annotated[FancyID, Depends()]) -> dict: - return {"id": "me"} - - with TestClient(app) as client: - r = client.post("/invoke_fancy") - assert r.status_code == 200 - invocation = r.json() - assert isinstance(invocation["id"], str) - - -def test_dep_from_module_with_subdep(): - """Add an endpoint that uses a dependency from another file""" - app = FastAPI() - - @app.post("/endpoint") - def endpoint(id: Annotated[ClassDependsOnFancyID, Depends()]) -> bool: - # Verify that the dependency is supplied, including its sub-dependency - assert id.sub.id == 1234 - return True - - with TestClient(app) as client: - r = client.post("/endpoint") - assert r.status_code == 200 - - -def test_fancy_id_aliased(): - """Add an endpoint that uses a dependency from another file""" - app = FastAPI() - - @app.post("/invoke_fancy") - def invoke_fancy(id: FancyIDDep) -> dict: - return {"id": "me"} - - with TestClient(app) as client: - r = client.post("/invoke_fancy") - assert r.status_code == 200 - invocation = r.json() - assert isinstance(invocation["id"], str) - - -def test_fancy_id_default(): - """Add an endpoint that uses a dependency from another file""" - app = FastAPI() - - @app.post("/invoke_fancy") - def invoke_fancy(id: Annotated[FancyID, Depends()]) -> dict: - return {"id": "me"} - - with TestClient(app) as client: - r = client.post("/invoke_fancy") - assert r.status_code == 200 - invocation = r.json() - assert isinstance(invocation["id"], str) - - -def test_class_dep(): - """Add an endpoint that uses a dependency class""" - app = FastAPI() - - class DepClass: - pass - - @app.post("/dep") - def endpoint(id: Annotated[DepClass, Depends()]) -> bool: - return True - - with TestClient(app) as client: - r = client.post("/dep") - assert r.status_code == 200 - invocation = r.json() - assert invocation is True - - -def test_class_dep_with_subdep(): - """Add an endpoint that uses a dependency class with sub-dependency. - - We do this twice, using a regular class and also a dataclass. - """ - app = FastAPI() - - class SubDepClass: - pass - - class DepClass: # noqa B903 - """A regular class that has sub-dependencies via __init__. - - Note that this could be a dataclass, but we want to check both - dataclasses and normal classes.""" - - def __init__(self, sub: Annotated[SubDepClass, Depends()]): - self.sub = sub - - @app.post("/dep") - def endpoint(id: Annotated[DepClass, Depends()]) -> bool: - assert isinstance(id.sub, SubDepClass) - return True - - @dataclass - class DepDataclass: - sub: Annotated[SubDepClass, Depends()] - - @app.post("/dep2") - def endpoint2(dep: Annotated[DepDataclass, Depends()]): - assert isinstance(dep.sub, SubDepClass) - return True - - with TestClient(app) as client: - for url in ["/dep", "/dep2"]: - r = client.post(url) - assert r.status_code == 200 - invocation = r.json() - assert invocation is True - - -def test_invocation_id(): - """Add an endpoint that uses a dependency imported from another file""" - app = FastAPI() - - @app.post("/endpoint") - def invoke_fancy(id: lt.deps.InvocationID) -> bool: - return True - - with TestClient(app) as client: - r = client.post("/endpoint") - assert r.status_code == 200 - - -def test_invocation_id_alias(): - """Add an endpoint that uses a dependency alias from another file""" - app = FastAPI() - - @app.post("/endpoint") - def endpoint(id: lt.deps.InvocationID) -> bool: - return True - - with TestClient(app) as client: - r = client.post("/endpoint") - assert r.status_code == 200 diff --git a/tests/old_dependency_tests/test_dependency_metadata.py b/tests/old_dependency_tests/test_dependency_metadata.py deleted file mode 100644 index 677b0ad3..00000000 --- a/tests/old_dependency_tests/test_dependency_metadata.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -This tests metadata retrieval, as used by e.g. the camera for EXIF info -""" - -from typing import Any, Mapping -import warnings -from fastapi.testclient import TestClient -import pytest -from ..temp_client import poll_task -import labthings_fastapi as lt - - -pytestmark = pytest.mark.filterwarnings( - "ignore:.*removed in v0.1.0.*:DeprecationWarning" -) - - -class ThingOne(lt.Thing): - def __init__(self, thing_server_interface): - super().__init__(thing_server_interface=thing_server_interface) - self._a = 0 - - @lt.property - def a(self): - return self._a - - @a.setter - def _set_a(self, value): - self._a = value - - @property - def thing_state(self): - return {"a": self.a} - - -with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - ThingOneDep = lt.deps.direct_thing_client_dependency(ThingOne, "thing_one") - - -class ThingTwo(lt.Thing): - A_VALUES = [1, 2, 3] - - @property - def thing_state(self): - return {"a": 1} - - @lt.action - def count_and_watch( - self, thing_one: ThingOneDep, get_metadata: lt.deps.GetThingStates - ) -> Mapping[str, Mapping[str, Any]]: - metadata = {} - for a in self.A_VALUES: - thing_one.a = a - metadata[f"a_{a}"] = get_metadata() - return metadata - - -@pytest.fixture -def client(): - """Yield a test client connected to a ThingServer.""" - server = lt.ThingServer( - { - "thing_one": ThingOne, - "thing_two": ThingTwo, - } - ) - with TestClient(server.app) as client: - yield client - - -def test_fresh_metadata(client): - """Check that fresh metadata is retrieved by get_thing_states.""" - r = client.post("/thing_two/count_and_watch") - invocation = poll_task(client, r.json()) - assert invocation["status"] == "completed" - out = invocation["output"] - for a in ThingTwo.A_VALUES: - assert out[f"a_{a}"]["thing_one"]["a"] == a - assert out[f"a_{a}"]["thing_two"]["a"] == 1 diff --git a/tests/old_dependency_tests/test_directthingclient.py b/tests/old_dependency_tests/test_directthingclient.py deleted file mode 100644 index be97cc49..00000000 --- a/tests/old_dependency_tests/test_directthingclient.py +++ /dev/null @@ -1,165 +0,0 @@ -"""Test the DirectThingClient class. - -This module tests inter-Thing interactions. It does not yet test exhaustively, -and has been added primarily to fix #165. -""" - -import warnings -from fastapi.testclient import TestClient -import pytest -import labthings_fastapi as lt -from labthings_fastapi.deps import DirectThingClient, direct_thing_client_class -from labthings_fastapi.testing import create_thing_without_server -from ..temp_client import poll_task - - -pytestmark = pytest.mark.filterwarnings( - "ignore:.*removed in v0.1.0.*:DeprecationWarning" -) - - -class Counter(lt.Thing): - ACTION_ONE_RESULT = "Action one result!" - - @lt.action - def increment(self) -> str: - """An action that takes no arguments""" - return self.increment_internal() - - def increment_internal(self) -> str: - """An action that increments the counter.""" - self.count += self.step - return self.ACTION_ONE_RESULT - - step: int = lt.property(default=1) - count: int = lt.property(default=0, readonly=True) - - -@pytest.fixture -def counter_client(mocker) -> DirectThingClient: - r"""Instantiate a Counter and wrap it in a DirectThingClient. - - In order to make this work without a server, ``DirectThingClient`` is - subclassed, and ``__init__`` is overridden. - This could be done with ``mocker`` but it would be more verbose and - less clear. - - :param mocker: the mocker test fixture from ``pytest-mock``\ . - :returns: a ``DirectThingClient`` subclass wrapping a ``Counter``\ . - """ - counter = create_thing_without_server(Counter) - - CounterClient = direct_thing_client_class(Counter, "counter") - - class StandaloneCounterClient(CounterClient): - def __init__(self, wrapped): - self._dependencies = {} - self._request = mocker.Mock() - self._wrapped_thing = wrapped - - return StandaloneCounterClient(counter) - - -with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - CounterDep = lt.deps.direct_thing_client_dependency(Counter, "counter") - RawCounterDep = lt.deps.raw_thing_dependency(Counter) - - -class Controller(lt.Thing): - """Controller is used to test a real DirectThingClient in a server. - - This is used by ``test_directthingclient_in_server`` to verify the - client works as expected when created normally, rather than by mocking - the server. - """ - - @lt.action - def count_in_twos(self, counter: CounterDep) -> str: - """An action that needs a Counter and uses its affordances. - - This only uses methods that are part of the HTTP API, so all - of these commands should work. - """ - counter.step = 2 - assert counter.count == 0 - counter.increment() - assert counter.count == 2 - return "success" - - @lt.action - def count_internal(self, counter: CounterDep) -> str: - """An action that tries to access local-only attributes. - - This previously used `pytest.raises` but that caused the test - to hang, most likely because this will run in a background thread. - """ - try: - counter.increment_internal() - raise AssertionError("Expected error was not raised!") - except AttributeError: - # pytest.raises seems to hang. - pass - try: - counter.count = 4 - raise AssertionError("Expected error was not raised!") - except AttributeError: - # pytest.raises seems to hang. - pass - return "success" - - @lt.action - def count_raw(self, counter: RawCounterDep) -> str: - """Increment the counter using a method that is not an Action.""" - counter.count = 0 - counter.step = -1 - counter.increment_internal() - assert counter.count == -1 - return "success" - - -def test_readwrite_property(counter_client): - """Test a read/write property works as expected.""" - counter_client.step = 2 - assert counter_client.step == 2 - - -def test_readonly_property(counter_client): - """Test a read/write property works as expected.""" - assert counter_client.count == 0 - with pytest.raises(AttributeError): - counter_client.count = 10 - - -def test_action(counter_client): - """Test we can run an action.""" - assert counter_client.count == 0 - counter_client.increment() - assert counter_client.count == 1 - - -def test_method(counter_client): - """Methods that are not decorated as actions should be missing.""" - with pytest.raises(AttributeError): - counter_client.increment_internal() - # Just to double-check the line above isn't a typo... - counter_client._wrapped_thing.increment_internal() - - -@pytest.mark.parametrize("action", ["count_in_twos", "count_internal", "count_raw"]) -def test_directthingclient_in_server(action): - """Test that a Thing can depend on another Thing - - This uses the internal thing client mechanism. - """ - server = lt.ThingServer( - { - "counter": Counter, - "controller": Controller, - } - ) - with TestClient(server.app) as client: - r = client.post(f"/controller/{action}") - invocation = poll_task(client, r.json()) - assert invocation["status"] == "completed" - assert invocation["output"] == "success" diff --git a/tests/old_dependency_tests/test_thing_dependencies.py b/tests/old_dependency_tests/test_thing_dependencies.py deleted file mode 100644 index 6b1f45c4..00000000 --- a/tests/old_dependency_tests/test_thing_dependencies.py +++ /dev/null @@ -1,177 +0,0 @@ -""" -This tests Things that depend on other Things -""" - -import inspect -import warnings -from fastapi.testclient import TestClient -from fastapi import Request -import pytest -import labthings_fastapi as lt -from ..temp_client import poll_task -from labthings_fastapi.client.in_server import direct_thing_client_class -from labthings_fastapi.utilities.introspection import fastapi_dependency_params - - -pytestmark = pytest.mark.filterwarnings( - "ignore:.*removed in v0.1.0.*:DeprecationWarning" -) - - -class ThingOne(lt.Thing): - ACTION_ONE_RESULT = "Action one result!" - - @lt.action - def action_one(self) -> str: - """An action that takes no arguments""" - return self.action_one_internal() - - def action_one_internal(self) -> str: - return self.ACTION_ONE_RESULT - - -with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - ThingOneDep = lt.deps.direct_thing_client_dependency(ThingOne, "thing_one") - - -class ThingTwo(lt.Thing): - @lt.action - def action_two(self, thing_one: ThingOneDep) -> str: - """An action that needs a ThingOne""" - return thing_one.action_one() - - @lt.action - def action_two_a(self, thing_one: ThingOneDep) -> str: - """Another action that needs a ThingOne""" - return thing_one.action_one() - - -with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - ThingTwoDep = lt.deps.direct_thing_client_dependency(ThingTwo, "thing_two") - - -class ThingThree(lt.Thing): - @lt.action - def action_three(self, thing_two: ThingTwoDep) -> str: - """An action that needs a ThingTwo""" - # Note that we don't have to supply the ThingOne dependency - return thing_two.action_two() - - -def dependency_names(func: callable) -> list[str]: - """Get the names of the dependencies of a function""" - return [p.name for p in fastapi_dependency_params(func)] - - -def test_direct_thing_dependency(): - """Check that direct thing clients are distinct classes""" - ThingOneClient = direct_thing_client_class(ThingOne, "thing_one") - ThingTwoClient = direct_thing_client_class(ThingTwo, "thing_two") - print(f"{ThingOneClient}: ThingOneClient{inspect.signature(ThingOneClient)}") - for k in dir(ThingOneClient): - if k.startswith("__"): - continue - print(f"{k}: {getattr(ThingOneClient, k)}") - print(f"{ThingTwoClient}: ThingTwoClient{inspect.signature(ThingTwoClient)}") - for k in dir(ThingTwoClient): - if k.startswith("__"): - continue - print(f"{k}: {getattr(ThingTwoClient, k)}") - assert ThingOneClient is not ThingTwoClient - assert ThingOneClient.__init__ is not ThingTwoClient.__init__ - assert "thing_one" not in dependency_names(ThingOneClient) - assert "thing_one" in dependency_names(ThingTwoClient) - - -def test_interthing_dependency(): - """Test that a Thing can depend on another Thing - - This uses the internal thing client mechanism. - """ - server = lt.ThingServer({"thing_one": ThingOne, "thing_two": ThingTwo}) - with TestClient(server.app) as client: - r = client.post("/thing_two/action_two") - invocation = poll_task(client, r.json()) - assert invocation["status"] == "completed" - assert invocation["output"] == ThingOne.ACTION_ONE_RESULT - - -def test_interthing_dependency_with_dependencies(): - """Test that a Thing can depend on another Thing - - This uses the internal thing client mechanism, and requires - dependency injection for the called action - """ - server = lt.ThingServer( - {"thing_one": ThingOne, "thing_two": ThingTwo, "thing_three": ThingThree} - ) - with TestClient(server.app) as client: - r = client.post("/thing_three/action_three") - r.raise_for_status() - invocation = poll_task(client, r.json()) - assert invocation["status"] == "completed" - assert invocation["output"] == ThingOne.ACTION_ONE_RESULT - - -def test_raw_interthing_dependency(): - """Test that a Thing can depend on another Thing - - This uses the internal thing client mechanism. - """ - ThingOneDep = lt.deps.raw_thing_dependency(ThingOne) - - class ThingTwo(lt.Thing): - @lt.action - def action_two(self, thing_one: ThingOneDep) -> str: - """An action that needs a ThingOne""" - return thing_one.action_one() - - server = lt.ThingServer({"thing_one": ThingOne, "thing_two": ThingTwo}) - with TestClient(server.app) as client: - r = client.post("/thing_two/action_two") - invocation = poll_task(client, r.json()) - assert invocation["status"] == "completed" - assert invocation["output"] == ThingOne.ACTION_ONE_RESULT - - -def test_conflicting_dependencies(): - """Dependencies are stored by argument name, and can't be duplicated. - We check here that an error is raised if the same argument name is used - for two different dependencies. - - This also checks that dependencies on the same class but different - actions are recognised as "different". - """ - ThingTwoDepNoActions = lt.deps.direct_thing_client_dependency( - ThingTwo, "/thing_two/", [] - ) - - class ThingFour(lt.Thing): - @lt.action - def action_four(self, thing_two: ThingTwoDepNoActions) -> str: - return str(thing_two) - - @lt.action - def action_five(self, thing_two: ThingTwoDep) -> str: - return thing_two.action_two() - - with pytest.raises(lt.client.in_server.DependencyNameClashError): - lt.deps.direct_thing_client_dependency(ThingFour, "thing_four") - - -def check_request(): - """Check that the `Request` object has the same `app` as the server - - This is mostly just verifying that there's nothing funky in between the - Starlette `Request` object and the FastAPI `app`.""" - server = lt.ThingServer() - - @server.app.get("/check_request_app/") - def check_request_app(request: Request) -> bool: - return request.app is server.app - - with TestClient(server.app) as client: - r = client.get("/check_request_app/") - assert r.json() is True diff --git a/tests/test_actions.py b/tests/test_actions.py index 98f56095..bfc26422 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -230,7 +230,6 @@ class Example(lt.Thing): @lt.action def action( self, - portal: lt.deps.BlockingPortal, param1: int = 0, param2: str = "string", ) -> float | None: @@ -241,7 +240,6 @@ def action( @example_decorator def decorated( self, - portal: lt.deps.BlockingPortal, param1: int = 0, param2: str = "string", ) -> float | None: diff --git a/tests/test_blob_output.py b/tests/test_blob_output.py index 59d0e161..672b273d 100644 --- a/tests/test_blob_output.py +++ b/tests/test_blob_output.py @@ -90,15 +90,6 @@ def client(): yield client -@pytest.mark.filterwarnings("ignore:.*removed in v0.1.0.*:DeprecationWarning") -def test_blob_type(): - """Check we can't put dodgy values into a blob output model""" - with pytest.raises(ValueError): - lt.blob.blob_type(media_type="text/plain\\'DROP TABLES") - M = lt.blob.blob_type(media_type="text/plain") - assert M.from_bytes(b"").media_type == "text/plain" - - @pytest.mark.parametrize( ("media_type", "expected"), [ diff --git a/tests/test_docs.py b/tests/test_docs.py index bf4d0885..41c75243 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -1,8 +1,6 @@ from pathlib import Path from runpy import run_path -from fastapi.testclient import TestClient import pytest -from labthings_fastapi import ThingClient from .test_server_cli import MonitoredProcess @@ -24,20 +22,3 @@ def test_quickstart_counter(): """Check we can create a server from the command line""" p = MonitoredProcess(target=run_quickstart_counter) p.run_monitored(terminate_outputs=["Application startup complete"]) - - -@pytest.mark.filterwarnings("ignore:.*removed in v0.1.0.*:DeprecationWarning") -def test_dependency_example(): - """Check the dependency example creates a server object. - - Running the example with `__name__` set to `__main__` would serve forever, - and start a full-blown HTTP server. Instead, we create the server but do - not run it - effectively we're importing the module into `globals`. - - We then create a TestClient to try out the server without the overhead - of HTTP, which is significantly faster. - """ - globals = run_path(docs / "dependencies" / "example.py", run_name="not_main") - with TestClient(globals["server"].app) as client: - testthing = ThingClient.from_url("/testthing/", client=client) - testthing.increment_counter()