Coverage for tests/test_server.py: 14%

364 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-13 01:31 -0800

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

16# it under the terms of the GNU General Public License as published by 

17# the Free Software Foundation, either version 3 of the License, or 

18# (at your option) any later version. 

19# 

20# This program is distributed in the hope that it will be useful, 

21# but WITHOUT ANY WARRANTY; without even the implied warranty of 

22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23# GNU General Public License for more details. 

24# 

25# You should have received a copy of the GNU General Public License 

26# along with this program. If not, see <http://www.gnu.org/licenses/>. 

27 

28import asyncio 

29import os.path 

30import tempfile 

31import threading 

32import unittest 

33import unittest.mock 

34import uuid 

35from concurrent.futures import ThreadPoolExecutor 

36from unittest.mock import DEFAULT, AsyncMock, NonCallableMock, patch 

37 

38from lsst.daf.butler.tests.dict_convertible_model import DictConvertibleModel 

39 

40try: 

41 # Failing to import any of these should disable the tests. 

42 import fastapi 

43 import httpx 

44 import safir.dependencies.logger 

45 from fastapi.testclient import TestClient 

46 

47 import lsst.daf.butler.remote_butler._query_results 

48 import lsst.daf.butler.remote_butler.server.handlers._query_limits 

49 import lsst.daf.butler.remote_butler.server.handlers._query_streaming 

50 from lsst.daf.butler.remote_butler import ButlerServerError, RemoteButler 

51 from lsst.daf.butler.remote_butler.authentication.cadc import CadcAuthenticationProvider 

52 from lsst.daf.butler.remote_butler.authentication.rubin import ( 

53 _EXPLICIT_BUTLER_ACCESS_TOKEN_ENVIRONMENT_KEY, 

54 RubinAuthenticationProvider, 

55 ) 

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

57 from lsst.daf.butler.remote_butler.server._config import mock_config 

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

59 authorizer_dependency, 

60 butler_factory_dependency, 

61 ) 

62 from lsst.daf.butler.remote_butler.server._gafaelfawr import MockGafaelfawrGroupAuthorizer 

63 from lsst.daf.butler.remote_butler.server.handlers._utils import generate_file_download_uri 

64 from lsst.daf.butler.remote_butler.server_models import QueryCollectionsRequestModel 

65 from lsst.daf.butler.tests.server import TEST_REPOSITORY_NAME, UnhandledServerError, create_test_server 

66 

67 reason_text = "" 

68except ImportError as e: 

69 create_test_server = None 

70 reason_text = str(e) 

71 

72 

73from lsst.daf.butler import ( 

74 Butler, 

75 DataCoordinate, 

76 DatasetId, 

77 DatasetNotFoundError, 

78 DatasetRef, 

79 DatasetType, 

80 FileDataset, 

81 InvalidQueryError, 

82 LabeledButlerFactory, 

83 MissingDatasetTypeError, 

84 NoDefaultCollectionError, 

85 StorageClassFactory, 

86) 

87from lsst.daf.butler.datastore import DatasetRefURIs 

88from lsst.daf.butler.registry import RegistryDefaults 

89from lsst.daf.butler.tests import DatastoreMock, addDatasetType 

90from lsst.daf.butler.tests.utils import MetricsExample, MetricTestRepo, mock_env 

91from lsst.resources import ResourcePath 

92from lsst.resources.http import HttpResourcePath 

93 

94TESTDIR = os.path.abspath(os.path.dirname(__file__)) 

95 

96 

97@unittest.skipIf(create_test_server is None, f"Server dependencies not installed: {reason_text}") 

98class ButlerClientServerTestCase(unittest.TestCase): 

99 """Test for Butler client/server.""" 

100 

101 @classmethod 

102 def setUpClass(cls): 

103 server_instance = cls.enterClassContext(create_test_server(TESTDIR)) 

104 cls.server_instance = server_instance 

105 cls.client = server_instance.client 

106 cls.butler = server_instance.remote_butler 

107 cls.butler_without_error_propagation = server_instance.remote_butler_without_error_propagation 

108 

109 cls.storageClassFactory = StorageClassFactory() 

110 

