Coverage for tests/test_server.py: 12%
431 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-07 02:48 -0700
« prev ^ index » next coverage.py v7.15.2, created at 2026-08-07 02:48 -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/>.
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
38from lsst.daf.butler import (
39 Butler,
40 DataCoordinate,
41 DatasetId,
42 DatasetNotFoundError,
43 DatasetRef,
44 DatasetType,
45 FileDataset,
46 InvalidQueryError,
47 LabeledButlerFactory,
48 MissingDatasetTypeError,
49 NoDefaultCollectionError,
50 StorageClassFactory,
51)
52from lsst.daf.butler.datastore import DatasetRefURIs
53from lsst.daf.butler.registry import RegistryDefaults
54from lsst.daf.butler.tests import DatastoreMock, addDatasetType
55from lsst.daf.butler.tests.dict_convertible_model import DictConvertibleModel
56from lsst.daf.butler.tests.server_available import butler_server_import_error, butler_server_is_available
57from lsst.daf.butler.tests.utils import MetricsExample, MetricTestRepo, mock_env
58from lsst.resources import ResourcePath
59from lsst.resources.http import HttpResourcePath
61if butler_server_is_available: 61 ↛ 62line 61 didn't jump to line 62 because the condition on line 61 was never true
62 import fastapi
63 import httpx
64 import safir.dependencies.logger
65 from fastapi.testclient import TestClient
67 import lsst.daf.butler.remote_butler._get
68 import lsst.daf.butler.remote_butler._query_results
69 import lsst.daf.butler.remote_butler.server.handlers._query_limits
70 import lsst.daf.butler.remote_butler.server.handlers._query_streaming
71 from lsst.daf.butler.remote_butler import ButlerServerError, RemoteButler
72 from lsst.daf.butler.remote_butler.authentication.cadc import CadcAuthenticationProvider
73 from lsst.daf.butler.remote_butler.authentication.rubin import (
74 _EXPLICIT_BUTLER_ACCESS_TOKEN_ENVIRONMENT_KEY,
75 RubinAuthenticationProvider,
76 )
77 from lsst.daf.butler.remote_butler.server import create_app
78 from lsst.daf.butler.remote_butler.server._config import mock_config
79 from lsst.daf.butler.remote_butler.server._dependencies import (
80 authorizer_dependency,
81 butler_factory_dependency,
82 )
83 from lsst.daf.butler.remote_butler.server._gafaelfawr import MockGafaelfawrGroupAuthorizer
84 from lsst.daf.butler.remote_butler.server.handlers._utils import generate_file_download_uri
85 from lsst.daf.butler.remote_butler.server_models import QueryCollectionsRequestModel
86 from lsst.daf.butler.tests.server import TEST_REPOSITORY_NAME, UnhandledServerError, create_test_server
89TESTDIR = os.path.abspath(os.path.dirname(__file__))
92@unittest.skipIf(not butler_server_is_available, butler_server_import_error)
93class ButlerClientServerTestCase(unittest.TestCase):
94 """Test for Butler client/server."""
96 @classmethod
97 def setUpClass(cls):
98 server_instance = cls.enterClassContext(create_test_server(TESTDIR))
99 cls.server_instance = server_instance
100 cls.client = server_instance.client
101 cls.butler = server_instance.remote_butler
102 cls.butler_without_error_propagation = server_instance.remote_butler_without_error_propagation
104 cls.storageClassFactory = StorageClassFactory()
106 cls.repo = MetricTestRepo.create_from_butler(
107 server_instance.direct_butler, server_instance.config_file_path
108 )
109 # Add a file with corrupted data for testing error conditions
110 cls.dataset_with_corrupted_data = _create_corrupted_dataset(cls.repo)
111 # All of the datasets that come with MetricTestRepo are disassembled
112 # composites. Add a simple dataset for testing the common case.
113 cls.simple_dataset_ref = _create_simple_dataset(server_instance.direct_butler)
115 # Populate the test server.
116 # The DatastoreMock is required because the datasets referenced in
117 # these imports do not point at real files.
118 direct_butler = server_instance.direct_butler
119 DatastoreMock.apply(direct_butler)
120 direct_butler.import_(filename="resource://lsst.daf.butler/tests/registry_data/base.yaml")
121 direct_butler.import_(filename="resource://lsst.daf.butler/tests/registry_data/datasets.yaml")
123 def test_health_check(self):
124 try:
125 import importlib.metadata
127 importlib.metadata.metadata("lsst.daf.butler")
128 except ModuleNotFoundError:
129 raise self.skipTest("Standard python package metadata not available. Butler not pip installed.")
130 response = self.client.get("/")
131 self.assertEqual(response.status_code, 200)
132 self.assertEqual(response.json()["name"], "butler")
134 def test_static_files(self):
135 with tempfile.TemporaryDirectory() as tmpdir:
136 with open(os.path.join(tmpdir, "temp.txt"), "w") as fh:
137 fh.write("test data 123")
139 with mock_config() as server_config:
140 server_config.static_files_path = tmpdir
141 with create_test_server(TESTDIR, server_config=server_config) as server:
142 response = server.client.get("/api/butler/configs/temp.txt")
143 self.assertEqual(response.status_code, 200)
144 self.assertEqual(response.text, "test data 123")
146 def test_dimension_universe(self):
147 universe = self.butler.dimensions
148 self.assertEqual(universe.namespace, "daf_butler")
150 def test_get_dataset_type(self):
151 bias_type = self.butler.get_dataset_type("bias")
152 self.assertEqual(bias_type.name, "bias")
154 with self.assertRaises(MissingDatasetTypeError):
155 self.butler_without_error_propagation.get_dataset_type("not_bias")
157 def test_get_component_dataset_type(self):
158 """Test that retrieving a component dataset type does not require the
159 server to know the parent's storage class (DM-55497).
161 Component dataset type names must never be sent to the server, so
162 the component dataset type is constructed on the client from the
163 parent definition.
164 """
165 # Track the dataset type names requested from the server.
166 requested_paths: list[str] = []
167 original_get = self.butler._connection.get
169 def tracking_get(path, **kwargs):
170 requested_paths.append(path)
171 return original_get(path, **kwargs)
173 with patch.object(self.butler._connection, "get", side_effect=tracking_get):
174 component_type = self.butler.get_dataset_type("bias.image")
176 parent_type = self.butler.get_dataset_type("bias")
177 self.assertEqual(component_type, parent_type.makeComponentDatasetType("image"))
178 for path in requested_paths:
179 if path.startswith("dataset_type/"):
180 self.assertNotIn(".", path, f"Component dataset type name was sent to the server: {path!r}")
182 # A second call should be served from the client-side cache.
183 self.assertEqual(self.butler.get_dataset_type("bias.image"), component_type)
185 # An unknown component raises client-side.
186 with self.assertRaises(KeyError):
187 self.butler.get_dataset_type("bias.not_a_component")
189 # An unknown parent dataset type still raises the standard error.
190 with self.assertRaises(MissingDatasetTypeError):
191 self.butler_without_error_propagation.get_dataset_type("not_bias.image")
193 def test_find_dataset(self):
194 storage_class = self.storageClassFactory.getStorageClass("Exposure")
196 ref = self.butler.find_dataset("bias", collections="imported_g", detector=1, instrument="Cam1")
197 self.assertIsInstance(ref, DatasetRef)
198 self.assertEqual(ref.id, uuid.UUID("e15ab039-bc8b-4135-87c5-90902a7c0b22"))
199 self.assertFalse(ref.dataId.hasRecords())
201 # Try again with variation of parameters.
202 ref_new = self.butler.find_dataset(
203 "bias",
204 {"detector": 1},
205 collections="imported_g",
206 instrument="Cam1",
207 dimension_records=True,
208 )
209 self.assertEqual(ref_new, ref)
210 self.assertTrue(ref_new.dataId.hasRecords())
212 ref_new = self.butler.find_dataset(
213 ref.datasetType,
214 DataCoordinate.standardize(detector=1, instrument="Cam1", universe=self.butler.dimensions),
215 collections="imported_g",
216 storage_class=storage_class,
217 )
218 self.assertEqual(ref_new, ref)
220 ref2 = self.butler.get_dataset(ref.id)
221 self.assertEqual(ref2, ref)
223 # Use detector name to find it.
224 ref3 = self.butler.find_dataset(
225 ref.datasetType,
226 collections="imported_g",
227 instrument="Cam1",
228 full_name="Aa",
229 )
230 self.assertEqual(ref2, ref3)
232 # Try expanded refs.
233 self.assertFalse(ref.dataId.hasRecords())
234 expanded = self.butler.get_dataset(ref.id, dimension_records=True)
235 self.assertTrue(expanded.dataId.hasRecords())
237 # The test datasets are all Exposure so storage class conversion
238 # can not be tested until we fix that. For now at least test the
239 # code paths.
240 bias = self.butler.get_dataset(ref.id, storage_class=storage_class)
241 self.assertEqual(bias.datasetType.storageClass, storage_class)
243 # Unknown dataset should not fail.
244 self.assertIsNone(self.butler.get_dataset(uuid.uuid4()))
245 self.assertIsNone(self.butler.get_dataset(uuid.uuid4(), storage_class="NumpyArray"))
247 def test_instantiate_via_butler_http_search(self):
248 """Ensure that the primary Butler constructor's automatic search logic
249 correctly locates and reads the configuration file and ends up with a
250 RemoteButler pointing to the correct URL
251 """
253 # This is kind of a fragile test. Butler's search logic does a lot of
254 # manipulations involving creating new ResourcePaths, and ResourcePath
255 # doesn't use httpx so we can't easily inject the TestClient in there.
256 # We don't have an actual valid HTTP URL to give to the constructor
257 # because the test instance of the server is accessed via ASGI.
258 #
259 # Instead we just monkeypatch the HTTPResourcePath 'read' method and
260 # hope that all ResourcePath HTTP reads during construction are going
261 # to the server under test.
262 def override_read(http_resource_path):
263 return self.client.get(http_resource_path.geturl()).content
265 server_url = f"https://test.example/api/butler/repo/{TEST_REPOSITORY_NAME}/"
267 with patch.object(HttpResourcePath, "read", override_read):
268 # RegistryDefaults.finish() needs to download the dimension
269 # universe from the server, which will fail because there is no
270 # server here. So mock it out.
271 with patch.object(RegistryDefaults, "finish"):
272 # Add access key to environment variables. RemoteButler
273 # instantiation will throw an error if access key is not
274 # available.
275 with mock_env({_EXPLICIT_BUTLER_ACCESS_TOKEN_ENVIRONMENT_KEY: "fake-access-token"}):
276 butler = Butler(
277 server_url,
278 collections=["collection1", "collection2"],
279 run="collection2",
280 )
281 self.enterContext(butler)
282 self.assertIsInstance(butler, RemoteButler)
283 self.assertEqual(butler._connection.server_url, server_url)
284 self.assertEqual(butler.collections.defaults, ("collection1", "collection2"))
285 self.assertEqual(butler.run, "collection2")
286 # A butler created this way uses the default cache config.
287 self.assertFalse(butler._use_disabled_datastore_cache)
289 butler_factory = LabeledButlerFactory({"server": server_url})
290 factory_created_butler = butler_factory.create_butler(label="server", access_token="token")
291 self.assertIsInstance(factory_created_butler, RemoteButler)
292 self.assertTrue(factory_created_butler._use_disabled_datastore_cache)
293 self.assertEqual(factory_created_butler._connection.server_url, server_url)
295 def test_get(self):
296 dataset_type = "test_metric_comp"
297 data_id = {"instrument": "DummyCamComp", "visit": 423}
298 collections = "ingest/run"
299 # Test get() of a DatasetRef.
300 ref = self.butler.find_dataset(dataset_type, data_id, collections=collections)
301 metric = self.butler.get(ref)
302 self.assertIsInstance(metric, MetricsExample)
303 self.assertEqual(metric.summary, MetricTestRepo.METRICS_EXAMPLE_SUMMARY)
305 # Test get() by DataId.
306 data_id_metric = self.butler.get(dataset_type, dataId=data_id, collections=collections)
307 self.assertEqual(metric, data_id_metric)
308 # Test get() by DataId dict augmented with kwargs.
309 kwarg_metric = self.butler.get(
310 dataset_type, dataId={"instrument": "DummyCamComp"}, collections=collections, visit=423
311 )
312 self.assertEqual(metric, kwarg_metric)
313 # Test get() by DataId DataCoordinate augmented with kwargs.
314 coordinate = DataCoordinate.make_empty(self.butler.dimensions)
315 kwarg_data_coordinate_metric = self.butler.get(
316 dataset_type, dataId=coordinate, collections=collections, instrument="DummyCamComp", visit=423
317 )
318 self.assertEqual(metric, kwarg_data_coordinate_metric)
319 # Test get() of a non-existent DataId.
320 invalid_data_id = {"instrument": "NotAValidlInstrument", "visit": 423}
321 with self.assertRaises(DatasetNotFoundError):
322 self.butler_without_error_propagation.get(
323 dataset_type, dataId=invalid_data_id, collections=collections
324 )
326 # Test get() by DataId with default collections.
327 butler_with_default_collection = self.butler.clone(collections="ingest/run")
328 default_collection_metric = butler_with_default_collection.get(dataset_type, dataId=data_id)
329 self.assertEqual(metric, default_collection_metric)
331 # Test get() by DataId with no collections specified.
332 with self.assertRaises(NoDefaultCollectionError):
333 self.butler_without_error_propagation.get(dataset_type, dataId=data_id)
335 # Test looking up a non-existent ref
336 invalid_ref = ref.replace(id=uuid.uuid4())
337 with self.assertRaises(DatasetNotFoundError):
338 self.butler_without_error_propagation.get(invalid_ref)
340 with self.assertRaises(RuntimeError):
341 self.butler_without_error_propagation.get(self.dataset_with_corrupted_data)
343 # Test storage class override
344 new_sc = self.storageClassFactory.getStorageClass("MetricsConversion")
346 def check_sc_override(converted):
347 self.assertNotEqual(type(metric), type(converted))
348 self.assertIsInstance(converted, new_sc.pytype)
349 self.assertEqual(metric, converted)
351 check_sc_override(self.butler.get(ref, storageClass=new_sc))
353 # Test storage class override via DatasetRef.
354 check_sc_override(self.butler.get(ref.overrideStorageClass("MetricsConversion")))
355 # Test storage class override via DatasetType.
356 check_sc_override(
357 self.butler.get(
358 ref.datasetType.overrideStorageClass(new_sc), dataId=data_id, collections=collections
359 )
360 )
362 # Test component override via DatasetRef.
363 component_ref = ref.makeComponentRef("summary")
364 component_data = self.butler.get(component_ref)
365 self.assertEqual(component_data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY)
367 # Test overriding both storage class and component via DatasetRef.
368 converted_component_data = self.butler.get(component_ref, storageClass="DictConvertibleModel")
369 self.assertIsInstance(converted_component_data, DictConvertibleModel)
370 self.assertEqual(converted_component_data.content, MetricTestRepo.METRICS_EXAMPLE_SUMMARY)
372 # Test component override via DatasetType.
373 dataset_type_component_data = self.butler.get(
374 component_ref.datasetType, component_ref.dataId, collections=collections
375 )
376 self.assertEqual(dataset_type_component_data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY)
378 def test_get_formatter_receives_written_ref(self):
379 """The Formatter must always be given the written `DatasetRef`, so its
380 storage class matches the one the file was written with, regardless of
381 any read-time storage class override.
383 Formatters should read the write storage class from the
384 `FileDescriptor`, but some read it from the ref, so `RemoteButler` must
385 hand the Formatter the same written ref that `DirectButler` does.
386 """
387 remote_get = lsst.daf.butler.remote_butler._get
389 dataset_type = "test_metric_comp"
390 data_id = {"instrument": "DummyCamComp", "visit": 423}
391 collections = "ingest/run"
392 ref = self.butler.find_dataset(dataset_type, data_id, collections=collections)
393 write_storage_class = ref.datasetType.storageClass
394 override = self.storageClassFactory.getStorageClass("MetricsConversion")
395 self.assertNotEqual(write_storage_class, override)
397 captured: dict[str, object] = {}
398 original = remote_get.generate_datastore_get_information
400 def capturing(fileLocations, *, ref, parameters, readStorageClass=None):
401 captured["ref_storage_class"] = ref.datasetType.storageClass
402 captured["read_storage_class"] = readStorageClass
403 return original(fileLocations, ref=ref, parameters=parameters, readStorageClass=readStorageClass)
405 with patch.object(remote_get, "generate_datastore_get_information", side_effect=capturing):
406 self.butler.get(ref, storageClass=override)
408 # The ref given to the Formatter must carry the write storage class,
409 # and the override must instead travel via readStorageClass.
410 self.assertEqual(captured["ref_storage_class"], write_storage_class)
411 self.assertEqual(captured["read_storage_class"], override)
413 def test_component_access_without_server_storage_class(self):
414 """Test that component dataset access via dataset type name does not
415 require the server to know the parent's storage class.
417 Storage classes for many dataset types are defined by science
418 pipelines packages that are only installed on the client, so all
419 component handling must occur on the client.
420 """
421 dataset_type = "test_metric_comp"
422 component_type = "test_metric_comp.summary"
423 data_id = {"instrument": "DummyCamComp", "visit": 423}
424 collections = "ingest/run"
425 parent_ref = self.butler.find_dataset(dataset_type, data_id, collections=collections)
426 component_ref = parent_ref.makeComponentRef("summary")
428 # Track the dataset type names sent to the server. Component names
429 # must never be sent, because converting a component dataset type
430 # name to a DatasetType requires the server to instantiate the
431 # parent's storage class.
432 sent_dataset_types: list[str] = []
433 original_post = self.butler._connection.post
435 def tracking_post(path, model):
436 dataset_type_name = getattr(model, "dataset_type", None)
437 if dataset_type_name is not None:
438 sent_dataset_types.append(dataset_type_name)
439 return original_post(path, model)
441 with patch.object(self.butler._connection, "post", side_effect=tracking_post):
442 # get() with a component dataset type name.
443 data = self.butler.get(component_type, dataId=data_id, collections=collections)
444 self.assertEqual(data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY)
446 # find_dataset() with a component dataset type name.
447 found = self.butler.find_dataset(component_type, data_id, collections=collections)
448 self.assertEqual(found, component_ref)
449 self.assertEqual(found.datasetType, component_ref.datasetType)
451 # getDeferred() with a component dataset type name.
452 deferred_data = self.butler.getDeferred(component_type, data_id, collections=collections).get()
453 self.assertEqual(deferred_data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY)
455 # getDeferred() with a component DatasetRef.
456 deferred_ref_data = self.butler.getDeferred(component_ref).get()
457 self.assertEqual(deferred_ref_data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY)
459 # An empty component name raises rather than silently returning
460 # the composite.
461 with self.assertRaises(KeyError):
462 self.butler.get(f"{dataset_type}.", dataId=data_id, collections=collections)
463 with self.assertRaises(KeyError):
464 self.butler.getDeferred(f"{dataset_type}.", data_id, collections=collections)
466 self.assertGreater(len(sent_dataset_types), 0)
467 for name in sent_dataset_types:
468 self.assertNotIn(".", name, f"Component dataset type name {name!r} was sent to the server")
470 def test_getURIs_no_components(self):
471 # This dataset does not have components, and should return one URI.
472 def check_uri(uri: ResourcePath):
473 self.assertIsNotNone(uris.primaryURI)
474 self.assertEqual(uris.primaryURI.scheme, "https")
475 self.assertEqual(uris.primaryURI.read(), b"123")
477 uris = self.butler.getURIs(self.simple_dataset_ref)
478 self.assertEqual(len(uris.componentURIs), 0)
479 check_uri(uris.primaryURI)
481 check_uri(self.butler.getURI(self.simple_dataset_ref))
483 def test_getURIs_multiple_components(self):
484 # This dataset has multiple components, so we should get back multiple
485 # URIs.
486 dataset_type = "test_metric_comp"
487 data_id = {"instrument": "DummyCamComp", "visit": 423}
488 collections = "ingest/run"
490 def check_uris(uris: DatasetRefURIs):
491 self.assertIsNone(uris.primaryURI)
492 self.assertEqual(len(uris.componentURIs), 3)
493 path = uris.componentURIs["summary"]
494 self.assertEqual(path.scheme, "https")
495 data = path.read()
496 self.assertEqual(data, b"AM1: 5.2\nAM2: 30.6\n")
498 uris = self.butler.getURIs(dataset_type, dataId=data_id, collections=collections)
499 check_uris(uris)
501 # Calling getURI on a multi-file dataset raises an exception
502 with self.assertRaises(RuntimeError):
503 self.butler.getURI(dataset_type, dataId=data_id, collections=collections)
505 # getURIs does NOT respect component overrides on the DatasetRef,
506 # instead returning the parent's URIs. Unclear if this is "correct"
507 # from a conceptual point of view, but this matches DirectButler
508 # behavior.
509 ref = self.butler.find_dataset(dataset_type, data_id=data_id, collections=collections)
510 componentRef = ref.makeComponentRef("summary")
511 componentUris = self.butler.getURIs(componentRef)
512 check_uris(componentUris)
514 def test_file_download_redirect(self):
515 def get_download_redirect(id: DatasetId, component: str | None = None) -> httpx.Response:
516 uri = generate_file_download_uri("http://unittest.test/", TEST_REPOSITORY_NAME, id, component)
517 return self.client.get(
518 uri,
519 follow_redirects=False,
520 headers=RubinAuthenticationProvider("mock-token").get_server_headers(),
521 )
523 # Test behavior of a single-file dataset.
524 response = get_download_redirect(self.simple_dataset_ref.id)
525 self.assertEqual(response.status_code, 307)
526 self.assertTrue(response.has_redirect_location)
527 assert response.next_request is not None
528 self.assertEqual(response.next_request.url.scheme, "https")
529 self.assertIn("test_int_DummyCamComp_R_d-r_423_ingest_run.json", response.next_request.url.path)
531 response = get_download_redirect(self.simple_dataset_ref.id, "somecomponent")
532 self.assertEqual(response.status_code, 404)
534 # This dataset is a "disassembled composite" with multiple files.
535 dataset_type = "test_metric_comp"
536 data_id = {"instrument": "DummyCamComp", "visit": 423}
537 collections = "ingest/run"
538 ref = self.butler.find_dataset(dataset_type, data_id, collections=collections)
540 # Getting single component of a multi-file "disassembled composite".
541 response = get_download_redirect(ref.id, "summary")
542 self.assertEqual(response.status_code, 307)
543 self.assertTrue(response.has_redirect_location)
544 assert response.next_request is not None
545 self.assertEqual(response.next_request.url.scheme, "https")
546 self.assertIn("test_metric_comp.summary", response.next_request.url.path)
548 # Unknown component.
549 response = get_download_redirect(ref.id, "badcomponent")
550 self.assertEqual(response.status_code, 404)
552 # Not specifying the component for a multi-file "disassembled
553 # composite".
554 response = get_download_redirect(ref.id, None)
555 self.assertEqual(response.status_code, 422)
557 # Unknown dataset.
558 response = get_download_redirect(uuid.UUID("59467c1b-fa13-4f7a-8ff8-cd83e092e563"))
559 self.assertEqual(response.status_code, 404)
561 def test_auth_check(self):
562 # This is checking that the unit-test middleware for validating the
563 # authentication headers is working. It doesn't test actual server
564 # functionality -- in a real deployment, the authentication headers are
565 # handled by GafaelfawrIngress, not our app.
566 with self.assertRaises(UnhandledServerError):
567 self.client.get("/v1/dataset_type/int")
569 def test_exception_logging(self):
570 app = create_app()
572 def raise_error():
573 raise RuntimeError("An unhandled error")
575 app.dependency_overrides[butler_factory_dependency] = raise_error
576 client = TestClient(app, raise_server_exceptions=False)
578 with patch.object(safir.dependencies.logger, "logger_dependency") as mock_logger_dep:
579 mock_logger = NonCallableMock(["aerror"])
581 async def noop():
582 pass
584 mock_logger.aerror.return_value = noop()
586 async def get_logger():
587 return mock_logger
589 mock_logger_dep.return_value = get_logger()
590 client.get(
591 "/api/butler/repo/something/v1/dataset_type/int",
592 headers={"X-Auth-Request-User": "user-name", "X-Butler-Client-Request-Id": "request-id"},
593 )
594 mock_logger_dep.assert_called_once()
596 mock_logger.aerror.assert_called_once()
597 args, kwargs = mock_logger.aerror.call_args
598 self.assertIsInstance(kwargs["exc_info"], RuntimeError)
599 self.assertEqual(kwargs["clientRequestId"], "request-id")
600 self.assertEqual(kwargs["user"], "user-name")
602 def test_query_keepalive(self):
603 """Test that long-running queries stream keep-alive messages to stop
604 the HTTP connection from closing before they are able to return
605 results.
606 """
607 # Normally it takes 15 seconds for a timeout -- mock it to trigger
608 # immediately instead.
609 with patch.object(
610 lsst.daf.butler.remote_butler.server.handlers._query_streaming, "_timeout"
611 ) as mock_timeout:
612 # Hook into QueryDriver to track the number of keep-alives we have
613 # seen.
614 with patch.object(
615 lsst.daf.butler.remote_butler._query_results, "_received_keep_alive"
616 ) as mock_keep_alive:
617 mock_timeout.side_effect = _timeout_twice()
618 with self.butler.query() as query:
619 datasets = list(query.datasets("bias", "imported_g"))
620 self.assertEqual(len(datasets), 3)
621 self.assertGreaterEqual(mock_timeout.call_count, 3)
622 self.assertGreaterEqual(mock_keep_alive.call_count, 2)
624 def test_query_retries(self):
625 """Test that the server will send HTTP status 503 to put backpressure
626 on clients if it is overloaded, and that the client will retry if this
627 happens.
628 """
629 query_event = threading.Event()
630 retry_event = asyncio.Event()
632 async def block_first_request() -> None:
633 # Signal the unit tests that we have reached the critical section
634 # in the server, where the first client has reserved the query
635 # slot.
636 query_event.set()
637 # Block inside the query, until the 2nd client has been forced to
638 # retry.
639 await retry_event.wait()
641 async def block_second_request() -> None:
642 # Release the first client, so it can finish its query and prevent
643 # this client from being blocked on the next go-round.
644 retry_event.set()
646 def do_query(butler: Butler) -> list[DatasetRef]:
647 return butler.query_datasets("bias", "imported_g")
649 with (
650 patch.object(
651 lsst.daf.butler.remote_butler.server.handlers._query_limits,
652 "_MAXIMUM_CONCURRENT_STREAMING_QUERIES",
653 new=1,
654 ),
655 patch.object(
656 lsst.daf.butler.remote_butler.server.handlers._query_limits, "_QUERY_RETRY_SECONDS", new=1
657 ),
658 patch.object(
659 lsst.daf.butler.remote_butler.server.handlers._query_limits,
660 "_block_query_for_unit_test",
661 new=AsyncMock(wraps=block_first_request),
662 ) as mock_first_client,
663 patch.object(
664 lsst.daf.butler.remote_butler.server.handlers._query_limits,
665 "_block_retry_for_unit_test",
666 new=AsyncMock(wraps=block_second_request),
667 ) as mock_second_client,
668 ThreadPoolExecutor(max_workers=1) as exec1,
669 ThreadPoolExecutor(max_workers=1) as exec2,
670 ):
671 first_butler = self.butler
672 second_butler = self.butler.clone()
674 # Run the first client up until the server starts executing its
675 # query.
676 future1 = exec1.submit(do_query, first_butler)
677 event_reached = query_event.wait(60)
678 if not event_reached:
679 raise TimeoutError("Server did not execute query logic as expected.")
681 # Start the second client, which will trigger the retry logic and
682 # release the first client to finish its query.
683 future2 = exec2.submit(do_query, second_butler)
685 result1 = future1.result(60)
686 result2 = future2.result(60)
687 self.assertEqual(len(result1), 3)
688 self.assertEqual(len(result2), 3)
689 # The original thread should have gone through this section, and
690 # then the 2nd thread after it retries.
691 self.assertEqual(mock_first_client.await_count, 2)
692 # We should have triggered the retry logic at least once, but it
693 # might occur multiple times depending how long the first client
694 # takes to finish.
695 self.assertGreaterEqual(mock_second_client.await_count, 1)
697 # TODO DM-46204: This can be removed once the RSP recommended image has
698 # been upgraded to a version that contains DM-46129.
699 def test_deprecated_collection_endpoints(self):
700 # These REST endpoints are no longer used by Butler client so they need
701 # to be checked separately until they can be removed.
702 json = self.butler._connection.get(
703 "collection_info",
704 params={"name": "imported_g", "include_doc": True, "include_parents": True},
705 ).json()
706 self.assertEqual(json["name"], "imported_g")
707 self.assertEqual(json["type"], 1)
709 json = self.butler._connection.post(
710 "query_collections",
711 QueryCollectionsRequestModel(
712 search=["imported_*"], collection_types=[1], flatten_chains=False, include_chains=False
713 ),
714 ).json()
715 self.assertCountEqual(json["collections"], ["imported_g", "imported_r"])
717 def test_oversized_data_coordinate_upload(self):
718 with self.butler.query() as query:
719 ref = self.simple_dataset_ref
720 data_id = ref.dataId
721 data_coordinates = [DataCoordinate.standardize(data_id, visit=x) for x in range(100_001)]
722 with self.assertRaisesRegex(InvalidQueryError, "data coordinate rows"):
723 list(query.join_data_coordinates(data_coordinates).datasets(ref.datasetType, ref.run))
726@unittest.skipIf(not butler_server_is_available, butler_server_import_error)
727class ButlerClientServerAuthorizationTestCase(unittest.TestCase):
728 """Test authentication/authorization functionality."""
730 def test_group_authorization(self):
731 """Test that group membership repository authorization is checked when
732 repository is accessed.
733 """
734 with create_test_server(TESTDIR) as server_instance:
735 mock = MockGafaelfawrGroupAuthorizer()
736 server_instance.app.dependency_overrides[authorizer_dependency] = lambda: mock
737 server_instance.direct_butler.registry.registerDatasetType(
738 DatasetType("bias", [], "int", universe=server_instance.direct_butler.dimensions)
739 )
740 server_instance.direct_butler.collections.register("collection")
741 butler = server_instance.remote_butler
742 mock.set_response(False)
743 with self.assertRaises(ButlerServerError) as e:
744 butler.get_dataset_type("bias")
745 self.assertEqual(e.exception.status_code, 403)
746 with self.assertRaises(ButlerServerError) as e:
747 butler.query_datasets("bias", collections="*", find_first=False)
748 self.assertEqual(e.exception.status_code, 403)
750 mock.set_response(True)
751 self.assertEqual(butler.get_dataset_type("bias").name, "bias")
752 self.assertEqual(butler.query_datasets("bias", collections="collection", explain=False), [])
754 def test_cadc_auth(self) -> None:
755 """Test server running in CADC auth mode."""
756 with mock_config() as config:
757 config.authentication = "cadc"
758 config.gafaelfawr_url = "DISABLED"
759 with create_test_server(TESTDIR, server_config=config) as instance:
760 self.assertIsInstance(instance.remote_butler._connection.auth, CadcAuthenticationProvider)
762 # Set up a dataset backed by an HTTP URL.
763 # CADC uses a plain HTTP service, not S3, for hosting Butler
764 # artifacts.
765 dataset_type = DatasetType("test", [], "int", universe=instance.direct_butler.dimensions)
766 ref = DatasetRef(
767 datasetType=dataset_type,
768 dataId=DataCoordinate.makeEmpty(instance.direct_butler.dimensions),
769 run="ingest/run",
770 )
771 path = ResourcePath("https://fake-server.example/some-directory/file.json")
772 dataset = FileDataset(path, ref)
773 # ingest() insists on doing file existence checks, and we don't
774 # have an HTTP server to point it at.
775 with unittest.mock.patch(
776 "lsst.daf.butler.datastores.fileDatastore.FileDatastore._standardizeIngestPath"
777 ) as mock:
778 mock.return_value = path
779 instance.direct_butler.ingest(dataset, transfer="direct", record_validation_info=False)
781 # At the CADC, paths used for file download should NOT be a
782 # signed URL, and should have authentication headers attached.
783 def check_path(path_to_check: ResourcePath):
784 self.assertEqual(str(path_to_check), str(path))
785 assert isinstance(path_to_check, HttpResourcePath)
786 self.assertIsNotNone(path_to_check._extra_headers)
787 self.assertIsNotNone(path_to_check._extra_headers.get("Authorization"))
789 check_path(instance.remote_butler.getURI(ref))
790 transfer_map = instance.remote_butler._file_transfer_source.get_file_info_for_transfer(
791 [ref.id]
792 )
793 check_path(transfer_map[ref.id][0].location.pathInStore)
796def _create_corrupted_dataset(repo: MetricTestRepo) -> DatasetRef:
797 run = "corrupted-run"
798 ref = repo.addDataset({"instrument": "DummyCamComp", "visit": 423}, run=run)
799 uris = repo.butler.getURIs(ref)
800 oneOfTheComponents = list(uris.componentURIs.values())[0]
801 oneOfTheComponents.write("corrupted data")
802 return ref
805def _create_simple_dataset(butler: Butler) -> DatasetRef:
806 dataset_type = addDatasetType(butler, "test_int", {"instrument", "visit"}, "int")
807 ref = butler.put(123, dataset_type, dataId={"instrument": "DummyCamComp", "visit": 423}, run="ingest/run")
808 return ref
811def _timeout_twice():
812 """Return a mock side-effect function that raises a timeout error the first
813 two times it is called.
814 """
815 count = 0
817 def timeout(*args):
818 nonlocal count
819 count += 1
820 if count <= 2:
821 raise TimeoutError()
822 return DEFAULT
824 return timeout
827@unittest.skipIf(not butler_server_is_available, butler_server_import_error)
828class QueryLimitsTestCase(unittest.IsolatedAsyncioTestCase):
829 """Test details of the code that limits the maximum number of concurrent
830 queries in the server.
831 """
833 async def test_query_limits(self):
834 limits = lsst.daf.butler.remote_butler.server.handlers._query_limits.QueryLimits()
836 await limits.enforce_query_limits("user1") # under limit, doesn't raise
837 async with limits.track_query("user1"):
838 await limits.enforce_query_limits("user1") # under limit, doesn't raise
839 async with limits.track_query("user1"):
840 with self.assertRaises(fastapi.HTTPException) as exc:
841 await limits.enforce_query_limits("user1")
842 self.assertEqual(exc.exception.status_code, 429)
845if __name__ == "__main__":
846 unittest.main()