diff --git a/docs/source/public_api.rst b/docs/source/public_api.rst index d66c311b..c76f01cf 100644 --- a/docs/source/public_api.rst +++ b/docs/source/public_api.rst @@ -229,29 +229,44 @@ This page summarises the parts of the LabThings API that should be most frequent :return: When used as intended, the result is an `.EndpointDescriptor`. -.. py:class:: ThingServer(things: config_model.ThingsConfig, settings_folder: Optional[str] = None, application_config: Optional[collections.abc.Mapping[str, Any]] = None, debug: bool = False) +.. py:class:: ThingServer(config: ThingServerConfig, debug: bool = False) The `ThingServer` sets up a `fastapi.FastAPI` application and uses it to expose the capabilities of `Thing` instances over HTTP. - Full documentation of how the class works is available at `labthings_fastpi.server.ThingServer`\ . Most of the attributes of `ThingServer` should not be accessed directly by `Thing` subclasses - instead they should use the `ThingServerInterface` for a cleaner way to access the server. - - :param things: A mapping of Thing names to `~lt.Thing` subclasses, or - `ThingConfig` objects specifying the subclass, its initialisation - arguments, and any connections to other `~lt.Thing`\ s. - :param settings_folder: the location on disk where `~lt.Thing` - settings will be saved. - :param application_config: A mapping containing custom configuration for the - application. This is not processed by LabThings. Each `~lt.Thing` can access - application. This is not processed by LabThings. Each `~lt.Thing` can access - this via the Thing-Server interface. - :param debug: If ``True``, set the log level for `~lt.Thing` instances to - DEBUG. - + Full documentation of how the class works is available at `labthings_fastpi.server.ThingServer`\ . + Most of the attributes of `ThingServer` should not be accessed directly by `Thing` subclasses - instead they should use the `ThingServerInterface` for a cleaner way to access the server. - .. automethod:: labthings_fastapi.server.ThingServer.from_config - :no-index: + :param config: a `ThingServerConfig` instance (or compatible dictionary) setting the server's configuration. + :param debug: sets the log level for `~lt.Thing` instances to DEBUG if it is set to `True`\ . + :param \**kwargs: for backwards compatibility, keyword arguments are used to create the server configuration if `config` is missing. This raises a `DeprecationWarning`\ . + + .. py:method:: from_config(config: ThingServerConfig, debug: bool = False) -> ThingServer + + This method of creating a `ThingServer` is deprecated, as it is equivalent to the constructor. + + :return: a `ThingServer` with the supplied configuration. + + .. py:classmethod:: from_things(things: Mapping[str: ThingConfig | type[Thing] | str]) -> ThingServer + + This class method allows a server to be created by passing in a mapping of Thing names to Thing configurations. + Thing configurations may be either a `Thing` subclass, or an import string (e.g. ``my.module:MyClass``), or a `ThingConfig` instance (or compatible dictionary). + + .. code-block:: python + server = lt.ThingServer.from_things( + { + "my_thing": MyThingSubclass, + "their_thing": "their.module:TheirThing", + "configured_thing": { + "cls": MyThingSubclass, + "kwargs": {"a": 10}, + }, + } + ) + + :param things: a mapping of Thing names to Thing configurations. + :param \**kwargs: additional keyword arguments are passed to `ThingServerConfig`\ . .. py:class:: ThingServerInterface(server: ThingServer, name: str) diff --git a/docs/source/quickstart/counter.py b/docs/source/quickstart/counter.py index 187bb887..76f3fabf 100644 --- a/docs/source/quickstart/counter.py +++ b/docs/source/quickstart/counter.py @@ -31,7 +31,7 @@ def slowly_increase_counter(self) -> None: if __name__ == "__main__": import uvicorn - server = lt.ThingServer({"counter": TestThing}) + server = lt.ThingServer.from_things({"counter": TestThing}) # We run the server using `uvicorn`: uvicorn.run(server.app, port=5000, ws="websockets-sansio") diff --git a/docs/source/thing_slots.rst b/docs/source/thing_slots.rst index 32034d2f..13a057be 100644 --- a/docs/source/thing_slots.rst +++ b/docs/source/thing_slots.rst @@ -43,7 +43,7 @@ The following example shows the use of a `~lt.thing_slot`: things = {"thing_a": ThingA, "thing_b": ThingB} - server = lt.ThingServer(things) + server = lt.ThingServer.from_things(things) In this example, ``ThingB.thing_a`` is the simplest form of `~lt.thing_slot`: it diff --git a/docs/source/tutorial/writing_a_thing.rst b/docs/source/tutorial/writing_a_thing.rst index 1df0595a..d2996cc1 100644 --- a/docs/source/tutorial/writing_a_thing.rst +++ b/docs/source/tutorial/writing_a_thing.rst @@ -30,7 +30,7 @@ Our first Thing will pretend to be a light: we can set its brightness and turn i self.is_on = not self.is_on - server = lt.ThingServer({"light": Light}) + server = lt.ThingServer.from_things({"light": Light}) if __name__ == "__main__": import uvicorn diff --git a/src/labthings_fastapi/outputs/mjpeg_stream.py b/src/labthings_fastapi/outputs/mjpeg_stream.py index 0a79aa78..2688fe21 100644 --- a/src/labthings_fastapi/outputs/mjpeg_stream.py +++ b/src/labthings_fastapi/outputs/mjpeg_stream.py @@ -456,7 +456,7 @@ class Camera(lt.Thing): stream = MJPEGStreamDescriptor() - server = lt.ThingServer({"camera": Camera}) + server = lt.ThingServer.from_things({"camera": Camera}) :param app: the `fastapi.FastAPI` application to which we are being added. :param thing: the host `~lt.Thing` instance. diff --git a/src/labthings_fastapi/server/__init__.py b/src/labthings_fastapi/server/__init__.py index 9483baa5..82fe5ab5 100644 --- a/src/labthings_fastapi/server/__init__.py +++ b/src/labthings_fastapi/server/__init__.py @@ -6,8 +6,10 @@ See the :ref:`tutorial` for examples of how to set up a `~lt.ThingServer`. """ -from __future__ import annotations -from typing import Any, AsyncGenerator, Optional, TypeVar +import warnings +from fastapi.testclient import TestClient +from pydantic import ValidationError +from typing import Any, AsyncGenerator, Optional, TypeVar, overload from typing_extensions import Self import os import logging @@ -15,9 +17,10 @@ from fastapi import APIRouter, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from anyio.from_thread import BlockingPortal -from contextlib import asynccontextmanager, AsyncExitStack -from collections.abc import Mapping, Sequence +from contextlib import asynccontextmanager, AsyncExitStack, contextmanager +from collections.abc import Iterator, Mapping, Sequence from types import MappingProxyType +import uvicorn from ..middleware.url_for import url_for_middleware from ..thing_slots import ThingSlot @@ -61,50 +64,82 @@ class ThingServer: an `anyio.from_thread.BlockingPortal`. """ + @overload + def __init__(self, config: ThingServerConfig, *, debug: bool = False) -> None: ... + + @overload + def __init__(self, *, debug: bool = False, **kwargs: Any) -> None: ... + def __init__( self, - things: ThingsConfig, - settings_folder: Optional[str] = None, - api_prefix: str = "", - application_config: Optional[Mapping[str, Any]] = None, + config: ThingServerConfig | None = None, + *, debug: bool = False, + **kwargs: Any, ) -> None: r"""Initialise a LabThings server. + The `~lt.ThingServer` is responsible for running the code in `~lt.Thing` + instances, and making them available over the network. It should be configured + by passing a `~lt.ThingServerConfig` object (or a dictionary that can + be validated as a `~lt.ThingServerConfig` object). + + For convenience and backwards compatibility, if `config` is `None` the keyword + arguments will be passed to `~lt.ThingServerConfig` instead. Keyword arguments + may not be used if the `config` argument is used, and may be removed in the + future. + Setting up the `~lt.ThingServer` involves creating the underlying `fastapi.FastAPI` app, setting its lifespan function (used to set up and shut down the `~lt.Thing` instances), and configuring it to allow cross-origin requests. - We also create the `.ActionManager` to manage :ref:`actions` and the - `.BlobManager` to manage the downloading of :ref:`blobs`. - - :param things: A mapping of Thing names to `~lt.Thing` subclasses, or - `~lt.ThingConfig` objects specifying the subclass, its initialisation - arguments, and any connections to other `~lt.Thing`\ s. - :param settings_folder: the location on disk where `~lt.Thing` - settings will be saved. - :param api_prefix: A prefix for all API routes. This must either - be empty, or start with a slash and not end with a slash. - :param application_config: A mapping containing custom configuration for the - application. This is not processed by LabThings. Each `~lt.Thing` can - access this via the Thing-Server interface. - :param debug: If ``True``, set the log level for `~lt.Thing` instances to - DEBUG. + :param config: a `~lt.ThingServerConfig` object that configures the server, + or something that may be converted to one. + :param debug: ff ``True``, set the log level for `~lt.Thing` instances to + DEBUG. + :param \**kwargs: ff keyword arguments are supplied, they will be passed + to the constructor of `~lt.ThingServerConfig`\ . This is not allowed + if `config` is a `~lt.ThingServerConfig` object. + + :raises TypeError: if the value of `config` cannot be parsed as a + `~lt.ThingServerConfig`\ . + :raises ValueError: if keyword arguments are supplied together with `config`\ . """ self.startup_failure: dict | None = None + self._debug = debug # Note: this is safe to call multiple times. - configure_thing_logger(logging.DEBUG if debug else None) - self._config = ThingServerConfig( - things=things, - settings_folder=settings_folder, - api_prefix=api_prefix, - application_config=application_config, - ) + configure_thing_logger(logging.DEBUG if self._debug else None) + if config is not None: + try: + self._config = ThingServerConfig.model_validate(config) + except ValidationError as e: + raise TypeError( + "The value passed to `ThingServer()` could not be validated as " + "a server configuration. If you are passing a dictionary of " + "Things, this must be done using `ThingServer.from_things` instead." + ) from e + if kwargs != {}: + raise ValueError( + f"Extra keyword arguments supplied to `ThingServer()`: {kwargs}. " + "When a `ThingServerConfig` object is specified, no extra keyword " + "arguments may be supplied." + ) + else: + warnings.warn( + DeprecationWarning( + "`ThingServer` should be initialised with the `config` parameter. " + "Taking configuration options from keyword arguments will be " + "removed in a future release." + ), + stacklevel=2, + ) + self._config = ThingServerConfig(**kwargs) + if self._config.settings_folder is None: + self._config.settings_folder = "./settings" self.app = FastAPI(lifespan=self.lifespan) self._set_cors_middleware() self._set_url_for_middleware() - self.settings_folder = settings_folder or "./settings" self.action_manager = ActionManager() self.app.include_router(self.action_manager.router(), prefix=self._api_prefix) self.app.include_router(blob.router, prefix=self._api_prefix) @@ -117,18 +152,56 @@ def __init__( self._connect_things() self._attach_things_to_server() + @classmethod + def from_things( + cls, + things: ThingsConfig, + debug: bool = False, + **kwargs: Any, + ) -> Self: + r"""Create a ThingServer using a dictionary of `~lt.Thing` subclasses. + + In test and example code, it's convenient to be able to pass server and + `Thing` configurations as keyword arguments rather than a config model. + + This convenience method will turn its keyword arguments into a server + configuration and create a server based on it. + + :param things: A mapping of names to `Thing` configurations. These may + be specified as a `~lt.ThingConfig` object, a `~lt.Thing` subclass, + or an import string referencing a `~lt.Thing` subclass. + :param debug: Whether to start the server in debug mode. + :param \**kwargs: Additional keyword arguments are passed to + `~lt.ThingServerConfig`\ . + :return: a `~lt.ThingServer` instance. + """ + return cls( + ThingServerConfig( + things=things, + **kwargs, + ), + debug=debug, + ) + @classmethod def from_config(cls, config: ThingServerConfig, debug: bool = False) -> Self: r"""Create a ThingServer from a configuration model. - This is equivalent to ``ThingServer(**dict(config))``\ . + This is equivalent to ``ThingServer(config, debug=debug)``\ . :param config: The configuration parameters for the server. :param debug: If ``True``, set the log level for `~lt.Thing` instances to DEBUG. :return: A `~lt.ThingServer` configured as per the model. """ - return cls(**dict(config), debug=debug) + warnings.warn( + DeprecationWarning( + "`ThingServer.from_config()` is redundant and will be removed in " + "a future release. Use `ThingServer()` instead." + ), + stacklevel=2, + ) + return cls(config, debug=debug) def _set_cors_middleware(self) -> None: """Configure the server to allow requests from other origins. @@ -157,6 +230,26 @@ def _set_url_for_middleware(self) -> None: """ self.app.middleware("http")(url_for_middleware) + @property + def debug(self) -> bool: + """Whether the server is in debug mode.""" + return self._debug + + @property + def settings_folder(self) -> str: + """The folder in which we will store `Thing` settings. + + :raises RuntimeError: if there is no settings folder set. + This should never happen, as it's set in `__init__`. + """ + if self._config.settings_folder is None: + raise RuntimeError( + "The settings folder should be set during initialisation. " + "This may indicate a LabThings bug, or incorrect subclassing " + "of `ThingServer`." + ) + return self._config.settings_folder + @property def things(self) -> Mapping[str, Thing]: """Return a dictionary of all the things. @@ -384,3 +477,39 @@ def thing_paths(request: Request) -> Mapping[str, str]: } return router + + def serve(self, host: str = "localhost", port: int = 5000) -> None: + r"""Run the server in `uvicorn`\ . + + This method will run the server from Python, using `uvicorn.run`\ . + This is the most convenient way to run a LabThings server from Python, and + is identical to what happens when it is run from the command line. + + :param host: The IP address or hostname on which to serve. By default, this + is ``localhost`` which is only accessible from your computer. To serve + over a network on all available IPv4 addresses, use ``"0.0.0.0"``. + :param port: The port on which to serve. This defaults to 5000. + """ + uvicorn.run(self.app, host=host, port=port, ws="websockets-sansio") + + @contextmanager + def test_client(self) -> Iterator[TestClient]: + """A context manager to test out a server without binding to a port. + + This context manager will start up the server and run an event loop, but + instead of responding to requests on a network port, it uses + `fastapi.testclient.TestClient` to simulate HTTP requests. + + This is provided to simplify test code, and should not be used in production. + + :yields: a `fastapi.testclient.TestClient` to simulate HTTP requests. + + .. warning:: + + Usually, a server is only started up and shut down once. Calling this + method multiple times may have unexpected results. + + As a rule, only ever use this method in your test suite. + """ + with TestClient(self.app) as client: + yield client diff --git a/src/labthings_fastapi/server/cli.py b/src/labthings_fastapi/server/cli.py index 3369b5c3..70856ba3 100644 --- a/src/labthings_fastapi/server/cli.py +++ b/src/labthings_fastapi/server/cli.py @@ -20,7 +20,7 @@ from argparse import ArgumentParser, Namespace import sys -from typing import Literal, Optional, overload +from typing import Optional from pydantic import ValidationError import uvicorn @@ -113,25 +113,13 @@ def config_from_args(args: Namespace) -> ThingServerConfig: raise RuntimeError("No configuration (or empty configuration) provided") -@overload -def serve_from_cli( - argv: Optional[list[str]], dry_run: Literal[True] -) -> ThingServer: ... - - -@overload -def serve_from_cli(argv: Optional[list[str]], dry_run: Literal[False]) -> None: ... - - -def serve_from_cli( - argv: Optional[list[str]] = None, dry_run: bool = False -) -> ThingServer | None: +def serve_from_cli(argv: Optional[list[str]] = None) -> ThingServer: r"""Start the server from the command line. This function will parse command line arguments, load configuration, set up a server, and start it. It calls `.parse_args`, - `.config_from_args` and `~lt.ThingServer.from_config` to get a server, then - starts `uvicorn` to serve on the specified host and port. + `.config_from_args` and `~lt.ThingServer` to get a server, then + serves on the specified host and port using `uvicorn`\ . If the ``fallback`` argument is specified, errors that stop the LabThings server from starting will be handled by starting a simple @@ -143,12 +131,10 @@ def serve_from_cli( :param argv: command line arguments (defaults to arguments supplied to the current command). - :param dry_run: may be set to ``True`` to terminate after the server - has been created. This tests set-up code and verifies all of the - Things specified can be correctly loaded and instantiated, but - does not start `uvicorn`\ . - :return: the `~lt.ThingServer` instance created, if ``dry_run`` is ``True``. + :return: the `~lt.ThingServer` instance created. This is mostly useful + for test code that mocks `uvicorn.run` to allow inspection of the + server. :raise BaseException: if the server cannot start, and the ``fallback`` option is not specified. @@ -158,10 +144,9 @@ def serve_from_cli( try: config, server = None, None config = config_from_args(args) - server = ThingServer.from_config(config, True if args.debug else False) - if dry_run: - return server - uvicorn.run(server.app, host=args.host, port=args.port, ws="websockets-sansio") + server = ThingServer(config, debug=True if args.debug else False) + server.serve(host=args.host, port=args.port) + return server except BaseException as e: if args.fallback: print(f"Error: {e}") @@ -175,10 +160,10 @@ def serve_from_cli( ) uvicorn.run(app, host=args.host, port=args.port, ws="websockets-sansio") + sys.exit(3) # If we served the fallback, be clear that it wasn't a success. else: if isinstance(e, (ValidationError, ThingImportFailure)): print(f"Error reading LabThings configuration:\n{e}") sys.exit(3) else: raise e - return None # This is required as we sometimes return the server diff --git a/src/labthings_fastapi/server/config_model.py b/src/labthings_fastapi/server/config_model.py index 679a0d3a..8cff6a97 100644 --- a/src/labthings_fastapi/server/config_model.py +++ b/src/labthings_fastapi/server/config_model.py @@ -8,6 +8,7 @@ """ from pydantic import ( + AfterValidator, BaseModel, Field, ImportString, @@ -118,9 +119,25 @@ class ThingConfig(BaseModel): # type: ignore[no-redef] ) +RESERVED_THING_NAMES = ("things", "cls") + + +def check_reserved_thing_names(name: str) -> str: + """Validate a Thing name by checking it's not in a list of banned names. + + :param name: the name to check. + :return: the name, if valid. + :raises ValueError: if the name is not valid. + """ + if name in RESERVED_THING_NAMES: + raise ValueError(f"{name} is not allowed as the name for a Thing.") + return name + + ThingName = Annotated[ str, Field(min_length=1, pattern=r"^([a-zA-Z0-9\-_]+)$"), + AfterValidator(check_reserved_thing_names), ] diff --git a/tests/test_action_cancel.py b/tests/test_action_cancel.py index ea07c241..891a1504 100644 --- a/tests/test_action_cancel.py +++ b/tests/test_action_cancel.py @@ -4,7 +4,6 @@ import uuid import pytest -from fastapi.testclient import TestClient from .temp_client import poll_task, task_href import labthings_fastapi as lt import time @@ -68,7 +67,7 @@ def count_and_only_cancel_if_asked_twice(self, n: int = 10): @pytest.fixture def server(): """Create a server with a CancellableCountingThing added.""" - server = lt.ThingServer({"counting_thing": CancellableCountingThing}) + server = lt.ThingServer.from_things({"counting_thing": CancellableCountingThing}) return server @@ -80,7 +79,7 @@ def counting_thing(server): @pytest.fixture def client(server): - with TestClient(server.app) as client: + with server.test_client() as client: yield client diff --git a/tests/test_action_logging.py b/tests/test_action_logging.py index c056b7e1..3bf4eb9a 100644 --- a/tests/test_action_logging.py +++ b/tests/test_action_logging.py @@ -3,7 +3,6 @@ """ import logging -from fastapi.testclient import TestClient import pytest from .temp_client import poll_task import labthings_fastapi as lt @@ -34,8 +33,8 @@ def action_with_invocation_error(self): @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: + server = lt.ThingServer.from_things({"log_and_error_thing": ThingThatLogsAndErrors}) + with server.test_client() as client: yield client diff --git a/tests/test_action_manager.py b/tests/test_action_manager.py index 83d67a18..37242d42 100644 --- a/tests/test_action_manager.py +++ b/tests/test_action_manager.py @@ -1,4 +1,3 @@ -from fastapi.testclient import TestClient import pytest import httpx from .temp_client import poll_task @@ -28,8 +27,8 @@ def increment_counter_longlife(self): @pytest.fixture def client(): """Yield a TestClient connected to a ThingServer.""" - server = lt.ThingServer({"thing": CounterThing}) - with TestClient(server.app) as client: + server = lt.ThingServer.from_things({"thing": CounterThing}) + with server.test_client() as client: yield client diff --git a/tests/test_actions.py b/tests/test_actions.py index bfc26422..fbf892ba 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -30,8 +30,8 @@ def say_hello(self) -> str: @pytest.fixture def client(): """Yield a client connected to a ThingServer""" - server = lt.ThingServer({"thing": MyThing}) - with TestClient(server.app) as client: + server = lt.ThingServer.from_things({"thing": MyThing}) + with server.test_client() as client: yield client diff --git a/tests/test_blob_output.py b/tests/test_blob_output.py index 60144a3e..830bbe84 100644 --- a/tests/test_blob_output.py +++ b/tests/test_blob_output.py @@ -80,13 +80,13 @@ def check_passthrough(self) -> bool: @pytest.fixture def client(): """Yield a test client connected to a ThingServer.""" - server = lt.ThingServer( + server = lt.ThingServer.from_things( { "thing_one": ThingOne, "thing_two": ThingTwo, } ) - with TestClient(server.app) as client: + with server.test_client() as client: yield client diff --git a/tests/test_endpoint_decorator.py b/tests/test_endpoint_decorator.py index def69c06..1b836197 100644 --- a/tests/test_endpoint_decorator.py +++ b/tests/test_endpoint_decorator.py @@ -1,4 +1,3 @@ -from fastapi.testclient import TestClient from pydantic import BaseModel import labthings_fastapi as lt @@ -24,9 +23,9 @@ def post_method(self, body: PostBodyModel) -> str: def test_endpoints(): """Check endpoints may be added to the app and work as expected.""" - server = lt.ThingServer({"thing": MyThing}) + server = lt.ThingServer.from_things({"thing": MyThing}) thing = server.things["thing"] - with TestClient(server.app) as client: + with server.test_client() as client: # Check the function works when used directly assert thing.path_from_name() == "path_from_name" # Check it works identically over HTTP. The path is diff --git a/tests/test_fallback.py b/tests/test_fallback.py index e3a1136f..572473b4 100644 --- a/tests/test_fallback.py +++ b/tests/test_fallback.py @@ -136,7 +136,7 @@ def test_fallback_with_error(): def test_fallback_with_server(): config = lt.ThingServerConfig.model_validate(CONFIG_DICT) - server = lt.ThingServer.from_config(config) + server = lt.ThingServer(config) app.set_context(FallbackContext(server=server)) with TestClient(app) as client: response = client.get("/") @@ -166,7 +166,7 @@ def test_actual_server_fallback(): fallback. """ # ThingThatCantStart has an error in __enter__ - server = lt.ThingServer({"bad_thing": ThingThatCantStart}) + server = lt.ThingServer.from_things({"bad_thing": ThingThatCantStart}) # Starting the server is a SystemExit with pytest.raises(SystemExit, match="3") as excinfo: diff --git a/tests/test_locking_decorator.py b/tests/test_locking_decorator.py index 8caa8e15..391c5123 100644 --- a/tests/test_locking_decorator.py +++ b/tests/test_locking_decorator.py @@ -3,7 +3,6 @@ import functools from threading import RLock, Event, Thread -from fastapi.testclient import TestClient import pytest import labthings_fastapi as lt @@ -115,9 +114,9 @@ def echo_via_client(client): def test_locking_in_server(): """Check the lock works within LabThings.""" - server = lt.ThingServer({"thing": LockedExample}) + server = lt.ThingServer.from_things({"thing": LockedExample}) thing = server.things["thing"] - with TestClient(server.app) as client: + with server.test_client() as client: # Start a long task r1 = client.post("/thing/wait_wrapper", json={}) # Wait for it to start diff --git a/tests/test_logs.py b/tests/test_logs.py index ead3152d..2464cd39 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -11,7 +11,6 @@ from types import EllipsisType import pytest from uuid import UUID, uuid4 -from fastapi.testclient import TestClient from labthings_fastapi import logs from labthings_fastapi.invocations import LogRecordModel from labthings_fastapi.invocation_contexts import ( @@ -177,11 +176,12 @@ def test_configure_thing_logger(): assert dest[0].msg == "Test" -def test_cli_debug_flag(): +def test_cli_debug_flag(mocker): """ Test that using the --debug flag sets the logger level to DEBUG, and that not using it leaves the logger level at INFO. """ + mocker.patch("uvicorn.run") # Reset logger level to INFO reset_thing_logger() @@ -189,26 +189,28 @@ def test_cli_debug_flag(): logs.configure_thing_logger() # Run without --debug - # We use dry_run=True to avoid starting uvicorn # We need a dummy config dummy_json = '{"things": {}}' - serve_from_cli(["--json", dummy_json], dry_run=True) + server = serve_from_cli(["--json", dummy_json]) assert logs.THING_LOGGER.level == logging.INFO + assert server.debug is False # Run with --debug - serve_from_cli(["--json", dummy_json, "--debug"], dry_run=True) + server = serve_from_cli(["--json", dummy_json, "--debug"]) assert logs.THING_LOGGER.level == logging.DEBUG + assert server.debug is True # Reset back to INFO reset_thing_logger() -def test_cli_debug_flag_with_thing(caplog): +def test_cli_debug_flag_with_thing(caplog, mocker): """ Test that using the --debug flag allows capturing debug logs during __init__. """ + mocker.patch("uvicorn.run") # Reset logger level to INFO reset_thing_logger() @@ -221,14 +223,16 @@ def test_cli_debug_flag_with_thing(caplog): # Run without --debug and capture logs with caplog.at_level(logging.DEBUG, logger="labthings_fastapi.things"): - serve_from_cli(["--json", config_json], dry_run=True) + server = serve_from_cli(["--json", config_json]) + assert server.debug is False # There are no logs assert len(caplog.messages) == 0 # Run with --debug and capture logs with caplog.at_level(logging.DEBUG, logger="labthings_fastapi.things"): - serve_from_cli(["--json", config_json, "--debug"], dry_run=True) + server = serve_from_cli(["--json", config_json, "--debug"]) + assert server.debug is True # Check that the debug message was captured assert "Debug message during __init__" in caplog.text @@ -266,8 +270,8 @@ def test_add_thing_log_destination(): def _call_action_can_get_logs(): """Run `log_and_capture` as an action, Return the final HTTP response.""" - server = lt.ThingServer({"logging_thing": ThingThatLogs}) - with TestClient(server.app) as client: + server = lt.ThingServer.from_things({"logging_thing": ThingThatLogs}) + with server.test_client() as client: response = client.post("/logging_thing/log_and_capture", json={"msg": "foobar"}) response.raise_for_status() return poll_task(client, response.json()) diff --git a/tests/test_mjpeg_stream.py b/tests/test_mjpeg_stream.py index f00b43b1..f1d48195 100644 --- a/tests/test_mjpeg_stream.py +++ b/tests/test_mjpeg_stream.py @@ -2,7 +2,6 @@ import threading import time from PIL import Image -from fastapi.testclient import TestClient import pytest import labthings_fastapi as lt @@ -46,8 +45,8 @@ def _make_images(self): @pytest.fixture def client(): """Yield a test client connected to a ThingServer""" - server = lt.ThingServer({"telly": Telly}) - with TestClient(server.app) as client: + server = lt.ThingServer.from_things({"telly": Telly}) + with server.test_client() as client: yield client @@ -73,7 +72,7 @@ def test_mjpeg_stream(client): if __name__ == "__main__": import uvicorn - server = lt.ThingServer({"telly": Telly}) + server = lt.ThingServer.from_things({"telly": Telly}) telly = server.things["telly"] assert isinstance(telly, Telly) telly.framerate = 6 diff --git a/tests/test_numpy_type.py b/tests/test_numpy_type.py index cf1d5c9d..0d200492 100644 --- a/tests/test_numpy_type.py +++ b/tests/test_numpy_type.py @@ -2,7 +2,6 @@ from pydantic import BaseModel, RootModel import numpy as np -from fastapi.testclient import TestClient from labthings_fastapi.testing import create_thing_without_server from labthings_fastapi.types.numpy import NDArray, DenumpifyingDict @@ -115,8 +114,8 @@ def test_rootmodel(): def test_numpy_over_http(): """Read numpy array over http.""" - server = lt.ThingServer({"np_thing": MyNumpyThing}) - with TestClient(server.app) as client: + server = lt.ThingServer.from_things({"np_thing": MyNumpyThing}) + with server.test_client() as client: np_thing_client = lt.ThingClient.from_url("/np_thing/", client=client) arrayprop = np_thing_client.array_property diff --git a/tests/test_properties.py b/tests/test_properties.py index 7b6b9b6e..65e39c7d 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -5,7 +5,6 @@ from annotated_types import Ge, Le, Gt, Lt, MultipleOf, MinLen, MaxLen from pydantic import BaseModel, RootModel, ValidationError -from fastapi.testclient import TestClient import pytest import labthings_fastapi as lt @@ -204,7 +203,9 @@ class ConstrainedPropInfo: @pytest.fixture def server(): with TemporaryDirectory() as dirpath: - server = lt.ThingServer({"thing": PropertyTestThing}, settings_folder=dirpath) + server = lt.ThingServer.from_things( + {"thing": PropertyTestThing}, settings_folder=dirpath + ) yield server @@ -281,7 +282,7 @@ def test_property_get_and_set(server): to set a known value, and check it comes back when we read it with a GET request. """ - with TestClient(server.app) as client: + with server.test_client() as client: test_str = "A silly test string" # Write to the property: response = client.put("/thing/stringprop", json=test_str) @@ -298,7 +299,7 @@ def test_boolprop(server): PUT requests write to the property, and GET reads it. """ - with TestClient(server.app) as client: + with server.test_client() as client: r = client.get("/thing/boolprop") assert r.status_code == 200 # Successful read assert r.json() is False # Known initial value @@ -311,7 +312,7 @@ def test_boolprop(server): def test_decorator_with_no_annotation(server): """Test a property made with an un-annotated function.""" - with TestClient(server.app) as client: + with server.test_client() as client: r = client.get("/thing/undoc") assert r.status_code == 200 # Read the property OK assert r.json() is None # The return value was None @@ -321,7 +322,7 @@ def test_decorator_with_no_annotation(server): def test_readwrite_with_getter_and_setter(server): """Test floatprop can be read and written with a getter/setter.""" - with TestClient(server.app) as client: + with server.test_client() as client: r = client.get("/thing/floatprop") assert r.status_code == 200 # Read the property OK assert r.json() == 1.0 # Got the expected value @@ -340,7 +341,7 @@ def test_sync_action(server): This action doesn't start any extra threads. """ - with TestClient(server.app) as client: + with server.test_client() as client: # Write to the property so it has a known value r = client.put("/thing/boolprop", json=False) assert r.status_code == 201 # successful write @@ -368,7 +369,7 @@ def test_setting_from_thread(server): This checks there's nothing special about the action thread. """ - with TestClient(server.app) as client: + with server.test_client() as client: # Reset boolprop to a known state r = client.put("/thing/boolprop", json=False) assert r.status_code == 201 @@ -389,7 +390,7 @@ def test_setting_without_event_loop(): # This test may need to change, if we change the intended behaviour # Currently it should never be necessary to change properties from the # main thread, so we raise an error if you try to do so - server = lt.ThingServer({"thing": PropertyTestThing}) + server = lt.ThingServer.from_things({"thing": PropertyTestThing}) thing = server.things["thing"] assert isinstance(thing, PropertyTestThing) with pytest.raises(ServerNotRunningError): @@ -465,7 +466,7 @@ def test_constrained_properties_http(server, prop_info): It also checks that the constraints propagate to the JSONSchema. """ - with TestClient(server.app) as client: + with server.test_client() as client: r = client.get("/thing/") r.raise_for_status() thing_description = r.json() diff --git a/tests/test_server.py b/tests/test_server.py index 455229e5..b2abd183 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -6,16 +6,16 @@ import pytest import labthings_fastapi as lt -from fastapi.testclient import TestClient from starlette.routing import Route from labthings_fastapi.example_things import MyThing +from labthings_fastapi.server.config_model import ThingServerConfig def test_server_from_config_non_thing_error(): """Test a typeerror is raised if something that's not a Thing is added.""" with pytest.raises(TypeError, match="not a Thing"): - lt.ThingServer.from_config( + lt.ThingServer( lt.ThingServerConfig( things={"thingone": lt.ThingConfig(cls="builtins:object")} ) @@ -50,8 +50,8 @@ def test_server_thing_descriptions(): "slowly_increase_counter", ] - server = lt.ThingServer.from_config(conf) - with TestClient(server.app) as client: + server = lt.ThingServer(conf) + with server.test_client() as client: response = client.get("/api/thing_descriptions/") response.raise_for_status() thing_descriptions = response.json() @@ -80,7 +80,7 @@ def test_api_prefix(api_prefix): class Example(lt.Thing): """An example Thing""" - server = lt.ThingServer(things={"example": Example}, api_prefix=api_prefix) + server = lt.ThingServer.from_things({"example": Example}, api_prefix=api_prefix) paths = [route.path for route in server.app.routes if isinstance(route, Route)] # Dynamically generate expected paths based on the parametrized prefix @@ -110,13 +110,13 @@ class Example(lt.Thing): def test_things_endpoints(): """Test that the two endpoints for listing Things work.""" - server = lt.ThingServer( + server = lt.ThingServer.from_things( { "thing_a": MyThing, "thing_b": MyThing, } ) - with TestClient(server.app) as client: + with server.test_client() as client: # Check the thing_descriptions endpoint response = client.get("/thing_descriptions/") response.raise_for_status() @@ -137,3 +137,88 @@ def test_things_endpoints(): td = response.json() assert td["title"] == "MyThing" assert tds["thing_a"] == td + + +@pytest.mark.parametrize( + ("input", "validated"), + [ + (None, False), + (True, True), + (False, False), + ], +) +def test_debug_flag(input, validated): + """Check that the debug flag can be retrieved.""" + kwargs = {} + if input is not None: + kwargs["debug"] = input + server = lt.ThingServer.from_things({}, **kwargs) + assert server.debug is validated + with pytest.raises(AttributeError): + server.debug = False + + +def test_settings_folder(): + """Check that the settings folder behaves correctly.""" + # Without setting a value, it should take the default value + server = lt.ThingServer.from_things({}) + assert server.settings_folder == "./settings" + server._config.settings_folder = None # Deliberately induce error + with pytest.raises(RuntimeError): + # If the config object has None for the settings folder, + # an error should be raised. This is set to a string in + # __init__. + _ = server.settings_folder + + # The settings folder should be settable from an argument or config + server = lt.ThingServer.from_things({}, settings_folder="./mysettings") + assert server.settings_folder == "./mysettings" + + # The settings folder should be settable from an argument or config + server = lt.ThingServer( + lt.ThingServerConfig(things={}, settings_folder="./mysettings") + ) + assert server.settings_folder == "./mysettings" + + +def test_server_init(): + """Check the various different ways in which the server may be initialised.""" + config_dict = { + "things": { + "my_thing": MyThing, + }, + "api_prefix": "/api/v3", + } + config_model = ThingServerConfig(**config_dict) + + def check_server(server: lt.ThingServer, debug: bool = False): + """Make sure the server config is as expected.""" + assert len(server.things) == 1 + assert isinstance(server.things["my_thing"], MyThing) + assert server._api_prefix == "/api/v3" + assert server.debug == debug + + # The type hint doesn't match a dict, but it works anyway. + check_server(lt.ThingServer(config_dict)) + # Supplying a model is the "right" way to do it + check_server(lt.ThingServer(config_model)) + # The old usage should use `from_things` + check_server( + lt.ThingServer.from_things(config_dict["things"], api_prefix="/api/v3") + ) + check_server( + lt.ThingServer.from_things(config_model.thing_configs, api_prefix="/api/v3") + ) + check_server(lt.ThingServer(config_model, debug=True), debug=True) + # ThingServer.from_config is retired in favour of the constructor + with pytest.warns(DeprecationWarning, match="redundant"): + check_server(lt.ThingServer.from_config(config_model)) + # `things` can still be passed as kwargs, but it's deprecated + with pytest.warns(DeprecationWarning, match="keyword arguments"): + check_server(lt.ThingServer(**config_dict)) + # Supplying config and **kwargs is an error + with pytest.raises(ValueError, match="no extra keyword arguments"): + lt.ThingServer(config_model, settings_folder="./foo") + # Invalid configuration raises a TypeError, with upgrade message + with pytest.raises(TypeError, match="from_things"): + lt.ThingServer(config_dict["things"]) diff --git a/tests/test_server_cli.py b/tests/test_server_cli.py index 2a391091..9d560b70 100644 --- a/tests/test_server_cli.py +++ b/tests/test_server_cli.py @@ -81,7 +81,7 @@ def run_monitored(self, terminate_outputs=None, timeout=10): def test_server_from_config(): """Check we can create a server from a config object""" - server = ThingServer.from_config(CONFIG) + server = ThingServer(CONFIG) assert isinstance(server, ThingServer) @@ -112,16 +112,19 @@ def test_serve_from_cli_with_config_file(): @pytest.mark.slow -def test_serve_with_no_config_without_multiprocessing(): +def test_serve_with_no_config_without_multiprocessing(mocker): + """Test that no configuration options result in an error.""" with raises(RuntimeError): - serve_from_cli([], dry_run=True) + serve_from_cli([]) @pytest.mark.slow def test_serve_with_no_config(): """Check an empty config fails, using multiprocessing. + This is important, because if it passes it means our tests above - are not actually testing anything. + are not actually testing anything - perhaps the errors are being + swallowed by `check_serve_from_cli`. """ with raises(RuntimeError): check_serve_from_cli([]) diff --git a/tests/test_server_config_model.py b/tests/test_server_config_model.py index 0ead57fb..272ba4eb 100644 --- a/tests/test_server_config_model.py +++ b/tests/test_server_config_model.py @@ -68,6 +68,8 @@ def test_ThingConfig(): "thing/with/slashes", "trailingslash/", "/leadingslash", + "things", + "cls", ] diff --git a/tests/test_settings.py b/tests/test_settings.py index eb7ac54e..1911a796 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -7,7 +7,6 @@ import os from pydantic import BaseModel -from fastapi.testclient import TestClient import labthings_fastapi as lt from labthings_fastapi.testing import create_thing_without_server @@ -176,11 +175,13 @@ def test_functional_settings_save(tempdir): ``floatsetting`` is a functional setting, we should also test a `.DataSetting` for completeness. """ - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) setting_file = _get_setting_file(server, "thing") # No setting file created when first added assert not os.path.isfile(setting_file) - with TestClient(server.app) as client: + with server.test_client() as client: # We write a new value to the property with a PUT request r = client.put("/thing/floatsetting", json=2.0) # A 201 return code means the operation succeeded (i.e. @@ -202,12 +203,14 @@ def test_data_settings_save(tempdir): This uses ``intsetting`` which is a `.DataSetting` so it tests a different code path to the functional setting above.""" - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) setting_file = _get_setting_file(server, "thing") # The settings file should not be created yet - it's created the # first time we write to a setting. assert not os.path.isfile(setting_file) - with TestClient(server.app) as client: + with server.test_client() as client: # Change the value using a PUT request r = client.put("/thing/boolsetting", json=True) # Check the value was written successfully (201 response code) @@ -225,13 +228,15 @@ def test_data_settings_save(tempdir): def test_settings_dict_save(tempdir): """Check settings are saved if the dict is updated in full""" - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) setting_file = _get_setting_file(server, "thing") thing = server.things["thing"] assert isinstance(thing, ThingWithSettings) # No setting file created when first added assert not os.path.isfile(setting_file) - with TestClient(server.app): + with server.test_client(): thing.dictsetting = {"c": 3} assert os.path.isfile(setting_file) with open(setting_file, "r", encoding="utf-8") as file_obj: @@ -245,13 +250,15 @@ def test_settings_dict_internal_update(tempdir): This behaviour is not ideal, but it is documented. If the behaviour is updated then the documentation should be updated and this test removed """ - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) setting_file = _get_setting_file(server, "thing") thing = server.things["thing"] assert isinstance(thing, ThingWithSettings) # No setting file created when first added assert not os.path.isfile(setting_file) - with TestClient(server.app): + with server.test_client(): thing.dictsetting["a"] = 4 # As only an internal member of the dictornary was set, the saving was not # triggered. @@ -260,7 +267,9 @@ def test_settings_dict_internal_update(tempdir): def test_settings_load(tempdir, caplog): """Check settings can be loaded from disk when added to server""" - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) setting_file = _get_setting_file(server, "thing") del server setting_json = json.dumps( @@ -276,7 +285,9 @@ def test_settings_load(tempdir, caplog): file_obj.write(setting_json) with caplog.at_level(logging.WARNING): # Add thing to server and check new settings are loaded - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) assert len(caplog.records) == 0 thing = server.things["thing"] assert isinstance(thing, ThingWithSettings) @@ -289,7 +300,9 @@ def test_settings_load(tempdir, caplog): def test_load_extra_settings(caplog, tempdir): """Load from setting file. Extra setting in file should create a warning.""" - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) setting_file = _get_setting_file(server, "thing") del server setting_dict = _settings_dict(floatsetting=3.0, stringsetting="bar") @@ -302,7 +315,9 @@ def test_load_extra_settings(caplog, tempdir): # Recreate the server and check for the error with caplog.at_level(logging.WARNING): # Add thing to server - _ = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + _ = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) assert len(caplog.records) == 1 assert caplog.records[0].levelname == "WARNING" assert caplog.records[0].name == "labthings_fastapi.things.thing" @@ -311,7 +326,9 @@ def test_load_extra_settings(caplog, tempdir): def test_try_loading_corrupt_settings(tempdir, caplog): """Load from setting file. Extra setting in file should create a warning.""" # Create the server once, so we can get the settings path - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) setting_file = _get_setting_file(server, "thing") del server @@ -327,7 +344,9 @@ def test_try_loading_corrupt_settings(tempdir, caplog): # Recreate the server and check for the warning in logs with caplog.at_level(logging.WARNING): # Add thing to server - _ = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + _ = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) assert len(caplog.records) == 1 assert caplog.records[0].levelname == "WARNING" assert caplog.records[0].name == "labthings_fastapi.things.thing" @@ -336,7 +355,9 @@ def test_try_loading_corrupt_settings(tempdir, caplog): def test_try_loading_setting_file_without_mapping(tempdir, caplog): """Load from setting file that isn't a JSON object.""" # Create the server once, so we can get the settings path - server = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + server = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) setting_file = _get_setting_file(server, "thing") del server @@ -345,7 +366,9 @@ def test_try_loading_setting_file_without_mapping(tempdir, caplog): # Recreate the server and check for the warning in logs with caplog.at_level(logging.WARNING): # Add thing to server - _ = lt.ThingServer({"thing": ThingWithSettings}, settings_folder=tempdir) + _ = lt.ThingServer.from_things( + {"thing": ThingWithSettings}, settings_folder=tempdir + ) assert len(caplog.records) == 1 assert caplog.records[0].levelname == "WARNING" assert caplog.records[0].name == "labthings_fastapi.things.thing" diff --git a/tests/test_thing.py b/tests/test_thing.py index 523bda34..92183fa7 100644 --- a/tests/test_thing.py +++ b/tests/test_thing.py @@ -11,7 +11,7 @@ def test_td_validates(): def test_add_thing(): """Check that thing can be added to the server""" - server = ThingServer({"thing": MyThing}) + server = ThingServer.from_things({"thing": MyThing}) assert isinstance(server.things["thing"], MyThing) @@ -22,7 +22,7 @@ def test_thing_can_access_application_config(): "application_config": {"foo": "bar", "mock": True}, } - server = ThingServer.from_config(conf) + server = ThingServer(conf) thing1 = server.things["thing1"] thing2 = server.things["thing2"] diff --git a/tests/test_thing_client.py b/tests/test_thing_client.py index 04333a52..c170535a 100644 --- a/tests/test_thing_client.py +++ b/tests/test_thing_client.py @@ -4,7 +4,6 @@ import pytest import labthings_fastapi as lt -from fastapi.testclient import TestClient from labthings_fastapi.exceptions import ClientPropertyError, FailedToInvokeActionError @@ -63,8 +62,10 @@ def throw_value_error(self) -> None: @pytest.fixture def thing_client_and_thing(): """Yield a test client connected to a ThingServer and the Thing itself.""" - server = lt.ThingServer({"test_thing": ThingToTest}, api_prefix="/api/v1") - with TestClient(server.app) as client: + server = lt.ThingServer.from_things( + {"test_thing": ThingToTest}, api_prefix="/api/v1" + ) + with server.test_client() as client: thing_client = lt.ThingClient.from_url("/api/v1/test_thing/", client=client) thing = server.things["test_thing"] yield thing_client, thing diff --git a/tests/test_thing_lifecycle.py b/tests/test_thing_lifecycle.py index e737506d..ed5c1512 100644 --- a/tests/test_thing_lifecycle.py +++ b/tests/test_thing_lifecycle.py @@ -1,6 +1,5 @@ import pytest import labthings_fastapi as lt -from fastapi.testclient import TestClient class LifecycleThing(lt.Thing): @@ -20,7 +19,7 @@ def __exit__(self, *args): @pytest.fixture def server(): """A ThingServer with a LifecycleThing.""" - return lt.ThingServer({"thing": LifecycleThing}) + return lt.ThingServer.from_things({"thing": LifecycleThing}) @pytest.fixture @@ -31,7 +30,7 @@ def thing(server): def test_thing_alive(server, thing): assert thing.alive is False - with TestClient(server.app) as client: + with server.test_client() as client: assert thing.alive is True r = client.get("/thing/alive") assert r.json() is True @@ -44,10 +43,10 @@ def test_thing_alive_twice(server, thing): sure our lifecycle stuff is closing down cleanly and can restart. """ assert thing.alive is False - with TestClient(server.app) as client: + with server.test_client() as client: r = client.get("/thing/alive") assert r.json() is True assert thing.alive is False - with TestClient(server.app) as client: + with server.test_client() as client: r = client.get("/thing/alive") assert r.json() is True diff --git a/tests/test_thing_server_interface.py b/tests/test_thing_server_interface.py index 27ee091f..8bed3a6e 100644 --- a/tests/test_thing_server_interface.py +++ b/tests/test_thing_server_interface.py @@ -6,7 +6,6 @@ from typing import Mapping from unittest.mock import Mock -from fastapi.testclient import TestClient import pytest import labthings_fastapi as lt @@ -69,7 +68,7 @@ class ExampleWithSlots(lt.Thing): def server(): """Return a LabThings server""" with tempfile.TemporaryDirectory() as dir: - server = lt.ThingServer( + server = lt.ThingServer.from_things( things={"example": ExampleThing}, settings_folder=dir, ) @@ -102,7 +101,7 @@ def test_get_server_error(): This is an error condition that I would find surprising if it ever occurred, but it's worth checking. """ - server = lt.ThingServer(things={}) + server = lt.ThingServer.from_things(things={}) interface = ThingServerInterface(server, NAME) assert interface._get_server() is server del server @@ -124,7 +123,7 @@ async def set_mutable(val): # error. interface.start_async_task_soon(set_mutable, True) - with TestClient(server.app) as _: + with server.test_client() as _: # TestClient starts an event loop in the background # so this should work interface.start_async_task_soon(set_mutable, True) @@ -149,7 +148,7 @@ async def async_shout(input: str): # error. interface.call_async_task(async_shout, "foobar") - with TestClient(server.app) as _: + with server.test_client() as _: # TestClient starts an event loop in the background # so this should work assert interface.call_async_task(async_shout, "foobar") == "FOOBAR" diff --git a/tests/test_thing_slots.py b/tests/test_thing_slots.py index 08f83161..acf23896 100644 --- a/tests/test_thing_slots.py +++ b/tests/test_thing_slots.py @@ -4,7 +4,6 @@ import gc import pytest import labthings_fastapi as lt -from fastapi.testclient import TestClient from labthings_fastapi.exceptions import ThingSlotError @@ -344,7 +343,7 @@ def test_circular_connection(cls_1, cls_2, connections) -> None: Thing classes. Circular dependencies should not cause any problems for the LabThings server. """ - server = lt.ThingServer( + server = lt.ThingServer.from_things( things={ "thing_one": lt.ThingConfig( cls=cls_1, thing_slots=connections.get("thing_one", {}) @@ -356,7 +355,7 @@ def test_circular_connection(cls_1, cls_2, connections) -> None: ) things = [server.things[n] for n in ["thing_one", "thing_two"]] - with TestClient(server.app) as _: + with server.test_client() as _: # The things should be connected as the server is now running for thing, other in zip(things, reversed(things), strict=True): assert thing.other_thing is other @@ -397,22 +396,22 @@ def test_connections_none_default(connections, error): } if error is None: - server = lt.ThingServer(things) - with TestClient(server.app): + server = lt.ThingServer.from_things(things) + with server.test_client(): thing_one = server.things["thing_one"] assert isinstance(thing_one, ThingN) assert thing_one.other_thing is thing_one return with pytest.raises(ThingSlotError, match=error): - server = lt.ThingServer(things) + server = lt.ThingServer.from_things(things) def test_optional_and_empty(): """Check that an optional or mapping connection can be None/empty.""" - server = lt.ThingServer({"thing_one": ThingOne, "thing_two": ThingTwo}) + server = lt.ThingServer.from_things({"thing_one": ThingOne, "thing_two": ThingTwo}) - with TestClient(server.app): + with server.test_client(): thing_one = server.things["thing_one"] assert isinstance(thing_one, ThingOne) assert thing_one.optional_thing is None @@ -435,15 +434,15 @@ def test_mapping_and_multiple(): # We can't set up a server like this, because # thing_one.optional_thing will match multiple ThingThree instances. with pytest.raises(ThingSlotError, match="multiple Things"): - server = lt.ThingServer(things) + server = lt.ThingServer.from_things(things) # Set optional thing to one specific name and it will start OK. things["thing_one"] = lt.ThingConfig( cls=ThingOne, thing_slots={"optional_thing": "thing_3"}, ) - server = lt.ThingServer(things) - with TestClient(server.app): + server = lt.ThingServer.from_things(things) + with server.test_client(): thing_one = server.things["thing_one"] assert isinstance(thing_one, ThingOne) assert thing_one.optional_thing is not None diff --git a/tests/test_websocket.py b/tests/test_websocket.py index 2781e04f..7446a079 100644 --- a/tests/test_websocket.py +++ b/tests/test_websocket.py @@ -1,4 +1,3 @@ -from fastapi.testclient import TestClient import pytest import labthings_fastapi as lt from labthings_fastapi.exceptions import ( @@ -55,14 +54,14 @@ def cancel_myself(self): @pytest.fixture def server(): """Create a server, and add a MyThing test Thing to it.""" - server = lt.ThingServer({"thing": ThingWithProperties}) + server = lt.ThingServer.from_things({"thing": ThingWithProperties}) return server @pytest.fixture def client(server): """Yield a TestClient connected to the server.""" - with TestClient(server.app) as client: + with server.test_client() as client: yield client