111 cls.repo = MetricTestRepo.create_from_butler( 

112 server_instance.direct_butler, server_instance.config_file_path 

113 ) 

114 # Add a file with corrupted data for testing error conditions 

115 cls.dataset_with_corrupted_data = _create_corrupted_dataset(cls.repo) 

116 # All of the datasets that come with MetricTestRepo are disassembled 

117 # composites. Add a simple dataset for testing the common case. 

118 cls.simple_dataset_ref = _create_simple_dataset(server_instance.direct_butler) 

119 

120 # Populate the test server. 

121 # The DatastoreMock is required because the datasets referenced in 

122 # these imports do not point at real files. 

123 direct_butler = server_instance.direct_butler 

124 DatastoreMock.apply(direct_butler) 

125 direct_butler.import_(filename="resource://lsst.daf.butler/tests/registry_data/base.yaml") 

126 direct_butler.import_(filename="resource://lsst.daf.butler/tests/registry_data/datasets.yaml") 

127 

128 def test_health_check(self): 

129 try: 

130 import importlib.metadata 

131 

132 importlib.metadata.metadata("lsst.daf.butler") 

133 except ModuleNotFoundError: 

134 raise self.skipTest("Standard python package metadata not available. Butler not pip installed.") 

135 response = self.client.get("/") 

136 self.assertEqual(response.status_code, 200) 

137 self.assertEqual(response.json()["name"], "butler") 

138 

139 def test_static_files(self): 

140 with tempfile.TemporaryDirectory() as tmpdir: 

141 with open(os.path.join(tmpdir, "temp.txt"), "w") as fh: 

142 fh.write("test data 123") 

143 

144 with mock_config() as server_config: 

145 server_config.static_files_path = tmpdir 

146 with create_test_server(TESTDIR, server_config=server_config) as server: 

147 response = server.client.get("/api/butler/configs/temp.txt") 

148 self.assertEqual(response.status_code, 200) 

149 self.assertEqual(response.text, "test data 123") 

150 

151 def test_dimension_universe(self): 

152 universe = self.butler.dimensions 

153 self.assertEqual(universe.namespace, "daf_butler") 

154 

155 def test_get_dataset_type(self): 

156 bias_type = self.butler.get_dataset_type("bias") 

157 self.assertEqual(bias_type.name, "bias") 

158 

159 with self.assertRaises(MissingDatasetTypeError): 

160 self.butler_without_error_propagation.get_dataset_type("not_bias") 

161 

162 def test_find_dataset(self): 

163 storage_class = self.storageClassFactory.getStorageClass("Exposure") 

164 

165 ref = self.butler.find_dataset("bias", collections="imported_g", detector=1, instrument="Cam1") 

166 self.assertIsInstance(ref, DatasetRef) 

167 self.assertEqual(ref.id, uuid.UUID("e15ab039-bc8b-4135-87c5-90902a7c0b22")) 

168 self.assertFalse(ref.dataId.hasRecords()) 

169 

170 # Try again with variation of parameters. 

171 ref_new = self.butler.find_dataset( 

172 "bias", 

173 {"detector": 1}, 

174 collections="imported_g", 

175 instrument="Cam1", 

176 dimension_records=True, 

177 ) 

178 self.assertEqual(ref_new, ref) 

179 self.assertTrue(ref_new.dataId.hasRecords()) 

180 

181 ref_new = self.butler.find_dataset( 

182 ref.datasetType, 

183 DataCoordinate.standardize(detector=1, instrument="Cam1", universe=self.butler.dimensions), 

184 collections="imported_g", 

185 storage_class=storage_class, 

186 ) 

187 self.assertEqual(ref_new, ref) 

188 

189 ref2 = self.butler.get_dataset(ref.id) 

190 self.assertEqual(ref2, ref) 

191 

192 # Use detector name to find it. 

193 ref3 = self.butler.find_dataset( 

194 ref.datasetType, 

195 collections="imported_g", 

196 instrument="Cam1", 

197 full_name="Aa", 

198 ) 

199 self.assertEqual(ref2, ref3) 

200 

201 # Try expanded refs. 

202 self.assertFalse(ref.dataId.hasRecords()) 

