Coverage for tests/test_server.py: 14%

529 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-09-09 02:00 -0700

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 

28from __future__ import annotations 

29 

30import asyncio 

31import os.path 

32import tempfile 

33import threading 

34import unittest 

35import unittest.mock 

36import uuid 

37from collections.abc import Generator, Iterator 

38from concurrent.futures import ThreadPoolExecutor 

39from contextlib import contextmanager 

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

41 

42from lsst.daf.butler import ( 

43 Butler, 

44 DataCoordinate, 

45 DatasetId, 

46 DatasetNotFoundError, 

47 DatasetRef, 

48 DatasetType, 

49 FileDataset, 

50 InvalidQueryError, 

51 LabeledButlerFactory, 

52 MissingDatasetTypeError, 

53 NoDefaultCollectionError, 

54 StorageClassFactory, 

55 UnknownComponentError, 

56) 

57from lsst.daf.butler.datastore import DatasetRefURIs 

58from lsst.daf.butler.registry import DatasetTypeExpressionError, RegistryDefaults 

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

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

61from lsst.daf.butler.tests.server_available import butler_server_import_error, butler_server_is_available 

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

63from lsst.resources import ResourcePath 

64from lsst.resources.http import HttpResourcePath 

65 

66if butler_server_is_available: 66 ↛ 67line 66 didn't jump to line 67 because the condition on line 66 was never true

67 import fastapi 

68 import httpx 

69 import safir.dependencies.logger 

70 from fastapi.testclient import TestClient 

71 

72 import lsst.daf.butler.remote_butler._get 

73 import lsst.daf.butler.remote_butler._query_results 

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

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

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

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

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

79 _EXPLICIT_BUTLER_ACCESS_TOKEN_ENVIRONMENT_KEY, 

80 RubinAuthenticationProvider, 

81 ) 

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

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

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

85 authorizer_dependency, 

86 butler_factory_dependency, 

87 ) 

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

89 from lsst.daf.butler.remote_butler.server.handlers._query_streaming import ( 

90 _stream_query_pages, 

91 ) 

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

93 from lsst.daf.butler.remote_butler.server_models import QueryCollectionsRequestModel, QueryKeepAliveModel 

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

95 

96 

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

98 

99 

100@unittest.skipIf(not butler_server_is_available, butler_server_import_error) 

101class ButlerClientServerTestCase(unittest.TestCase): 

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

103 

104 @classmethod 

105 def setUpClass(cls): 

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

107 cls.server_instance = server_instance 

108 cls.client = server_instance.client 

109 cls.butler = server_instance.remote_butler 

110 cls.butler_without_error_propagation = server_instance.remote_butler_without_error_propagation 

111 

112 cls.storageClassFactory = StorageClassFactory() 

113 

114 cls.repo = MetricTestRepo.create_from_butler( 

115 server_instance.direct_butler, server_instance.config_file_path 

116 ) 

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

118 cls.dataset_with_corrupted_data = _create_corrupted_dataset(cls.repo) 

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

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

121 cls.simple_dataset_ref = _create_simple_dataset(server_instance.direct_butler) 

122 

123 # Populate the test server. 

124 # The DatastoreMock is required because the datasets referenced in 

125 # these imports do not point at real files. 

126 direct_butler = server_instance.direct_butler 

127 DatastoreMock.apply(direct_butler) 

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

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

130 

131 def test_health_check(self): 

132 try: 

133 import importlib.metadata 

134 

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

136 except ModuleNotFoundError: 

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

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

139 self.assertEqual(response.status_code, 200) 

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

141 

142 def test_static_files(self): 

143 with tempfile.TemporaryDirectory() as tmpdir: 

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

145 fh.write("test data 123") 

146 

147 with mock_config() as server_config: 

148 server_config.static_files_path = tmpdir 

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

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

151 self.assertEqual(response.status_code, 200) 

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

153 

154 def test_dimension_universe(self): 

155 universe = self.butler.dimensions 

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

157 

158 def test_get_dataset_type(self): 

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

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

161 

162 with self.assertRaises(MissingDatasetTypeError): 

163 self.butler_without_error_propagation.get_dataset_type("not_bias") 

164 

165 def test_get_component_dataset_type(self): 

166 """Test that retrieving a component dataset type does not require the 

167 server to know the parent's storage class (DM-55497). 

168 

169 Component dataset type names must never be sent to the server, so 

170 the component dataset type is constructed on the client from the 

171 parent definition. 

172 """ 

