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