203 expanded = self.butler.get_dataset(ref.id, dimension_records=True) 

204 self.assertTrue(expanded.dataId.hasRecords()) 

205 

206 # The test datasets are all Exposure so storage class conversion 

207 # can not be tested until we fix that. For now at least test the 

208 # code paths. 

209 bias = self.butler.get_dataset(ref.id, storage_class=storage_class) 

210 self.assertEqual(bias.datasetType.storageClass, storage_class) 

211 

212 # Unknown dataset should not fail. 

213 self.assertIsNone(self.butler.get_dataset(uuid.uuid4())) 

214 self.assertIsNone(self.butler.get_dataset(uuid.uuid4(), storage_class="NumpyArray")) 

215 

216 def test_instantiate_via_butler_http_search(self): 

217 """Ensure that the primary Butler constructor's automatic search logic 

218 correctly locates and reads the configuration file and ends up with a 

219 RemoteButler pointing to the correct URL 

220 """ 

221 

222 # This is kind of a fragile test. Butler's search logic does a lot of 

223 # manipulations involving creating new ResourcePaths, and ResourcePath 

224 # doesn't use httpx so we can't easily inject the TestClient in there. 

225 # We don't have an actual valid HTTP URL to give to the constructor 

226 # because the test instance of the server is accessed via ASGI. 

227 # 

228 # Instead we just monkeypatch the HTTPResourcePath 'read' method and 

229 # hope that all ResourcePath HTTP reads during construction are going 

230 # to the server under test. 

231 def override_read(http_resource_path): 

232 return self.client.get(http_resource_path.geturl()).content 

233 

234 server_url = f"https://test.example/api/butler/repo/{TEST_REPOSITORY_NAME}/" 

235 

236 with patch.object(HttpResourcePath, "read", override_read): 

237 # RegistryDefaults.finish() needs to download the dimension 

238 # universe from the server, which will fail because there is no 

239 # server here. So mock it out. 

240 with patch.object(RegistryDefaults, "finish"): 

241 # Add access key to environment variables. RemoteButler 

242 # instantiation will throw an error if access key is not 

243 # available. 

244 with mock_env({_EXPLICIT_BUTLER_ACCESS_TOKEN_ENVIRONMENT_KEY: "fake-access-token"}): 

245 butler = Butler( 

246 server_url, 

247 collections=["collection1", "collection2"], 

248 run="collection2", 

249 ) 

250 self.assertIsInstance(butler, RemoteButler) 

251 self.assertEqual(butler._connection.server_url, server_url) 

252 self.assertEqual(butler.collections.defaults, ("collection1", "collection2")) 

253 self.assertEqual(butler.run, "collection2") 

254 # A butler created this way uses the default cache config. 

255 self.assertFalse(butler._use_disabled_datastore_cache) 

256 

257 butler_factory = LabeledButlerFactory({"server": server_url}) 

258 factory_created_butler = butler_factory.create_butler(label="server", access_token="token") 

259 self.assertIsInstance(factory_created_butler, RemoteButler) 

260 self.assertTrue(factory_created_butler._use_disabled_datastore_cache) 

261 self.assertEqual(factory_created_butler._connection.server_url, server_url) 

262 

263 def test_get(self): 

264 dataset_type = "test_metric_comp" 

265 data_id = {"instrument": "DummyCamComp", "visit": 423} 

266 collections = "ingest/run" 

267 # Test get() of a DatasetRef. 

268 ref = self.butler.find_dataset(dataset_type, data_id, collections=collections) 

269 metric = self.butler.get(ref) 

270 self.assertIsInstance(metric, MetricsExample) 

271 self.assertEqual(metric.summary, MetricTestRepo.METRICS_EXAMPLE_SUMMARY) 

272 

273 # Test get() by DataId. 

274 data_id_metric = self.butler.get(dataset_type, dataId=data_id, collections=collections) 

275 self.assertEqual(metric, data_id_metric) 

276 # Test get() by DataId dict augmented with kwargs. 

277 kwarg_metric = self.butler.get( 

278 dataset_type, dataId={"instrument": "DummyCamComp"}, collections=collections, visit=423 

279 ) 