173 # Track the dataset type names requested from the server. 

174 requested_paths: list[str] = [] 

175 original_get = self.butler._connection.get 

176 

177 def tracking_get(path, **kwargs): 

178 requested_paths.append(path) 

179 return original_get(path, **kwargs) 

180 

181 with patch.object(self.butler._connection, "get", side_effect=tracking_get): 

182 component_type = self.butler.get_dataset_type("bias.image") 

183 

184 parent_type = self.butler.get_dataset_type("bias") 

185 self.assertEqual(component_type, parent_type.makeComponentDatasetType("image")) 

186 for path in requested_paths: 

187 if path.startswith("dataset_type/"): 

188 self.assertNotIn(".", path, f"Component dataset type name was sent to the server: {path!r}") 

189 

190 # A second call should be served from the client-side cache. 

191 self.assertEqual(self.butler.get_dataset_type("bias.image"), component_type) 

192 

193 # An unknown component raises client-side. 

194 with self.assertRaises(KeyError): 

195 self.butler.get_dataset_type("bias.not_a_component") 

196 

197 # An unknown parent dataset type still raises the standard error. 

198 with self.assertRaises(MissingDatasetTypeError): 

199 self.butler_without_error_propagation.get_dataset_type("not_bias.image") 

200 

201 def test_get_dataset_type_with_invalid_name(self): 

202 """Test that a syntactically invalid dataset type name is rejected on 

203 the client without contacting the server (DM-53347). 

204 """ 

205 with patch.object(self.butler._connection, "get") as mock: 

206 with self.assertRaises(DatasetTypeExpressionError): 

207 self.butler.get_dataset_type("...") 

208 mock.assert_not_called() 

209 

210 def test_query_dataset_types_with_invalid_name(self): 

211 """Test that a dataset type search expression the server cannot handle 

212 is reported to the client as a user error rather than an internal 

213 server error (DM-53347). 

214 """ 

215 # "..." is a valid wildcard for the Ellipsis object but not as a 

216 # string, where it parses as a component dataset type name. 

217 with self.assertRaises(DatasetTypeExpressionError): 

218 self.butler.registry.queryDatasetTypes("...") 

219 

220 with self.assertRaises(DatasetTypeExpressionError): 

221 self.butler.registry.queryDatasetTypes("bias.image") 

222 

223 def test_find_dataset(self): 

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

225 

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

227 self.assertIsInstance(ref, DatasetRef) 

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

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

230 

231 # Try again with variation of parameters. 

232 ref_new = self.butler.find_dataset( 

233 "bias", 

234 {"detector": 1}, 

235 collections="imported_g", 

236 instrument="Cam1", 

237 dimension_records=True, 

238 ) 

239 self.assertEqual(ref_new, ref) 

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

241 

242 ref_new = self.butler.find_dataset( 

243 ref.datasetType, 

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

245 collections="imported_g", 

246 storage_class=storage_class, 

247 ) 

248 self.assertEqual(ref_new, ref) 

249 

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

251 self.assertEqual(ref2, ref) 

252 

253 # Use detector name to find it. 

254 ref3 = self.butler.find_dataset( 

255 ref.datasetType, 

256 collections="imported_g", 

257 instrument="Cam1", 

258 full_name="Aa", 

259 ) 

260 self.assertEqual(ref2, ref3) 

261 

262 # Try expanded refs. 

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

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

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

266 

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

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

269 # code paths. 

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

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

272 

273 # Unknown dataset should not fail. 

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

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

276 

277 def test_instantiate_via_butler_http_search(self): 

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

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

280 RemoteButler pointing to the correct URL 

