Coverage for python/lsst/daf/butler/tests/server.py: 7%

87 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-11 01:26 -0700

1import json 

2import os 

3from collections.abc import Iterator 

4from contextlib import contextmanager 

5from dataclasses import dataclass 

6from tempfile import TemporaryDirectory 

7 

8from fastapi import FastAPI, Request 

9from fastapi.testclient import TestClient 

10 

11from lsst.daf.butler import Butler, ButlerConfig, Config, LabeledButlerFactory 

12from lsst.daf.butler.remote_butler import RemoteButler 

13from lsst.daf.butler.remote_butler._factory import RemoteButlerFactory 

14from lsst.daf.butler.remote_butler.server import create_app 

15from lsst.daf.butler.remote_butler.server._config import ButlerServerConfig, RepositoryConfig, mock_config 

16from lsst.daf.butler.remote_butler.server._dependencies import ( 

17 auth_delegated_token_dependency, 

18 butler_factory_dependency, 

19 reset_dependency_caches, 

20 user_name_dependency, 

21) 

22from lsst.resources import ResourcePath 

23from lsst.resources.s3utils import clean_test_environment_for_s3, getS3Client 

24 

25from ..direct_butler import DirectButler 

26from .hybrid_butler import HybridButler 

27from .postgresql import TemporaryPostgresInstance 

28from .server_utils import add_auth_header_check_middleware 

29 

30try: 

31 # moto v5 

32 from moto import mock_aws # type: ignore 

33except ImportError: 

34 # moto v4 and earlier 

35 from moto import mock_s3 as mock_aws # type: ignore 

36 

37__all__ = ("TEST_REPOSITORY_NAME", "TestServerInstance", "create_test_server") 

38 

39 

40TEST_REPOSITORY_NAME = "testrepo" 

41 

42 

43@dataclass(frozen=True) 

44class TestServerInstance: 

45 """Butler instances and other data associated with a temporary server 

46 instance. 

47 """ 

48 

49 config_file_path: str 

50 """Path to the Butler config file used by the server.""" 

51 client: TestClient 

52 """HTTPX client connected to the temporary server.""" 

53 remote_butler: RemoteButler 

54 """`RemoteButler` connected to the temporary server.""" 

55 remote_butler_without_error_propagation: RemoteButler 

56 """`RemoteButler` connected to the temporary server. 

57 

58 By default, the TestClient instance raises any unhandled exceptions 

59 from the server as if they had originated in the client to ease debugging. 

60 However, this can make it appear that error propagation is working 

61 correctly when in a real deployment the server exception would cause a 500 

62 Internal Server Error. This instance of the butler is set up so that any 

63 unhandled server exceptions do return a 500 status code.""" 

64 direct_butler: Butler 

65 """`DirectButler` instance connected to the same repository as the 

66 temporary server. 

67 """ 

68 hybrid_butler: HybridButler 

69 """`HybridButler` instance connected to the temporary server.""" 

70 app: FastAPI 

71 """Butler server FastAPI app.""" 

72 

73 

74@contextmanager 

75def create_test_server( 

76 test_directory: str, 

77 *, 

78 postgres: TemporaryPostgresInstance | None = None, 

79 server_config: ButlerServerConfig | None = None, 

80) -> Iterator[TestServerInstance]: 

81 """Create a temporary Butler server instance for testing. 

82 

83 Parameters 

84 ---------- 

85 test_directory : `str` 

86 Path to the ``tests/`` directory at the root of the repository, 

87 containing Butler test configuration files. 

88 postgres : `TemporaryPostgresInstance` | `None` 

89 If provided, the Butler server will use this postgres database 

90 instance. If no postgres instance is specified, the server will use a 

91 a SQLite database. 

92 server_config : `ButlerServerConfig`, optional 

93 Configuration to use for the Butler server. 

94 

95 Returns 

96 ------- 

97 instance : `TestServerInstance` 

98 Object containing Butler instances connected to the server and 

99 associated information. 

100 """ 

101 # Set up a mock S3 environment using Moto. Moto also monkeypatches the 

102 # `requests` library so that any HTTP requests to presigned S3 URLs get 

103 # redirected to the mocked S3. 

104 # Note that all files are stored in memory. 

105 with clean_test_environment_for_s3(): 

106 with mock_aws(): 

107 base_config_path = os.path.join(test_directory, "config/basic/server.yaml") 

108 # Create S3 buckets used for the datastore in server.yaml. 

109 for bucket in ["mutable-bucket", "immutable-bucket"]: 

110 getS3Client().create_bucket(Bucket=bucket) 

111 

112 config = Config(base_config_path) 