280 self.assertEqual(metric, kwarg_metric) 

281 # Test get() by DataId DataCoordinate augmented with kwargs. 

282 coordinate = DataCoordinate.make_empty(self.butler.dimensions) 

283 kwarg_data_coordinate_metric = self.butler.get( 

284 dataset_type, dataId=coordinate, collections=collections, instrument="DummyCamComp", visit=423 

285 ) 

286 self.assertEqual(metric, kwarg_data_coordinate_metric) 

287 # Test get() of a non-existent DataId. 

288 invalid_data_id = {"instrument": "NotAValidlInstrument", "visit": 423} 

289 with self.assertRaises(DatasetNotFoundError): 

290 self.butler_without_error_propagation.get( 

291 dataset_type, dataId=invalid_data_id, collections=collections 

292 ) 

293 

294 # Test get() by DataId with default collections. 

295 butler_with_default_collection = self.butler.clone(collections="ingest/run") 

296 default_collection_metric = butler_with_default_collection.get(dataset_type, dataId=data_id) 

297 self.assertEqual(metric, default_collection_metric) 

298 

299 # Test get() by DataId with no collections specified. 

300 with self.assertRaises(NoDefaultCollectionError): 

301 self.butler_without_error_propagation.get(dataset_type, dataId=data_id) 

302 

303 # Test looking up a non-existent ref 

304 invalid_ref = ref.replace(id=uuid.uuid4()) 

305 with self.assertRaises(DatasetNotFoundError): 

306 self.butler_without_error_propagation.get(invalid_ref) 

307 

308 with self.assertRaises(RuntimeError): 

309 self.butler_without_error_propagation.get(self.dataset_with_corrupted_data) 

310 

311 # Test storage class override 

312 new_sc = self.storageClassFactory.getStorageClass("MetricsConversion") 

313 

314 def check_sc_override(converted): 

315 self.assertNotEqual(type(metric), type(converted)) 

316 self.assertIsInstance(converted, new_sc.pytype) 

317 self.assertEqual(metric, converted) 

318 

319 check_sc_override(self.butler.get(ref, storageClass=new_sc)) 

320 

321 # Test storage class override via DatasetRef. 

322 check_sc_override(self.butler.get(ref.overrideStorageClass("MetricsConversion"))) 

323 # Test storage class override via DatasetType. 

324 check_sc_override( 

325 self.butler.get( 

326 ref.datasetType.overrideStorageClass(new_sc), dataId=data_id, collections=collections 

327 ) 

328 ) 

329 

330 # Test component override via DatasetRef. 

331 component_ref = ref.makeComponentRef("summary") 

332 component_data = self.butler.get(component_ref) 

333 self.assertEqual(component_data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY) 

334 

335 # Test overriding both storage class and component via DatasetRef. 

336 converted_component_data = self.butler.get(component_ref, storageClass="DictConvertibleModel") 

337 self.assertIsInstance(converted_component_data, DictConvertibleModel) 

338 self.assertEqual(converted_component_data.content, MetricTestRepo.METRICS_EXAMPLE_SUMMARY) 

339 

340 # Test component override via DatasetType. 

341 dataset_type_component_data = self.butler.get( 

342 component_ref.datasetType, component_ref.dataId, collections=collections 

343 ) 

344 self.assertEqual(dataset_type_component_data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY) 

345 

346 def test_getURIs_no_components(self): 

347 # This dataset does not have components, and should return one URI. 

348 def check_uri(uri: ResourcePath): 

349 self.assertIsNotNone(uris.primaryURI) 

350 self.assertEqual(uris.primaryURI.scheme, "https") 

351 self.assertEqual(uris.primaryURI.read(), b"123") 

352 

353 uris = self.butler.getURIs(self.simple_dataset_ref) 

354 self.assertEqual(len(uris.componentURIs), 0) 

355 check_uri(uris.primaryURI) 

356 

357 check_uri(self.butler.getURI(self.simple_dataset_ref)) 

358 

359 def test_getURIs_multiple_components(self): 

360 # This dataset has multiple components, so we should get back multiple 

361 # URIs. 

362 dataset_type = "test_metric_comp" 