281 """ 

282 

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

284 # manipulations involving creating new ResourcePaths, and ResourcePath 

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

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

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

288 # 

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

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

291 # to the server under test. 

292 def override_read(http_resource_path): 

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

294 

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

296 

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

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

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

300 # server here. So mock it out. 

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

302 # Add access key to environment variables. RemoteButler 

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

304 # available. 

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

306 butler = Butler( 

307 server_url, 

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

309 run="collection2", 

310 ) 

311 self.enterContext(butler) 

312 self.assertIsInstance(butler, RemoteButler) 

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

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

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

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

317 self.assertFalse(butler._use_disabled_datastore_cache) 

318 

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

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

321 self.assertIsInstance(factory_created_butler, RemoteButler) 

322 self.assertTrue(factory_created_butler._use_disabled_datastore_cache) 

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

324 

325 def test_get(self): 

326 dataset_type = "test_metric_comp" 

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

328 collections = "ingest/run" 

329 # Test get() of a DatasetRef. 

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

331 metric = self.butler.get(ref) 

332 self.assertIsInstance(metric, MetricsExample) 

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

334 

335 # Test get() by DataId. 

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

337 self.assertEqual(metric, data_id_metric) 

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

339 kwarg_metric = self.butler.get( 

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

341 ) 

342 self.assertEqual(metric, kwarg_metric) 

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

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

345 kwarg_data_coordinate_metric = self.butler.get( 

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

347 ) 

348 self.assertEqual(metric, kwarg_data_coordinate_metric) 

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

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

351 with self.assertRaises(DatasetNotFoundError): 

352 self.butler_without_error_propagation.get( 

353 dataset_type, dataId=invalid_data_id, collections=collections 

354 ) 

355 

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

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

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

359 self.assertEqual(metric, default_collection_metric) 

360 

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

362 with self.assertRaises(NoDefaultCollectionError): 

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

364 

365 # Test looking up a non-existent ref 

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

367 with self.assertRaises(DatasetNotFoundError): 

368 self.butler_without_error_propagation.get(invalid_ref) 

369 

370 with self.assertRaises(RuntimeError): 

371 self.butler_without_error_propagation.get(self.dataset_with_corrupted_data) 

372 

373 # Test storage class override 

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

375 

376 def check_sc_override(converted): 

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

378 self.assertIsInstance(converted, new_sc.pytype) 

379 self.assertEqual(metric, converted) 

380 

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

382 

383 # Test storage class override via DatasetRef. 

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

385 # Test storage class override via DatasetType. 

386 check_sc_override( 

387 self.butler.get( 

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

389 ) 

390 ) 

391 

392 # Test component override via DatasetRef. 

393 component_ref = ref.makeComponentRef("summary") 

394 component_data = self.butler.get(component_ref) 

395 self.assertEqual(component_data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY) 

396 

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

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

399 self.assertIsInstance(converted_component_data, DictConvertibleModel) 

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

401 

402 # Test component override via DatasetType. 

403 dataset_type_component_data = self.butler.get( 

404 component_ref.datasetType, component_ref.dataId, collections=collections 

405 ) 

406 self.assertEqual(dataset_type_component_data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY) 

407 

408 def test_get_formatter_receives_registry_ref(self): 

409 """The Formatter must always be given the `DatasetRef` as defined in 

410 the repository, so its storage class is unaffected by any read-time 

411 storage class override. 

412 

413 Formatters should read the write storage class from the 

414 `FileDescriptor`, but some read it from the ref, so `RemoteButler` must 

415 hand the Formatter the same ref that `DirectButler` does. 

416 """ 

417 remote_get = lsst.daf.butler.remote_butler._get 

418 

419 dataset_type = "test_metric_comp" 

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

421 collections = "ingest/run" 

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

423 write_storage_class = ref.datasetType.storageClass 

424 override = self.storageClassFactory.getStorageClass("MetricsConversion") 

425 self.assertNotEqual(write_storage_class, override) 

426 

427 captured: dict[str, object] = {} 

428 original = remote_get.generate_datastore_get_information 

429 

430 def capturing(fileLocations, *, registry_ref, read_ref, parameters): 

431 captured["registry_storage_class"] = registry_ref.datasetType.storageClass 

432 captured["read_storage_class"] = read_ref.datasetType.storageClass 

433 return original( 

434 fileLocations, registry_ref=registry_ref, read_ref=read_ref, parameters=parameters 

435 ) 

436 

437 with patch.object(remote_get, "generate_datastore_get_information", side_effect=capturing): 

438 self.butler.get(ref, storageClass=override) 

439 

440 # The ref given to the Formatter must carry the storage class from the 

441 # repository, and the override must instead travel via the read ref. 

442 self.assertEqual(captured["registry_storage_class"], write_storage_class) 

443 self.assertEqual(captured["read_storage_class"], override) 

444 

445 def test_component_access_without_server_storage_class(self): 

446 """Test that component dataset access via dataset type name does not 

447 require the server to know the parent's storage class. 

448 

449 Storage classes for many dataset types are defined by science 

450 pipelines packages that are only installed on the client, so all 

451 component handling must occur on the client. 

