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

87 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-01 08:17 +0000

1import json 

2import os 

3from collections.abc import Iterator 

4from contextlib import closing, 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 direct_butler = Butler.from_config(config_file_path, writeable=True) 

155 assert isinstance(direct_butler, DirectButler) 

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

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

158 # starts a new one on each request. 

159 with TestClient(app) as client, direct_butler, closing(server_butler_factory): 

160 remote_butler = _make_remote_butler(client) 

161 hybrid_butler = HybridButler(remote_butler, direct_butler) 

162 

163 client_without_error_propagation = TestClient(app, raise_server_exceptions=False) 

164 remote_butler_without_error_propagation = _make_remote_butler( 

165 client_without_error_propagation 

166 ) 

167 

168 yield TestServerInstance( 

169 config_file_path=config_file_path, 

170 client=client, 

171 direct_butler=direct_butler, 

172 remote_butler=remote_butler, 

173 remote_butler_without_error_propagation=remote_butler_without_error_propagation, 

174 hybrid_butler=hybrid_butler, 

175 app=app, 

176 ) 

177 

178 

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

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

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

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

183 config.configFile = ResourcePath(config_endpoint) 

184 butler_config = ButlerConfig(config) 

185 remote_butler_factory = RemoteButlerFactory.create_factory_from_config(butler_config, client) 

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

187 

188 

189class UnhandledServerError(Exception): 

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

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

192 difference between exceptions being propagated intentionally, and those 

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

194 

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

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

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

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

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

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

201 logic. 

202 """ 

203 

204 

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

206 @app.exception_handler(Exception) 

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

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