363 data_id = {"instrument": "DummyCamComp", "visit": 423} 

364 collections = "ingest/run" 

365 

366 def check_uris(uris: DatasetRefURIs): 

367 self.assertIsNone(uris.primaryURI) 

368 self.assertEqual(len(uris.componentURIs), 3) 

369 path = uris.componentURIs["summary"] 

370 self.assertEqual(path.scheme, "https") 

371 data = path.read() 

372 self.assertEqual(data, b"AM1: 5.2\nAM2: 30.6\n") 

373 

374 uris = self.butler.getURIs(dataset_type, dataId=data_id, collections=collections) 

375 check_uris(uris) 

376 

377 # Calling getURI on a multi-file dataset raises an exception 

378 with self.assertRaises(RuntimeError): 

379 self.butler.getURI(dataset_type, dataId=data_id, collections=collections) 

380 

381 # getURIs does NOT respect component overrides on the DatasetRef, 

382 # instead returning the parent's URIs. Unclear if this is "correct" 

383 # from a conceptual point of view, but this matches DirectButler 

384 # behavior. 

385 ref = self.butler.find_dataset(dataset_type, data_id=data_id, collections=collections) 

386 componentRef = ref.makeComponentRef("summary") 

387 componentUris = self.butler.getURIs(componentRef) 

388 check_uris(componentUris) 

389 

390 def test_file_download_redirect(self): 

391 def get_download_redirect(id: DatasetId, component: str | None = None) -> httpx.Response: 

392 uri = generate_file_download_uri("http://unittest.test/", TEST_REPOSITORY_NAME, id, component) 

393 return self.client.get( 

394 uri, 

395 follow_redirects=False, 

396 headers=RubinAuthenticationProvider("mock-token").get_server_headers(), 

397 ) 

398 

399 # Test behavior of a single-file dataset. 

400 response = get_download_redirect(self.simple_dataset_ref.id) 

401 self.assertEqual(response.status_code, 307) 

402 self.assertTrue(response.has_redirect_location) 

403 assert response.next_request is not None 

404 self.assertEqual(response.next_request.url.scheme, "https") 

405 self.assertIn("test_int_DummyCamComp_R_d-r_423_ingest_run.json", response.next_request.url.path) 

406 

407 response = get_download_redirect(self.simple_dataset_ref.id, "somecomponent") 

408 self.assertEqual(response.status_code, 404) 

409 

410 # This dataset is a "disassembled composite" with multiple files. 

411 dataset_type = "test_metric_comp" 

412 data_id = {"instrument": "DummyCamComp", "visit": 423} 

413 collections = "ingest/run" 

414 ref = self.butler.find_dataset(dataset_type, data_id, collections=collections) 

415 

416 # Getting single component of a multi-file "disassembled composite". 

417 response = get_download_redirect(ref.id, "summary") 

418 self.assertEqual(response.status_code, 307) 

419 self.assertTrue(response.has_redirect_location) 

420 assert response.next_request is not None 

421 self.assertEqual(response.next_request.url.scheme, "https") 

422 self.assertIn("test_metric_comp.summary", response.next_request.url.path) 

423 

424 # Unknown component. 

425 response = get_download_redirect(ref.id, "badcomponent") 

426 self.assertEqual(response.status_code, 404) 

427 

428 # Not specifying the component for a multi-file "disassembled 

429 # composite". 

430 response = get_download_redirect(ref.id, None) 

431 self.assertEqual(response.status_code, 422) 

432 

433 # Unknown dataset. 

434 response = get_download_redirect(uuid.UUID("59467c1b-fa13-4f7a-8ff8-cd83e092e563")) 

435 self.assertEqual(response.status_code, 404) 

436 

437 def test_auth_check(self): 

438 # This is checking that the unit-test middleware for validating the 

439 # authentication headers is working. It doesn't test actual server 

440 # functionality -- in a real deployment, the authentication headers are 

441 # handled by GafaelfawrIngress, not our app. 

442 with self.assertRaises(UnhandledServerError): 

443 self.client.get("/v1/dataset_type/int") 

444 

445 def test_exception_logging(self): 

446 app = create_app() 

447 

448 def raise_error(): 