452 """ 

453 dataset_type = "test_metric_comp" 

454 component_type = "test_metric_comp.summary" 

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

456 collections = "ingest/run" 

457 parent_ref = self.butler.find_dataset(dataset_type, data_id, collections=collections) 

458 component_ref = parent_ref.makeComponentRef("summary") 

459 

460 # Track the dataset type names sent to the server. Component names 

461 # must never be sent, because converting a component dataset type 

462 # name to a DatasetType requires the server to instantiate the 

463 # parent's storage class. 

464 sent_dataset_types: list[str] = [] 

465 original_post = self.butler._connection.post 

466 

467 def tracking_post(path, model): 

468 dataset_type_name = getattr(model, "dataset_type", None) 

469 if dataset_type_name is not None: 

470 sent_dataset_types.append(dataset_type_name) 

471 return original_post(path, model) 

472 

473 with patch.object(self.butler._connection, "post", side_effect=tracking_post): 

474 # get() with a component dataset type name. 

475 data = self.butler.get(component_type, dataId=data_id, collections=collections) 

476 self.assertEqual(data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY) 

477 

478 # find_dataset() with a component dataset type name. 

479 found = self.butler.find_dataset(component_type, data_id, collections=collections) 

480 self.assertEqual(found, component_ref) 

481 self.assertEqual(found.datasetType, component_ref.datasetType) 

482 

483 # getDeferred() with a component dataset type name. 

484 deferred_data = self.butler.getDeferred(component_type, data_id, collections=collections).get() 

485 self.assertEqual(deferred_data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY) 

486 

487 # getDeferred() with a component DatasetRef. 

488 deferred_ref_data = self.butler.getDeferred(component_ref).get() 

489 self.assertEqual(deferred_ref_data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY) 

490 

491 # A trailing separator with no component name is not a valid 

492 # dataset type name and is rejected rather than silently 

493 # returning the composite. 

494 with self.assertRaises(DatasetTypeExpressionError): 

495 self.butler.get(f"{dataset_type}.", dataId=data_id, collections=collections) 

496 with self.assertRaises(DatasetTypeExpressionError): 

497 self.butler.getDeferred(f"{dataset_type}.", data_id, collections=collections) 

498 

499 self.assertGreater(len(sent_dataset_types), 0) 

500 for name in sent_dataset_types: 

501 self.assertNotIn(".", name, f"Component dataset type name {name!r} was sent to the server") 

502 

503 def test_getURIs_no_components(self): 

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

505 def check_uri(uri: ResourcePath): 

506 self.assertIsNotNone(uris.primaryURI) 

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

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

509 

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

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

512 check_uri(uris.primaryURI) 

513 

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

515 

516 def test_getURIs_multiple_components(self): 

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

518 # URIs. 

519 dataset_type = "test_metric_comp" 

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

521 collections = "ingest/run" 

522 

523 def check_uris(uris: DatasetRefURIs): 

524 self.assertIsNone(uris.primaryURI) 

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

526 path = uris.componentURIs["summary"] 

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

528 data = path.read() 

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

530 

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

532 check_uris(uris) 

533 

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

535 with self.assertRaises(RuntimeError): 

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

537 

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

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

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

541 # behavior. 

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

543 componentRef = ref.makeComponentRef("summary") 

544 componentUris = self.butler.getURIs(componentRef) 

545 check_uris(componentUris) 

546 

547 def test_file_download_redirect(self): 

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

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

550 return self.client.get( 

551 uri, 

552 follow_redirects=False, 

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

554 ) 

555 

556 # Test behavior of a single-file dataset. 

557 response = get_download_redirect(self.simple_dataset_ref.id) 

558 self.assertEqual(response.status_code, 307) 

559 self.assertTrue(response.has_redirect_location) 

560 assert response.next_request is not None 

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

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

563 

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

565 self.assertEqual(response.status_code, 404) 

566 

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

568 dataset_type = "test_metric_comp" 

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

570 collections = "ingest/run" 

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

572 

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

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

575 self.assertEqual(response.status_code, 307) 

576 self.assertTrue(response.has_redirect_location) 

577 assert response.next_request is not None 

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

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

580 

581 # Unknown component. 

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

583 self.assertEqual(response.status_code, 404) 

584 

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

586 # composite". 

587 response = get_download_redirect(ref.id, None) 

588 self.assertEqual(response.status_code, 422) 

589 

590 # Unknown dataset. 

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

592 self.assertEqual(response.status_code, 404) 

593 

594 def test_auth_check(self): 

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

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

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

598 # handled by GafaelfawrIngress, not our app. 

599 with self.assertRaises(UnhandledServerError): 

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

601 

602 def test_exception_logging(self): 

603 app = create_app() 

604 

605 def raise_error(): 

606 raise RuntimeError("An unhandled error") 

607 

608 app.dependency_overrides[butler_factory_dependency] = raise_error 

609 client = TestClient(app, raise_server_exceptions=False) 

610 

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

612 mock_logger = NonCallableMock(["aerror"]) 

613 

614 async def noop(): 

615 pass 

616 

617 mock_logger.aerror.return_value = noop() 

618 

619 async def get_logger(): 

620 return mock_logger 

621 

622 mock_logger_dep.return_value = get_logger() 

623 client.get( 

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

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

626 ) 

627 mock_logger_dep.assert_called_once() 

628 

629 mock_logger.aerror.assert_called_once() 

630 args, kwargs = mock_logger.aerror.call_args 

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

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

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

634 

635 def test_query_keepalive(self): 

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

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

638 results. 

639 """ 

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