113 if postgres is not None: 

114 postgres.patch_butler_config(config) 

115 

116 with TemporaryDirectory() as root, mock_config(server_config) as server_config: 

117 Butler.makeRepo(root, config=config, forceConfigRoot=False) 

118 config_file_path = os.path.join(root, "butler.yaml") 

119 

120 server_config.repositories = { 

121 TEST_REPOSITORY_NAME: RepositoryConfig( 

122 config_uri=config_file_path, authorized_groups=["*"] 

123 ) 

124 } 

125 reset_dependency_caches() 

126 

127 app = create_app() 

128 if server_config.authentication == "rubin_science_platform": 

129 add_auth_header_check_middleware(app) 

130 _add_root_exception_handler(app) 

131 # Override the server's Butler initialization to point at our 

132 # test repo 

133 server_butler_factory = LabeledButlerFactory({TEST_REPOSITORY_NAME: config_file_path}) 

134 # DirectButler has a dimension_record_cache object that 

135 # maintains a complete set of dimension records for governor 

136 # dimensions. These values change infrequently and are needed 

137 # for almost every DirectButler operation, so the complete set 

138 # is downloaded the first time a record is needed. 

139 # 

140 # On the server it would be expensive to do this for every 

141 # request's new DirectButler instance, so normally these are 

142 # loaded once, the first time a repository is accessed. This 

143 # is a problem for unit tests because they typically manipulate 

144 # instrument records etc during setup. So configure the 

145 # factory to disable this preloading and re-fetch the records 

146 # as needed. 

147 server_butler_factory._preload_unsafe_direct_butler_caches = False 

148 app.dependency_overrides[butler_factory_dependency] = lambda: server_butler_factory 

149 # In an actual deployment, these headers would be provided by 

150 # the Gafaelfawr ingress. 

151 app.dependency_overrides[user_name_dependency] = lambda: "mock-username" 

152 app.dependency_overrides[auth_delegated_token_dependency] = lambda: "mock-delegated-token" 

153 

154 # Using TestClient in a context manager ensures that it uses 

155 # the same async event loop for all requests -- otherwise it 

156 # starts a new one on each request. 

157 with TestClient(app) as client: 

158 remote_butler = _make_remote_butler(client) 

159 

160 direct_butler = Butler.from_config(config_file_path, writeable=True) 

161 assert isinstance(direct_butler, DirectButler) 

162 hybrid_butler = HybridButler(remote_butler, direct_butler) 

163 

164 client_without_error_propagation = TestClient(app, raise_server_exceptions=False) 

165 remote_butler_without_error_propagation = _make_remote_butler( 

166 client_without_error_propagation 

167 ) 

168 

169 yield TestServerInstance( 

170 config_file_path=config_file_path, 

171 client=client, 

172 direct_butler=direct_butler, 

173 remote_butler=remote_butler, 

174 remote_butler_without_error_propagation=remote_butler_without_error_propagation, 

175 hybrid_butler=hybrid_butler, 

176 app=app, 

177 ) 

178 

179 

180def _make_remote_butler(client: TestClient) -> RemoteButler: 

181 config_endpoint = f"https://test.example/api/butler/repo/{TEST_REPOSITORY_NAME}/butler.yaml" 

182 config_json = client.get(config_endpoint).read() 

183 config = Config(json.loads(config_json)) 

184 config.configFile = ResourcePath(config_endpoint) 

185 butler_config = ButlerConfig(config) 

186 remote_butler_factory = RemoteButlerFactory.create_factory_from_config(butler_config, client) 

187 return remote_butler_factory.create_butler_for_access_token("fake-access-token") 

188 

189 

190class UnhandledServerError(Exception): 

191 """Raised for unhandled exceptions within the server that would result in a 

192 500 Internal Server Error in a real deployment. This allows us to tell the 

193 difference between exceptions being propagated intentionally, and those 

194 just bubbling up implicitly from the server to the client. 

195 

196 The FastAPI TestClient by default passes unhandled exceptions up from the 

197 server to the client. This is useful behavior for unit testing because it 

198 gives you traceability from the test to the problem in the server code. 

199 However, because RemoteButler is in some ways just a proxy for the 

200 server-side Butler, we raise similar exceptions on the client and server 

201 side. Thus the default TestClient behavior can mask missing error-handling 

202 logic. 

203 """ 

204 

205 

206def _add_root_exception_handler(app: FastAPI) -> None: 

207 @app.exception_handler(Exception) 

208 async def convert_exception_types(request: Request, exc: Exception) -> None: 

209 raise UnhandledServerError("Unhandled server exception") from exc