449 raise RuntimeError("An unhandled error") 

450 

451 app.dependency_overrides[butler_factory_dependency] = raise_error 

452 client = TestClient(app, raise_server_exceptions=False) 

453 

454 with patch.object(safir.dependencies.logger, "logger_dependency") as mock_logger_dep: 

455 mock_logger = NonCallableMock(["aerror"]) 

456 

457 async def noop(): 

458 pass 

459 

460 mock_logger.aerror.return_value = noop() 

461 

462 async def get_logger(): 

463 return mock_logger 

464 

465 mock_logger_dep.return_value = get_logger() 

466 client.get( 

467 "/api/butler/repo/something/v1/dataset_type/int", 

468 headers={"X-Auth-Request-User": "user-name", "X-Butler-Client-Request-Id": "request-id"}, 

469 ) 

470 mock_logger_dep.assert_called_once() 

471 

472 mock_logger.aerror.assert_called_once() 

473 args, kwargs = mock_logger.aerror.call_args 

474 self.assertIsInstance(kwargs["exc_info"], RuntimeError) 

475 self.assertEqual(kwargs["clientRequestId"], "request-id") 

476 self.assertEqual(kwargs["user"], "user-name") 

477 

478 def test_query_keepalive(self): 

479 """Test that long-running queries stream keep-alive messages to stop 

480 the HTTP connection from closing before they are able to return 

481 results. 

482 """ 

483 # Normally it takes 15 seconds for a timeout -- mock it to trigger 

484 # immediately instead. 

485 with patch.object( 

486 lsst.daf.butler.remote_butler.server.handlers._query_streaming, "_timeout" 

487 ) as mock_timeout: 

488 # Hook into QueryDriver to track the number of keep-alives we have 

489 # seen. 

490 with patch.object( 

491 lsst.daf.butler.remote_butler._query_results, "_received_keep_alive" 

492 ) as mock_keep_alive: 

493 mock_timeout.side_effect = _timeout_twice() 

494 with self.butler.query() as query: 

495 datasets = list(query.datasets("bias", "imported_g")) 

496 self.assertEqual(len(datasets), 3) 

497 self.assertGreaterEqual(mock_timeout.call_count, 3) 

498 self.assertGreaterEqual(mock_keep_alive.call_count, 2) 

499 

500 def test_query_retries(self): 

501 """Test that the server will send HTTP status 503 to put backpressure 

502 on clients if it is overloaded, and that the client will retry if this 

503 happens. 

504 """ 

505 query_event = threading.Event() 

506 retry_event = asyncio.Event() 

507 

508 async def block_first_request() -> None: 

509 # Signal the unit tests that we have reached the critical section 

510 # in the server, where the first client has reserved the query 

511 # slot. 

512 query_event.set() 

513 # Block inside the query, until the 2nd client has been forced to 

514 # retry. 

515 await retry_event.wait() 

516 

517 async def block_second_request() -> None: 

518 # Release the first client, so it can finish its query and prevent 

519 # this client from being blocked on the next go-round. 

520 retry_event.set() 

521 

522 def do_query(butler: Butler) -> list[DatasetRef]: 

523 return butler.query_datasets("bias", "imported_g") 

524 

525 with ( 

526 patch.object( 

527 lsst.daf.butler.remote_butler.server.handlers._query_limits, 

528 "_MAXIMUM_CONCURRENT_STREAMING_QUERIES", 

529 new=1, 

530 ), 

531 patch.object( 

532 lsst.daf.butler.remote_butler.server.handlers._query_limits, "_QUERY_RETRY_SECONDS", new=1 

533 ), 

534 patch.object( 

535 lsst.daf.butler.remote_butler.server.handlers._query_limits, 

536 "_block_query_for_unit_test", 

537 new=AsyncMock(wraps=block_first_request), 

538 ) as mock_first_client, 

539 patch.object( 

540 lsst.daf.butler.remote_butler.server.handlers._query_limits, 

541 "_block_retry_for_unit_test", 

542 new=AsyncMock(wraps=block_second_request), 

543 ) as mock_second_client, 

544 ThreadPoolExecutor(max_workers=1) as exec1, 

545 ThreadPoolExecutor(max_workers=1) as exec2, 

546 ): 