641 # immediately instead. 

642 with patch.object( 

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

644 ) as mock_timeout: 

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

646 # seen. 

647 with patch.object( 

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

649 ) as mock_keep_alive: 

650 mock_timeout.side_effect = _timeout_twice() 

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

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

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

654 self.assertGreaterEqual(mock_timeout.call_count, 3) 

655 self.assertGreaterEqual(mock_keep_alive.call_count, 2) 

656 

657 def test_query_retries(self): 

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

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

660 happens. 

661 """ 

662 query_event = threading.Event() 

663 retry_event = asyncio.Event() 

664 

665 async def block_first_request() -> None: 

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

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

668 # slot. 

669 query_event.set() 

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

671 # retry. 

672 await retry_event.wait() 

673 

674 async def block_second_request() -> None: 

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

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

677 retry_event.set() 

678 

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

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

681 

682 with ( 

683 patch.object( 

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

685 "_MAXIMUM_CONCURRENT_STREAMING_QUERIES", 

686 new=1, 

687 ), 

688 patch.object( 

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

690 ), 

691 patch.object( 

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

693 "_block_query_for_unit_test", 

694 new=AsyncMock(wraps=block_first_request), 

695 ) as mock_first_client, 

696 patch.object( 

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

698 "_block_retry_for_unit_test", 

699 new=AsyncMock(wraps=block_second_request), 

700 ) as mock_second_client, 

701 ThreadPoolExecutor(max_workers=1) as exec1, 

702 ThreadPoolExecutor(max_workers=1) as exec2, 

703 ): 

704 first_butler = self.butler 

705 second_butler = self.butler.clone() 

706 

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

708 # query. 

709 future1 = exec1.submit(do_query, first_butler) 

710 event_reached = query_event.wait(60) 

711 if not event_reached: 

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

713 

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

715 # release the first client to finish its query. 

716 future2 = exec2.submit(do_query, second_butler) 

717 

718 result1 = future1.result(60) 

719 result2 = future2.result(60) 

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

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

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

723 # then the 2nd thread after it retries. 

724 self.assertEqual(mock_first_client.await_count, 2) 

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

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

727 # takes to finish. 

728 self.assertGreaterEqual(mock_second_client.await_count, 1) 

729 

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

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

732 def test_deprecated_collection_endpoints(self): 

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

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

735 json = self.butler._connection.get( 

736 "collection_info", 

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

738 ).json() 

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

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

741 

742 json = self.butler._connection.post( 

743 "query_collections", 

744 QueryCollectionsRequestModel( 

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

746 ), 

747 ).json() 

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

749 

750 def test_oversized_data_coordinate_upload(self): 

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

752 ref = self.simple_dataset_ref 

753 data_id = ref.dataId 

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

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

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

757 

758 

759@unittest.skipIf(not butler_server_is_available, butler_server_import_error) 

760class ButlerClientServerParityTestCase(unittest.TestCase): 

761 """Test that `RemoteButler` and `DirectButler` agree for every combination 

762 of component request and storage class override. 

763 

764 This uses its own server instance because 

765 `ButlerClientServerTestCase` mocks the `DirectButler` datastore, which 

766 would leave nothing to compare the `RemoteButler` results against. 

767 """ 

768 

769 COMPONENT = "summary" 

770 

771 COMPOSITE_STORAGE_CLASS = "MetricsConversion" 

772 """Read storage class for the composite. Defines the same components as 

773 ``StructuredData`` but with a different Python type. 

