Coverage for python/lsst/daf/butler/tests/server.py: 7%
67 statements
« prev ^ index » next coverage.py v7.4.3, created at 2024-03-12 10:05 +0000
« prev ^ index » next coverage.py v7.4.3, created at 2024-03-12 10:05 +0000
1import os
2from collections.abc import Iterator
3from contextlib import contextmanager
4from dataclasses import dataclass
5from tempfile import TemporaryDirectory
7from fastapi import FastAPI, Request
8from fastapi.testclient import TestClient
9from lsst.daf.butler import Butler, Config, LabeledButlerFactory
10from lsst.daf.butler.remote_butler import RemoteButler, RemoteButlerFactory
11from lsst.daf.butler.remote_butler.server import create_app
12from lsst.daf.butler.remote_butler.server._dependencies import butler_factory_dependency
13from lsst.resources.s3utils import clean_test_environment_for_s3, getS3Client
15from ..direct_butler import DirectButler
16from .hybrid_butler import HybridButler
17from .server_utils import add_auth_header_check_middleware
19try:
20 # moto v5
21 from moto import mock_aws # type: ignore
22except ImportError:
23 # moto v4 and earlier
24 from moto import mock_s3 as mock_aws # type: ignore
26__all__ = ("create_test_server", "TestServerInstance", "TEST_REPOSITORY_NAME")
29TEST_REPOSITORY_NAME = "testrepo"
32@dataclass(frozen=True)
33class TestServerInstance:
34 """Butler instances and other data associated with a temporary server
35 instance.
36 """
38 config_file_path: str
39 """Path to the Butler config file used by the server."""
40 client: TestClient
41 """HTTPX client connected to the temporary server."""
42 remote_butler: RemoteButler
43 """`RemoteButler` connected to the temporary server."""
44 remote_butler_without_error_propagation: RemoteButler
45 """`RemoteButler` connected to the temporary server.
47 By default, the TestClient instance raises any unhandled exceptions
48 from the server as if they had originated in the client to ease debugging.
49 However, this can make it appear that error propagation is working
50 correctly when in a real deployment the server exception would cause a 500
51 Internal Server Error. This instance of the butler is set up so that any
52 unhandled server exceptions do return a 500 status code."""
53 direct_butler: Butler
54 """`DirectButler` instance connected to the same repository as the
55 temporary server.
56 """
57 hybrid_butler: HybridButler
58 """`HybridButler` instance connected to the temporary server."""
61@contextmanager
62def create_test_server(
63 test_directory: str, raise_original_server_exceptions: bool = False
64) -> Iterator[TestServerInstance]:
65 """Create a temporary Butler server instance for testing.
67 Parameters
68 ----------
69 test_directory : `str`
70 Path to the ``tests/`` directory at the root of the repository,
71 containing Butler test configuration files.
72 raise_original_server_exceptions : `bool`, optional
73 If True, exceptions raised by the server are passed up unchanged
74 through the client. If False, they are converted to
75 `UnhandledServerError` first.
77 Returns
78 -------
79 instance : `TestServerInstance`
80 Object containing Butler instances connected to the server and
81 associated information.
82 """
83 # Set up a mock S3 environment using Moto. Moto also monkeypatches the
84 # `requests` library so that any HTTP requests to presigned S3 URLs get
85 # redirected to the mocked S3.
86 # Note that all files are stored in memory.
87 with clean_test_environment_for_s3():
88 with mock_aws():
89 base_config_path = os.path.join(test_directory, "config/basic/server.yaml")
90 # Create S3 buckets used for the datastore in server.yaml.
91 for bucket in ["mutable-bucket", "immutable-bucket"]:
92 getS3Client().create_bucket(Bucket=bucket)
94 with TemporaryDirectory() as root:
95 Butler.makeRepo(root, config=Config(base_config_path), forceConfigRoot=False)
96 config_file_path = os.path.join(root, "butler.yaml")
98 app = create_app()
99 add_auth_header_check_middleware(app)
100 if not raise_original_server_exceptions:
101 _add_root_exception_handler(app)
102 # Override the server's Butler initialization to point at our
103 # test repo
104 server_butler_factory = LabeledButlerFactory({TEST_REPOSITORY_NAME: config_file_path})
105 app.dependency_overrides[butler_factory_dependency] = lambda: server_butler_factory
107 client = TestClient(app)
108 client_without_error_propagation = TestClient(app, raise_server_exceptions=False)
110 remote_butler = _make_remote_butler(client)
111 remote_butler_without_error_propagation = _make_remote_butler(
112 client_without_error_propagation
113 )
115 direct_butler = Butler.from_config(config_file_path, writeable=True)
116 assert isinstance(direct_butler, DirectButler)
117 hybrid_butler = HybridButler(remote_butler, direct_butler)
119 yield TestServerInstance(
120 config_file_path=config_file_path,
121 client=client,
122 direct_butler=direct_butler,
123 remote_butler=remote_butler,
124 remote_butler_without_error_propagation=remote_butler_without_error_propagation,
125 hybrid_butler=hybrid_butler,
126 )
129def _make_remote_butler(client: TestClient) -> RemoteButler:
130 remote_butler_factory = RemoteButlerFactory(
131 f"https://test.example/api/butler/repo/{TEST_REPOSITORY_NAME}", client
132 )
133 return remote_butler_factory.create_butler_for_access_token("fake-access-token")
136class UnhandledServerError(Exception):
137 """Raised for unhandled exceptions within the server that would result in a
138 500 Internal Server Error in a real deployment. This allows us to tell the
139 difference between exceptions being propagated intentionally, and those
140 just bubbling up implicitly from the server to the client.
142 The FastAPI TestClient by default passes unhandled exceptions up from the
143 server to the client. This is useful behavior for unit testing because it
144 gives you traceability from the test to the problem in the server code.
145 However, because RemoteButler is in some ways just a proxy for the
146 server-side Butler, we raise similar exceptions on the client and server
147 side. Thus the default TestClient behavior can mask missing error-handling
148 logic.
149 """
152def _add_root_exception_handler(app: FastAPI) -> None:
153 @app.exception_handler(Exception)
154 async def convert_exception_types(request: Request, exc: Exception) -> None:
155 raise UnhandledServerError("Unhandled server exception") from exc