547 first_butler = self.butler 

548 second_butler = self.butler.clone() 

549 

550 # Run the first client up until the server starts executing its 

551 # query. 

552 future1 = exec1.submit(do_query, first_butler) 

553 event_reached = query_event.wait(60) 

554 if not event_reached: 

555 raise TimeoutError("Server did not execute query logic as expected.") 

556 

557 # Start the second client, which will trigger the retry logic and 

558 # release the first client to finish its query. 

559 future2 = exec2.submit(do_query, second_butler) 

560 

561 result1 = future1.result(60) 

562 result2 = future2.result(60) 

563 self.assertEqual(len(result1), 3) 

564 self.assertEqual(len(result2), 3) 

565 # The original thread should have gone through this section, and 

566 # then the 2nd thread after it retries. 

567 self.assertEqual(mock_first_client.await_count, 2) 

568 # We should have triggered the retry logic at least once, but it 

569 # might occur multiple times depending how long the first client 

570 # takes to finish. 

571 self.assertGreaterEqual(mock_second_client.await_count, 1) 

572 

573 # TODO DM-46204: This can be removed once the RSP recommended image has 

574 # been upgraded to a version that contains DM-46129. 

575 def test_deprecated_collection_endpoints(self): 

576 # These REST endpoints are no longer used by Butler client so they need 

577 # to be checked separately until they can be removed. 

578 json = self.butler._connection.get( 

579 "collection_info", 

580 params={"name": "imported_g", "include_doc": True, "include_parents": True}, 

581 ).json() 

582 self.assertEqual(json["name"], "imported_g") 

583 self.assertEqual(json["type"], 1) 

584 

585 json = self.butler._connection.post( 

586 "query_collections", 

587 QueryCollectionsRequestModel( 

588 search=["imported_*"], collection_types=[1], flatten_chains=False, include_chains=False 

589 ), 

590 ).json() 

591 self.assertCountEqual(json["collections"], ["imported_g", "imported_r"]) 

592 

593 def test_oversized_data_coordinate_upload(self): 

594 with self.butler.query() as query: 

595 ref = self.simple_dataset_ref 

596 data_id = ref.dataId 

597 data_coordinates = [DataCoordinate.standardize(data_id, visit=x) for x in range(100_001)] 

598 with self.assertRaisesRegex(InvalidQueryError, "data coordinate rows"): 

599 list(query.join_data_coordinates(data_coordinates).datasets(ref.datasetType, ref.run)) 

600 

601 

602@unittest.skipIf(create_test_server is None, f"Server dependencies not installed: {reason_text}") 

603class ButlerClientServerAuthorizationTestCase(unittest.TestCase): 

604 """Test authentication/authorization functionality.""" 

605 

606 def test_group_authorization(self): 

607 """Test that group membership repository authorization is checked when 

608 repository is accessed. 

609 """ 

610 with create_test_server(TESTDIR) as server_instance: 

611 mock = MockGafaelfawrGroupAuthorizer() 

612 server_instance.app.dependency_overrides[authorizer_dependency] = lambda: mock 

613 server_instance.direct_butler.registry.registerDatasetType( 

614 DatasetType("bias", [], "int", universe=server_instance.direct_butler.dimensions) 

615 ) 

616 server_instance.direct_butler.collections.register("collection") 

617 butler = server_instance.remote_butler 

618 mock.set_response(False) 

619 with self.assertRaises(ButlerServerError) as e: 

620 butler.get_dataset_type("bias") 

621 self.assertEqual(e.exception.status_code, 403) 

622 with self.assertRaises(ButlerServerError) as e: 

623 butler.query_datasets("bias", collections="*", find_first=False) 

624 self.assertEqual(e.exception.status_code, 403) 

625 

626 mock.set_response(True) 

627 self.assertEqual(butler.get_dataset_type("bias").name, "bias") 

628 self.assertEqual(butler.query_datasets("bias", collections="collection", explain=False), []) 

629 

630 def test_cadc_auth(self) -> None: 

631 """Test server running in CADC auth mode.""" 

632 with mock_config() as config: 