774 """ 

775 

776 COMPONENT_STORAGE_CLASS = "DictConvertibleModel" 

777 """Read storage class for the component itself.""" 

778 

779 DATA_ID = {"instrument": "DummyCamComp", "visit": 423} 

780 COLLECTIONS = "ingest/run" 

781 

782 @classmethod 

783 def setUpClass(cls): 

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

785 cls.direct_butler = server_instance.direct_butler 

786 cls.remote_butler = server_instance.remote_butler 

787 cls.storageClassFactory = StorageClassFactory() 

788 repo = MetricTestRepo.create_from_butler( 

789 server_instance.direct_butler, server_instance.config_file_path 

790 ) 

791 # One dataset whose write storage class defines the component that will 

792 # be requested, and one whose write storage class defines no components 

793 # at all so that the component can only come from the read override. 

794 cls.refs = {} 

795 for name, storage_class in ( 

796 ("parity_with_components", "StructuredData"), 

797 ("parity_no_components", "StructuredDataNoComponents"), 

798 ): 

799 dataset_type = addDatasetType(repo.butler, name, {"instrument", "visit"}, storage_class) 

800 cls.refs[name] = repo.addDataset(cls.DATA_ID, datasetType=dataset_type) 

801 

802 def butlers(self): 

803 """Iterate over the butlers that must behave identically. 

804 

805 Yields 

806 ------ 

807 name : `str` 

808 Name of the butler, for use in subtest labels. 

809 butler : `Butler` 

810 The butler itself. 

811 """ 

812 yield "direct", self.direct_butler 

813 yield "remote", self.remote_butler 

814 

815 def test_composite_parity(self): 

816 """Both butlers must return the same composite, with and without a read 

817 storage class override. 

818 """ 

819 composite_pytype = self.storageClassFactory.getStorageClass(self.COMPOSITE_STORAGE_CLASS).pytype 

820 for name, ref in self.refs.items(): 

821 write_pytype = ref.datasetType.storageClass.pytype 

822 override_ref = ref.overrideStorageClass(self.COMPOSITE_STORAGE_CLASS) 

823 cases = { 

824 "get(ref)": (lambda b, r=ref: b.get(r), write_pytype), 

825 "get(name, dataId)": ( 

826 lambda b, n=name: b.get(n, self.DATA_ID, collections=self.COLLECTIONS), 

827 write_pytype, 

828 ), 

829 "get(ref, storageClass)": ( 

830 lambda b, r=ref: b.get(r, storageClass=self.COMPOSITE_STORAGE_CLASS), 

831 composite_pytype, 

832 ), 

833 "get(override_ref)": (lambda b, r=override_ref: b.get(r), composite_pytype), 

834 "getDeferred(ref).get()": (lambda b, r=ref: b.getDeferred(r).get(), write_pytype), 

835 "getDeferred(ref, storageClass).get()": ( 

836 lambda b, r=ref: b.getDeferred(r, storageClass=self.COMPOSITE_STORAGE_CLASS).get(), 

837 composite_pytype, 

838 ), 

839 } 

840 for label, (call, pytype) in cases.items(): 

841 for butler_name, butler in self.butlers(): 

842 with self.subTest(dataset=name, case=label, butler=butler_name): 

843 value = call(butler) 

844 self.assertIsInstance(value, pytype) 

845 self.assertEqual(value.summary, MetricTestRepo.METRICS_EXAMPLE_SUMMARY) 

846 

847 def test_component_parity(self): 

848 """Both butlers must return the same component, whether it is defined 

849 by the storage class used to write or only by the read override. 