633 config.authentication = "cadc" 

634 config.gafaelfawr_url = "DISABLED" 

635 with create_test_server(TESTDIR, server_config=config) as instance: 

636 self.assertIsInstance(instance.remote_butler._connection.auth, CadcAuthenticationProvider) 

637 

638 # Set up a dataset backed by an HTTP URL. 

639 # CADC uses a plain HTTP service, not S3, for hosting Butler 

640 # artifacts. 

641 dataset_type = DatasetType("test", [], "int", universe=instance.direct_butler.dimensions) 

642 ref = DatasetRef( 

643 datasetType=dataset_type, 

644 dataId=DataCoordinate.makeEmpty(instance.direct_butler.dimensions), 

645 run="ingest/run", 

646 ) 

647 path = ResourcePath("https://fake-server.example/some-directory/file.json") 

648 dataset = FileDataset(path, ref) 

649 # ingest() insists on doing file existence checks, and we don't 

650 # have an HTTP server to point it at. 

651 with unittest.mock.patch( 

652 "lsst.daf.butler.datastores.fileDatastore.FileDatastore._standardizeIngestPath" 

653 ) as mock: 

654 mock.return_value = path 

655 instance.direct_butler.ingest(dataset, transfer="direct", record_validation_info=False) 

656 

657 # At the CADC, paths used for file download should NOT be a 

658 # signed URL, and should have authentication headers attached. 

659 def check_path(path_to_check: ResourcePath): 

660 self.assertEqual(str(path_to_check), str(path)) 

661 assert isinstance(path_to_check, HttpResourcePath) 

662 self.assertIsNotNone(path_to_check._extra_headers) 

663 self.assertIsNotNone(path_to_check._extra_headers.get("Authorization")) 

664 

665 check_path(instance.remote_butler.getURI(ref)) 

666 transfer_map = instance.remote_butler._file_transfer_source.get_file_info_for_transfer( 

667 [ref.id] 

668 ) 

669 check_path(transfer_map[ref.id][0].location.pathInStore) 

670 

671 

672def _create_corrupted_dataset(repo: MetricTestRepo) -> DatasetRef: 

673 run = "corrupted-run" 

674 ref = repo.addDataset({"instrument": "DummyCamComp", "visit": 423}, run=run) 

675 uris = repo.butler.getURIs(ref) 

676 oneOfTheComponents = list(uris.componentURIs.values())[0] 

677 oneOfTheComponents.write("corrupted data") 

678 return ref 

679 

680 

681def _create_simple_dataset(butler: Butler) -> DatasetRef: 

682 dataset_type = addDatasetType(butler, "test_int", {"instrument", "visit"}, "int") 

683 ref = butler.put(123, dataset_type, dataId={"instrument": "DummyCamComp", "visit": 423}, run="ingest/run") 

684 return ref 

685 

686 

687def _timeout_twice(): 

688 """Return a mock side-effect function that raises a timeout error the first 

689 two times it is called. 

690 """ 

691 count = 0 

692 

693 def timeout(*args): 

694 nonlocal count 

695 count += 1 

696 if count <= 2: 

697 raise TimeoutError() 

698 return DEFAULT 

699 

700 return timeout 

701 

702 

703@unittest.skipIf(create_test_server is None, f"Server dependencies not installed: {reason_text}") 

704class QueryLimitsTestCase(unittest.IsolatedAsyncioTestCase): 

705 """Test details of the code that limits the maximum number of concurrent 

706 queries in the server. 

707 """ 

708 

709 async def test_query_limits(self): 

710 limits = lsst.daf.butler.remote_butler.server.handlers._query_limits.QueryLimits() 

711 

712 await limits.enforce_query_limits("user1") # under limit, doesn't raise 

713 async with limits.track_query("user1"): 

714 await limits.enforce_query_limits("user1") # under limit, doesn't raise 

715 async with limits.track_query("user1"): 

716 with self.assertRaises(fastapi.HTTPException) as exc: 

717 await limits.enforce_query_limits("user1") 

718 self.assertEqual(exc.exception.status_code, 429) 

719 

720 

721if __name__ == "__main__": 

722 unittest.main()