850 """ 

851 summary = MetricTestRepo.METRICS_EXAMPLE_SUMMARY 

852 converted = DictConvertibleModel.from_dict(summary) 

853 for name, ref in self.refs.items(): 

854 override_ref = ref.overrideStorageClass(self.COMPOSITE_STORAGE_CLASS) 

855 component_ref = override_ref.makeComponentRef(self.COMPONENT) 

856 cases = { 

857 # A component of the overridden composite, requested by ref, by 

858 # dataset type, and via a deferred handle. The component 

859 # dataset type carries the composite override in its 

860 # parentStorageClass, so unlike a component dataset type *name* 

861 # it can express both overrides at once. 

862 "get(component_ref)": (lambda b, r=component_ref: b.get(r), summary), 

863 "get(component_ref, storageClass)": ( 

864 lambda b, r=component_ref: b.get(r, storageClass=self.COMPONENT_STORAGE_CLASS), 

865 converted, 

866 ), 

867 "get(component_datasetType, dataId)": ( 

868 lambda b, r=component_ref: b.get( 

869 r.datasetType, self.DATA_ID, collections=self.COLLECTIONS 

870 ), 

871 summary, 

872 ), 

873 "getDeferred(component_ref).get()": ( 

874 lambda b, r=component_ref: b.getDeferred(r).get(), 

875 summary, 

876 ), 

877 # The handle storage class applies to the composite and so 

878 # selects the component, while the one given to get() applies 

879 # to the component itself. 

880 "getDeferred(ref, storageClass).get(component)": ( 

881 lambda b, r=ref: b.getDeferred(r, storageClass=self.COMPOSITE_STORAGE_CLASS).get( 

882 component=self.COMPONENT 

883 ), 

884 summary, 

885 ), 

886 "getDeferred(ref, storageClass).get(component, storageClass)": ( 

887 lambda b, r=ref: b.getDeferred(r, storageClass=self.COMPOSITE_STORAGE_CLASS).get( 

888 component=self.COMPONENT, storageClass=self.COMPONENT_STORAGE_CLASS 

889 ), 

890 converted, 

891 ), 

892 } 

893 if self.COMPONENT in ref.datasetType.storageClass.allComponents(): 

894 # The write storage class knows the component, so the plain 

895 # forms that carry no override also work. 

896 cases["get('name.component', dataId)"] = ( 

897 lambda b, n=name: b.get( 

898 f"{n}.{self.COMPONENT}", self.DATA_ID, collections=self.COLLECTIONS 

899 ), 

900 summary, 

901 ) 

902 cases["get('name.component', dataId, storageClass)"] = ( 

903 lambda b, n=name: b.get( 

904 f"{n}.{self.COMPONENT}", 

905 self.DATA_ID, 

906 collections=self.COLLECTIONS, 

907 storageClass=self.COMPONENT_STORAGE_CLASS, 

908 ), 

909 converted, 

910 ) 

911 cases["getDeferred(ref).get(component)"] = ( 

912 lambda b, r=ref: b.getDeferred(r).get(component=self.COMPONENT), 

913 summary, 

914 ) 

915 cases["getDeferred('name.component', dataId).get()"] = ( 

916 lambda b, n=name: b.getDeferred( 

917 f"{n}.{self.COMPONENT}", self.DATA_ID, collections=self.COLLECTIONS 

918 ).get(), 

919 summary, 

920 ) 

921 for label, (call, expected) in cases.items(): 

922 for butler_name, butler in self.butlers(): 

923 with self.subTest(dataset=name, case=label, butler=butler_name): 

924 self.assertEqual(call(butler), expected) 

925 

926 def test_unconveyable_component_override_parity(self): 

927 """Both butlers must raise for a component that only the read storage 

928 class defines when the request cannot convey the composite override. 

929 

930 A component dataset type *name* has nowhere to record a storage class 

931 override for the composite, and the ``storageClass`` argument applies 

932 to the component, so there is no way to say which composite storage 

933 class defines the component. Raising is the only correct answer; a 

934 `DatasetRef` or `DatasetType` must be used instead. 

935 """ 

936 name = "parity_no_components" 

937 ref = self.refs[name] 

938 self.assertNotIn(self.COMPONENT, ref.datasetType.storageClass.allComponents()) 

939 component_type = f"{name}.{self.COMPONENT}" 

940 cases = { 

941 "get('name.component', dataId)": lambda b: b.get( 

942 component_type, self.DATA_ID, collections=self.COLLECTIONS 

943 ), 

944 "get('name.component', dataId, storageClass)": lambda b: b.get( 

945 component_type, 

946 self.DATA_ID, 

947 collections=self.COLLECTIONS, 

948 storageClass=self.COMPONENT_STORAGE_CLASS, 

949 ), 

950 "getDeferred('name.component', dataId)": lambda b: b.getDeferred( 

951 component_type, self.DATA_ID, collections=self.COLLECTIONS 

952 ), 

953 "getDeferred('name.component', dataId, storageClass)": lambda b: b.getDeferred( 

954 component_type, 

955 self.DATA_ID, 

956 collections=self.COLLECTIONS, 

957 storageClass=self.COMPONENT_STORAGE_CLASS, 

958 ), 

959 "getDeferred(ref).get(component)": lambda b: b.getDeferred(ref).get(component=self.COMPONENT), 

960 } 

961 for label, call in cases.items(): 

962 for butler_name, butler in self.butlers(): 

963 with self.subTest(case=label, butler=butler_name): 

964 with self.assertRaises(UnknownComponentError): 

965 call(butler) 

966 

967 

968@unittest.skipIf(not butler_server_is_available, butler_server_import_error) 

969class ButlerClientServerAuthorizationTestCase(unittest.TestCase): 

970 """Test authentication/authorization functionality.""" 

971 

972 def test_group_authorization(self): 

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

974 repository is accessed. 

975 """ 

976 with create_test_server(TESTDIR) as server_instance: 

977 mock = MockGafaelfawrGroupAuthorizer() 

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

979 server_instance.direct_butler.registry.registerDatasetType( 

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

981 ) 

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

983 butler = server_instance.remote_butler 

984 mock.set_response(False) 

985 with self.assertRaises(ButlerServerError) as e: 

986 butler.get_dataset_type("bias") 

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

988 with self.assertRaises(ButlerServerError) as e: 

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

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

991 

992 mock.set_response(True) 

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

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

995 

996 def test_cadc_auth(self) -> None: 

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

998 with mock_config() as config: 

999 config.authentication = "cadc" 

1000 config.gafaelfawr_url = "DISABLED" 

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

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

1003 

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

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

1006 # artifacts. 

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

1008 ref = DatasetRef( 

1009 datasetType=dataset_type, 

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

1011 run="ingest/run", 

1012 ) 

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

1014 dataset = FileDataset(path, ref) 

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

1016 # have an HTTP server to point it at. 

1017 with unittest.mock.patch( 

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

1019 ) as mock: 

1020 mock.return_value = path 

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

1022 

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

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

1025 def check_path(path_to_check: ResourcePath): 

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

1027 assert isinstance(path_to_check, HttpResourcePath) 

1028 self.assertIsNotNone(path_to_check._extra_headers) 

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

1030 

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

1032 transfer_map = instance.remote_butler._file_transfer_source.get_file_info_for_transfer( 

1033 [ref.id] 

1034 ) 

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

1036 

1037 

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

1039 run = "corrupted-run" 

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

1041 uris = repo.butler.getURIs(ref) 

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

1043 oneOfTheComponents.write("corrupted data") 

1044 return ref 

1045 

1046 

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

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

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

1050 return ref 

1051 

1052 

1053def _timeout_twice(): 

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

1055 two times it is called. 

1056 """ 

1057 count = 0 

1058 

1059 def timeout(*args): 

1060 nonlocal count 

1061 count += 1 

1062 if count <= 2: 

1063 raise TimeoutError() 

1064 return DEFAULT 

1065 

1066 return timeout 

1067 

1068 

1069class _MockStreamingQuery: 

1070 @contextmanager 

1071 def setup(self) -> Generator[None]: 

1072 self.cleanup_executed = False 

1073 self.count = 0 

1074 try: 

1075 yield 

1076 finally: 

1077 self.cleanup_executed = True 

1078 

1079 def execute(self, context: None) -> Iterator[QueryKeepAliveModel]: 

1080 for _ in range(10): 

1081 self.count += 1 

1082 yield QueryKeepAliveModel() 

1083 

1084 

1085@unittest.skipIf(not butler_server_is_available, butler_server_import_error) 

1086class QueryStreamingTestCase(unittest.IsolatedAsyncioTestCase): 

1087 """Test implementation details of query streaming code.""" 

1088 

1089 async def test_query_disconnects(self): 

1090 query = _MockStreamingQuery() 

1091 iter = _stream_query_pages(query, None) 

1092 await anext(iter) 

1093 await anext(iter) 

1094 # FastAPI calls aclose() on the generator when disconnect occurs, so we 

1095 # simulate that here. 

1096 await iter.aclose() 

1097 # We should have gone through the loop at least twice to yield the two 

1098 # results we read. Can go up to 3-4 times depending on the timing of 

1099 # the buffered results from the synchronous inner thread to the async 

1100 # code. 

1101 self.assertIn(query.count, (2, 3, 4)) 

1102 # Cleanup was run correctly, meaning the thread was given time to exit 

1103 # before returning from aclose(). 

1104 self.assertTrue(query.cleanup_executed) 

1105 

1106 

1107@unittest.skipIf(not butler_server_is_available, butler_server_import_error) 

1108class QueryLimitsTestCase(unittest.IsolatedAsyncioTestCase): 

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

1110 queries in the server. 

1111 """ 

1112 

1113 async def test_query_limits(self): 

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

1115 

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

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

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

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

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

1121 await limits.enforce_query_limits("user1") 

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

1123 

1124 

1125if __name__ == "__main__": 

1126 unittest.main()