Coverage for tests/test_server.py: 14%
519 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-27 09:25 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-27 09:25 +0000
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/>.
28from __future__ import annotations
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
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 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
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
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
97TESTDIR = os.path.abspath(os.path.dirname(__file__))
100@unittest.skipIf(not butler_server_is_available, butler_server_import_error)
101class ButlerClientServerTestCase(unittest.TestCase):
102 """Test for Butler client/server."""
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
112 cls.storageClassFactory = StorageClassFactory()
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)
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")
131 def test_health_check(self):
132 try:
133 import importlib.metadata
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")
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")
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")
154 def test_dimension_universe(self):
155 universe = self.butler.dimensions
156 self.assertEqual(universe.namespace, "daf_butler")
158 def test_get_dataset_type(self):
159 bias_type = self.butler.get_dataset_type("bias")
160 self.assertEqual(bias_type.name, "bias")
162 with self.assertRaises(MissingDatasetTypeError):
163 self.butler_without_error_propagation.get_dataset_type("not_bias")
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).
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
177 def tracking_get(path, **kwargs):
178 requested_paths.append(path)
179 return original_get(path, **kwargs)
181 with patch.object(self.butler._connection, "get", side_effect=tracking_get):
182 component_type = self.butler.get_dataset_type("bias.image")
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}")
190 # A second call should be served from the client-side cache.
191 self.assertEqual(self.butler.get_dataset_type("bias.image"), component_type)
193 # An unknown component raises client-side.
194 with self.assertRaises(KeyError):
195 self.butler.get_dataset_type("bias.not_a_component")
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")
201 def test_find_dataset(self):
202 storage_class = self.storageClassFactory.getStorageClass("Exposure")
204 ref = self.butler.find_dataset("bias", collections="imported_g", detector=1, instrument="Cam1")
205 self.assertIsInstance(ref, DatasetRef)
206 self.assertEqual(ref.id, uuid.UUID("e15ab039-bc8b-4135-87c5-90902a7c0b22"))
207 self.assertFalse(ref.dataId.hasRecords())
209 # Try again with variation of parameters.
210 ref_new = self.butler.find_dataset(
211 "bias",
212 {"detector": 1},
213 collections="imported_g",
214 instrument="Cam1",
215 dimension_records=True,
216 )
217 self.assertEqual(ref_new, ref)
218 self.assertTrue(ref_new.dataId.hasRecords())
220 ref_new = self.butler.find_dataset(
221 ref.datasetType,
222 DataCoordinate.standardize(detector=1, instrument="Cam1", universe=self.butler.dimensions),
223 collections="imported_g",
224 storage_class=storage_class,
225 )
226 self.assertEqual(ref_new, ref)
228 ref2 = self.butler.get_dataset(ref.id)
229 self.assertEqual(ref2, ref)
231 # Use detector name to find it.
232 ref3 = self.butler.find_dataset(
233 ref.datasetType,
234 collections="imported_g",
235 instrument="Cam1",
236 full_name="Aa",
237 )
238 self.assertEqual(ref2, ref3)
240 # Try expanded refs.
241 self.assertFalse(ref.dataId.hasRecords())
242 expanded = self.butler.get_dataset(ref.id, dimension_records=True)
243 self.assertTrue(expanded.dataId.hasRecords())
245 # The test datasets are all Exposure so storage class conversion
246 # can not be tested until we fix that. For now at least test the
247 # code paths.
248 bias = self.butler.get_dataset(ref.id, storage_class=storage_class)
249 self.assertEqual(bias.datasetType.storageClass, storage_class)
251 # Unknown dataset should not fail.
252 self.assertIsNone(self.butler.get_dataset(uuid.uuid4()))
253 self.assertIsNone(self.butler.get_dataset(uuid.uuid4(), storage_class="NumpyArray"))
255 def test_instantiate_via_butler_http_search(self):
256 """Ensure that the primary Butler constructor's automatic search logic
257 correctly locates and reads the configuration file and ends up with a
258 RemoteButler pointing to the correct URL
259 """
261 # This is kind of a fragile test. Butler's search logic does a lot of
262 # manipulations involving creating new ResourcePaths, and ResourcePath
263 # doesn't use httpx so we can't easily inject the TestClient in there.
264 # We don't have an actual valid HTTP URL to give to the constructor
265 # because the test instance of the server is accessed via ASGI.
266 #
267 # Instead we just monkeypatch the HTTPResourcePath 'read' method and
268 # hope that all ResourcePath HTTP reads during construction are going
269 # to the server under test.
270 def override_read(http_resource_path):
271 return self.client.get(http_resource_path.geturl()).content
273 server_url = f"https://test.example/api/butler/repo/{TEST_REPOSITORY_NAME}/"
275 with patch.object(HttpResourcePath, "read", override_read):
276 # RegistryDefaults.finish() needs to download the dimension
277 # universe from the server, which will fail because there is no
278 # server here. So mock it out.
279 with patch.object(RegistryDefaults, "finish"):
280 # Add access key to environment variables. RemoteButler
281 # instantiation will throw an error if access key is not
282 # available.
283 with mock_env({_EXPLICIT_BUTLER_ACCESS_TOKEN_ENVIRONMENT_KEY: "fake-access-token"}):
284 butler = Butler(
285 server_url,
286 collections=["collection1", "collection2"],
287 run="collection2",
288 )
289 self.enterContext(butler)
290 self.assertIsInstance(butler, RemoteButler)
291 self.assertEqual(butler._connection.server_url, server_url)
292 self.assertEqual(butler.collections.defaults, ("collection1", "collection2"))
293 self.assertEqual(butler.run, "collection2")
294 # A butler created this way uses the default cache config.
295 self.assertFalse(butler._use_disabled_datastore_cache)
297 butler_factory = LabeledButlerFactory({"server": server_url})
298 factory_created_butler = butler_factory.create_butler(label="server", access_token="token")
299 self.assertIsInstance(factory_created_butler, RemoteButler)
300 self.assertTrue(factory_created_butler._use_disabled_datastore_cache)
301 self.assertEqual(factory_created_butler._connection.server_url, server_url)
303 def test_get(self):
304 dataset_type = "test_metric_comp"
305 data_id = {"instrument": "DummyCamComp", "visit": 423}
306 collections = "ingest/run"
307 # Test get() of a DatasetRef.
308 ref = self.butler.find_dataset(dataset_type, data_id, collections=collections)
309 metric = self.butler.get(ref)
310 self.assertIsInstance(metric, MetricsExample)
311 self.assertEqual(metric.summary, MetricTestRepo.METRICS_EXAMPLE_SUMMARY)
313 # Test get() by DataId.
314 data_id_metric = self.butler.get(dataset_type, dataId=data_id, collections=collections)
315 self.assertEqual(metric, data_id_metric)
316 # Test get() by DataId dict augmented with kwargs.
317 kwarg_metric = self.butler.get(
318 dataset_type, dataId={"instrument": "DummyCamComp"}, collections=collections, visit=423
319 )
320 self.assertEqual(metric, kwarg_metric)
321 # Test get() by DataId DataCoordinate augmented with kwargs.
322 coordinate = DataCoordinate.make_empty(self.butler.dimensions)
323 kwarg_data_coordinate_metric = self.butler.get(
324 dataset_type, dataId=coordinate, collections=collections, instrument="DummyCamComp", visit=423
325 )
326 self.assertEqual(metric, kwarg_data_coordinate_metric)
327 # Test get() of a non-existent DataId.
328 invalid_data_id = {"instrument": "NotAValidlInstrument", "visit": 423}
329 with self.assertRaises(DatasetNotFoundError):
330 self.butler_without_error_propagation.get(
331 dataset_type, dataId=invalid_data_id, collections=collections
332 )
334 # Test get() by DataId with default collections.
335 butler_with_default_collection = self.butler.clone(collections="ingest/run")
336 default_collection_metric = butler_with_default_collection.get(dataset_type, dataId=data_id)
337 self.assertEqual(metric, default_collection_metric)
339 # Test get() by DataId with no collections specified.
340 with self.assertRaises(NoDefaultCollectionError):
341 self.butler_without_error_propagation.get(dataset_type, dataId=data_id)
343 # Test looking up a non-existent ref
344 invalid_ref = ref.replace(id=uuid.uuid4())
345 with self.assertRaises(DatasetNotFoundError):
346 self.butler_without_error_propagation.get(invalid_ref)
348 with self.assertRaises(RuntimeError):
349 self.butler_without_error_propagation.get(self.dataset_with_corrupted_data)
351 # Test storage class override
352 new_sc = self.storageClassFactory.getStorageClass("MetricsConversion")
354 def check_sc_override(converted):
355 self.assertNotEqual(type(metric), type(converted))
356 self.assertIsInstance(converted, new_sc.pytype)
357 self.assertEqual(metric, converted)
359 check_sc_override(self.butler.get(ref, storageClass=new_sc))
361 # Test storage class override via DatasetRef.
362 check_sc_override(self.butler.get(ref.overrideStorageClass("MetricsConversion")))
363 # Test storage class override via DatasetType.
364 check_sc_override(
365 self.butler.get(
366 ref.datasetType.overrideStorageClass(new_sc), dataId=data_id, collections=collections
367 )
368 )
370 # Test component override via DatasetRef.
371 component_ref = ref.makeComponentRef("summary")
372 component_data = self.butler.get(component_ref)
373 self.assertEqual(component_data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY)
375 # Test overriding both storage class and component via DatasetRef.
376 converted_component_data = self.butler.get(component_ref, storageClass="DictConvertibleModel")
377 self.assertIsInstance(converted_component_data, DictConvertibleModel)
378 self.assertEqual(converted_component_data.content, MetricTestRepo.METRICS_EXAMPLE_SUMMARY)
380 # Test component override via DatasetType.
381 dataset_type_component_data = self.butler.get(
382 component_ref.datasetType, component_ref.dataId, collections=collections
383 )
384 self.assertEqual(dataset_type_component_data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY)
386 def test_get_formatter_receives_registry_ref(self):
387 """The Formatter must always be given the `DatasetRef` as defined in
388 the repository, so its storage class is unaffected by any read-time
389 storage class override.
391 Formatters should read the write storage class from the
392 `FileDescriptor`, but some read it from the ref, so `RemoteButler` must
393 hand the Formatter the same ref that `DirectButler` does.
394 """
395 remote_get = lsst.daf.butler.remote_butler._get
397 dataset_type = "test_metric_comp"
398 data_id = {"instrument": "DummyCamComp", "visit": 423}
399 collections = "ingest/run"
400 ref = self.butler.find_dataset(dataset_type, data_id, collections=collections)
401 write_storage_class = ref.datasetType.storageClass
402 override = self.storageClassFactory.getStorageClass("MetricsConversion")
403 self.assertNotEqual(write_storage_class, override)
405 captured: dict[str, object] = {}
406 original = remote_get.generate_datastore_get_information
408 def capturing(fileLocations, *, registry_ref, read_ref, parameters):
409 captured["registry_storage_class"] = registry_ref.datasetType.storageClass
410 captured["read_storage_class"] = read_ref.datasetType.storageClass
411 return original(
412 fileLocations, registry_ref=registry_ref, read_ref=read_ref, parameters=parameters
413 )
415 with patch.object(remote_get, "generate_datastore_get_information", side_effect=capturing):
416 self.butler.get(ref, storageClass=override)
418 # The ref given to the Formatter must carry the storage class from the
419 # repository, and the override must instead travel via the read ref.
420 self.assertEqual(captured["registry_storage_class"], write_storage_class)
421 self.assertEqual(captured["read_storage_class"], override)
423 def test_component_access_without_server_storage_class(self):
424 """Test that component dataset access via dataset type name does not
425 require the server to know the parent's storage class.
427 Storage classes for many dataset types are defined by science
428 pipelines packages that are only installed on the client, so all
429 component handling must occur on the client.
430 """
431 dataset_type = "test_metric_comp"
432 component_type = "test_metric_comp.summary"
433 data_id = {"instrument": "DummyCamComp", "visit": 423}
434 collections = "ingest/run"
435 parent_ref = self.butler.find_dataset(dataset_type, data_id, collections=collections)
436 component_ref = parent_ref.makeComponentRef("summary")
438 # Track the dataset type names sent to the server. Component names
439 # must never be sent, because converting a component dataset type
440 # name to a DatasetType requires the server to instantiate the
441 # parent's storage class.
442 sent_dataset_types: list[str] = []
443 original_post = self.butler._connection.post
445 def tracking_post(path, model):
446 dataset_type_name = getattr(model, "dataset_type", None)
447 if dataset_type_name is not None:
448 sent_dataset_types.append(dataset_type_name)
449 return original_post(path, model)
451 with patch.object(self.butler._connection, "post", side_effect=tracking_post):
452 # get() with a component dataset type name.
453 data = self.butler.get(component_type, dataId=data_id, collections=collections)
454 self.assertEqual(data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY)
456 # find_dataset() with a component dataset type name.
457 found = self.butler.find_dataset(component_type, data_id, collections=collections)
458 self.assertEqual(found, component_ref)
459 self.assertEqual(found.datasetType, component_ref.datasetType)
461 # getDeferred() with a component dataset type name.
462 deferred_data = self.butler.getDeferred(component_type, data_id, collections=collections).get()
463 self.assertEqual(deferred_data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY)
465 # getDeferred() with a component DatasetRef.
466 deferred_ref_data = self.butler.getDeferred(component_ref).get()
467 self.assertEqual(deferred_ref_data, MetricTestRepo.METRICS_EXAMPLE_SUMMARY)
469 # An empty component name raises rather than silently returning
470 # the composite.
471 with self.assertRaises(KeyError):
472 self.butler.get(f"{dataset_type}.", dataId=data_id, collections=collections)
473 with self.assertRaises(KeyError):
474 self.butler.getDeferred(f"{dataset_type}.", data_id, collections=collections)
476 self.assertGreater(len(sent_dataset_types), 0)
477 for name in sent_dataset_types:
478 self.assertNotIn(".", name, f"Component dataset type name {name!r} was sent to the server")
480 def test_getURIs_no_components(self):
481 # This dataset does not have components, and should return one URI.
482 def check_uri(uri: ResourcePath):
483 self.assertIsNotNone(uris.primaryURI)
484 self.assertEqual(uris.primaryURI.scheme, "https")
485 self.assertEqual(uris.primaryURI.read(), b"123")
487 uris = self.butler.getURIs(self.simple_dataset_ref)
488 self.assertEqual(len(uris.componentURIs), 0)
489 check_uri(uris.primaryURI)
491 check_uri(self.butler.getURI(self.simple_dataset_ref))
493 def test_getURIs_multiple_components(self):
494 # This dataset has multiple components, so we should get back multiple
495 # URIs.
496 dataset_type = "test_metric_comp"
497 data_id = {"instrument": "DummyCamComp", "visit": 423}
498 collections = "ingest/run"
500 def check_uris(uris: DatasetRefURIs):
501 self.assertIsNone(uris.primaryURI)
502 self.assertEqual(len(uris.componentURIs), 3)
503 path = uris.componentURIs["summary"]
504 self.assertEqual(path.scheme, "https")
505 data = path.read()
506 self.assertEqual(data, b"AM1: 5.2\nAM2: 30.6\n")
508 uris = self.butler.getURIs(dataset_type, dataId=data_id, collections=collections)
509 check_uris(uris)
511 # Calling getURI on a multi-file dataset raises an exception
512 with self.assertRaises(RuntimeError):
513 self.butler.getURI(dataset_type, dataId=data_id, collections=collections)
515 # getURIs does NOT respect component overrides on the DatasetRef,
516 # instead returning the parent's URIs. Unclear if this is "correct"
517 # from a conceptual point of view, but this matches DirectButler
518 # behavior.
519 ref = self.butler.find_dataset(dataset_type, data_id=data_id, collections=collections)
520 componentRef = ref.makeComponentRef("summary")
521 componentUris = self.butler.getURIs(componentRef)
522 check_uris(componentUris)
524 def test_file_download_redirect(self):
525 def get_download_redirect(id: DatasetId, component: str | None = None) -> httpx.Response:
526 uri = generate_file_download_uri("http://unittest.test/", TEST_REPOSITORY_NAME, id, component)
527 return self.client.get(
528 uri,
529 follow_redirects=False,
530 headers=RubinAuthenticationProvider("mock-token").get_server_headers(),
531 )
533 # Test behavior of a single-file dataset.
534 response = get_download_redirect(self.simple_dataset_ref.id)
535 self.assertEqual(response.status_code, 307)
536 self.assertTrue(response.has_redirect_location)
537 assert response.next_request is not None
538 self.assertEqual(response.next_request.url.scheme, "https")
539 self.assertIn("test_int_DummyCamComp_R_d-r_423_ingest_run.json", response.next_request.url.path)
541 response = get_download_redirect(self.simple_dataset_ref.id, "somecomponent")
542 self.assertEqual(response.status_code, 404)
544 # This dataset is a "disassembled composite" with multiple files.
545 dataset_type = "test_metric_comp"
546 data_id = {"instrument": "DummyCamComp", "visit": 423}
547 collections = "ingest/run"
548 ref = self.butler.find_dataset(dataset_type, data_id, collections=collections)
550 # Getting single component of a multi-file "disassembled composite".
551 response = get_download_redirect(ref.id, "summary")
552 self.assertEqual(response.status_code, 307)
553 self.assertTrue(response.has_redirect_location)
554 assert response.next_request is not None
555 self.assertEqual(response.next_request.url.scheme, "https")
556 self.assertIn("test_metric_comp.summary", response.next_request.url.path)
558 # Unknown component.
559 response = get_download_redirect(ref.id, "badcomponent")
560 self.assertEqual(response.status_code, 404)
562 # Not specifying the component for a multi-file "disassembled
563 # composite".
564 response = get_download_redirect(ref.id, None)
565 self.assertEqual(response.status_code, 422)
567 # Unknown dataset.
568 response = get_download_redirect(uuid.UUID("59467c1b-fa13-4f7a-8ff8-cd83e092e563"))
569 self.assertEqual(response.status_code, 404)
571 def test_auth_check(self):
572 # This is checking that the unit-test middleware for validating the
573 # authentication headers is working. It doesn't test actual server
574 # functionality -- in a real deployment, the authentication headers are
575 # handled by GafaelfawrIngress, not our app.
576 with self.assertRaises(UnhandledServerError):
577 self.client.get("/v1/dataset_type/int")
579 def test_exception_logging(self):
580 app = create_app()
582 def raise_error():
583 raise RuntimeError("An unhandled error")
585 app.dependency_overrides[butler_factory_dependency] = raise_error
586 client = TestClient(app, raise_server_exceptions=False)
588 with patch.object(safir.dependencies.logger, "logger_dependency") as mock_logger_dep:
589 mock_logger = NonCallableMock(["aerror"])
591 async def noop():
592 pass
594 mock_logger.aerror.return_value = noop()
596 async def get_logger():
597 return mock_logger
599 mock_logger_dep.return_value = get_logger()
600 client.get(
601 "/api/butler/repo/something/v1/dataset_type/int",
602 headers={"X-Auth-Request-User": "user-name", "X-Butler-Client-Request-Id": "request-id"},
603 )
604 mock_logger_dep.assert_called_once()
606 mock_logger.aerror.assert_called_once()
607 args, kwargs = mock_logger.aerror.call_args
608 self.assertIsInstance(kwargs["exc_info"], RuntimeError)
609 self.assertEqual(kwargs["clientRequestId"], "request-id")
610 self.assertEqual(kwargs["user"], "user-name")
612 def test_query_keepalive(self):
613 """Test that long-running queries stream keep-alive messages to stop
614 the HTTP connection from closing before they are able to return
615 results.
616 """
617 # Normally it takes 15 seconds for a timeout -- mock it to trigger
618 # immediately instead.
619 with patch.object(
620 lsst.daf.butler.remote_butler.server.handlers._query_streaming, "_timeout"
621 ) as mock_timeout:
622 # Hook into QueryDriver to track the number of keep-alives we have
623 # seen.
624 with patch.object(
625 lsst.daf.butler.remote_butler._query_results, "_received_keep_alive"
626 ) as mock_keep_alive:
627 mock_timeout.side_effect = _timeout_twice()
628 with self.butler.query() as query:
629 datasets = list(query.datasets("bias", "imported_g"))
630 self.assertEqual(len(datasets), 3)
631 self.assertGreaterEqual(mock_timeout.call_count, 3)
632 self.assertGreaterEqual(mock_keep_alive.call_count, 2)
634 def test_query_retries(self):
635 """Test that the server will send HTTP status 503 to put backpressure
636 on clients if it is overloaded, and that the client will retry if this
637 happens.
638 """
639 query_event = threading.Event()
640 retry_event = asyncio.Event()
642 async def block_first_request() -> None:
643 # Signal the unit tests that we have reached the critical section
644 # in the server, where the first client has reserved the query
645 # slot.
646 query_event.set()
647 # Block inside the query, until the 2nd client has been forced to
648 # retry.
649 await retry_event.wait()
651 async def block_second_request() -> None:
652 # Release the first client, so it can finish its query and prevent
653 # this client from being blocked on the next go-round.
654 retry_event.set()
656 def do_query(butler: Butler) -> list[DatasetRef]:
657 return butler.query_datasets("bias", "imported_g")
659 with (
660 patch.object(
661 lsst.daf.butler.remote_butler.server.handlers._query_limits,
662 "_MAXIMUM_CONCURRENT_STREAMING_QUERIES",
663 new=1,
664 ),
665 patch.object(
666 lsst.daf.butler.remote_butler.server.handlers._query_limits, "_QUERY_RETRY_SECONDS", new=1
667 ),
668 patch.object(
669 lsst.daf.butler.remote_butler.server.handlers._query_limits,
670 "_block_query_for_unit_test",
671 new=AsyncMock(wraps=block_first_request),
672 ) as mock_first_client,
673 patch.object(
674 lsst.daf.butler.remote_butler.server.handlers._query_limits,
675 "_block_retry_for_unit_test",
676 new=AsyncMock(wraps=block_second_request),
677 ) as mock_second_client,
678 ThreadPoolExecutor(max_workers=1) as exec1,
679 ThreadPoolExecutor(max_workers=1) as exec2,
680 ):
681 first_butler = self.butler
682 second_butler = self.butler.clone()
684 # Run the first client up until the server starts executing its
685 # query.
686 future1 = exec1.submit(do_query, first_butler)
687 event_reached = query_event.wait(60)
688 if not event_reached:
689 raise TimeoutError("Server did not execute query logic as expected.")
691 # Start the second client, which will trigger the retry logic and
692 # release the first client to finish its query.
693 future2 = exec2.submit(do_query, second_butler)
695 result1 = future1.result(60)
696 result2 = future2.result(60)
697 self.assertEqual(len(result1), 3)
698 self.assertEqual(len(result2), 3)
699 # The original thread should have gone through this section, and
700 # then the 2nd thread after it retries.
701 self.assertEqual(mock_first_client.await_count, 2)
702 # We should have triggered the retry logic at least once, but it
703 # might occur multiple times depending how long the first client
704 # takes to finish.
705 self.assertGreaterEqual(mock_second_client.await_count, 1)
707 # TODO DM-46204: This can be removed once the RSP recommended image has
708 # been upgraded to a version that contains DM-46129.
709 def test_deprecated_collection_endpoints(self):
710 # These REST endpoints are no longer used by Butler client so they need
711 # to be checked separately until they can be removed.
712 json = self.butler._connection.get(
713 "collection_info",
714 params={"name": "imported_g", "include_doc": True, "include_parents": True},
715 ).json()
716 self.assertEqual(json["name"], "imported_g")
717 self.assertEqual(json["type"], 1)
719 json = self.butler._connection.post(
720 "query_collections",
721 QueryCollectionsRequestModel(
722 search=["imported_*"], collection_types=[1], flatten_chains=False, include_chains=False
723 ),
724 ).json()
725 self.assertCountEqual(json["collections"], ["imported_g", "imported_r"])
727 def test_oversized_data_coordinate_upload(self):
728 with self.butler.query() as query:
729 ref = self.simple_dataset_ref
730 data_id = ref.dataId
731 data_coordinates = [DataCoordinate.standardize(data_id, visit=x) for x in range(100_001)]
732 with self.assertRaisesRegex(InvalidQueryError, "data coordinate rows"):
733 list(query.join_data_coordinates(data_coordinates).datasets(ref.datasetType, ref.run))
736@unittest.skipIf(not butler_server_is_available, butler_server_import_error)
737class ButlerClientServerParityTestCase(unittest.TestCase):
738 """Test that `RemoteButler` and `DirectButler` agree for every combination
739 of component request and storage class override.
741 This uses its own server instance because
742 `ButlerClientServerTestCase` mocks the `DirectButler` datastore, which
743 would leave nothing to compare the `RemoteButler` results against.
744 """
746 COMPONENT = "summary"
748 COMPOSITE_STORAGE_CLASS = "MetricsConversion"
749 """Read storage class for the composite. Defines the same components as
750 ``StructuredData`` but with a different Python type.
751 """
753 COMPONENT_STORAGE_CLASS = "DictConvertibleModel"
754 """Read storage class for the component itself."""
756 DATA_ID = {"instrument": "DummyCamComp", "visit": 423}
757 COLLECTIONS = "ingest/run"
759 @classmethod
760 def setUpClass(cls):
761 server_instance = cls.enterClassContext(create_test_server(TESTDIR))
762 cls.direct_butler = server_instance.direct_butler
763 cls.remote_butler = server_instance.remote_butler
764 cls.storageClassFactory = StorageClassFactory()
765 repo = MetricTestRepo.create_from_butler(
766 server_instance.direct_butler, server_instance.config_file_path
767 )
768 # One dataset whose write storage class defines the component that will
769 # be requested, and one whose write storage class defines no components
770 # at all so that the component can only come from the read override.
771 cls.refs = {}
772 for name, storage_class in (
773 ("parity_with_components", "StructuredData"),
774 ("parity_no_components", "StructuredDataNoComponents"),
775 ):
776 dataset_type = addDatasetType(repo.butler, name, {"instrument", "visit"}, storage_class)
777 cls.refs[name] = repo.addDataset(cls.DATA_ID, datasetType=dataset_type)
779 def butlers(self):
780 """Iterate over the butlers that must behave identically.
782 Yields
783 ------
784 name : `str`
785 Name of the butler, for use in subtest labels.
786 butler : `Butler`
787 The butler itself.
788 """
789 yield "direct", self.direct_butler
790 yield "remote", self.remote_butler
792 def test_composite_parity(self):
793 """Both butlers must return the same composite, with and without a read
794 storage class override.
795 """
796 composite_pytype = self.storageClassFactory.getStorageClass(self.COMPOSITE_STORAGE_CLASS).pytype
797 for name, ref in self.refs.items():
798 write_pytype = ref.datasetType.storageClass.pytype
799 override_ref = ref.overrideStorageClass(self.COMPOSITE_STORAGE_CLASS)
800 cases = {
801 "get(ref)": (lambda b, r=ref: b.get(r), write_pytype),
802 "get(name, dataId)": (
803 lambda b, n=name: b.get(n, self.DATA_ID, collections=self.COLLECTIONS),
804 write_pytype,
805 ),
806 "get(ref, storageClass)": (
807 lambda b, r=ref: b.get(r, storageClass=self.COMPOSITE_STORAGE_CLASS),
808 composite_pytype,
809 ),
810 "get(override_ref)": (lambda b, r=override_ref: b.get(r), composite_pytype),
811 "getDeferred(ref).get()": (lambda b, r=ref: b.getDeferred(r).get(), write_pytype),
812 "getDeferred(ref, storageClass).get()": (
813 lambda b, r=ref: b.getDeferred(r, storageClass=self.COMPOSITE_STORAGE_CLASS).get(),
814 composite_pytype,
815 ),
816 }
817 for label, (call, pytype) in cases.items():
818 for butler_name, butler in self.butlers():
819 with self.subTest(dataset=name, case=label, butler=butler_name):
820 value = call(butler)
821 self.assertIsInstance(value, pytype)
822 self.assertEqual(value.summary, MetricTestRepo.METRICS_EXAMPLE_SUMMARY)
824 def test_component_parity(self):
825 """Both butlers must return the same component, whether it is defined
826 by the storage class used to write or only by the read override.
827 """
828 summary = MetricTestRepo.METRICS_EXAMPLE_SUMMARY
829 converted = DictConvertibleModel.from_dict(summary)
830 for name, ref in self.refs.items():
831 override_ref = ref.overrideStorageClass(self.COMPOSITE_STORAGE_CLASS)
832 component_ref = override_ref.makeComponentRef(self.COMPONENT)
833 cases = {
834 # A component of the overridden composite, requested by ref, by
835 # dataset type, and via a deferred handle. The component
836 # dataset type carries the composite override in its
837 # parentStorageClass, so unlike a component dataset type *name*
838 # it can express both overrides at once.
839 "get(component_ref)": (lambda b, r=component_ref: b.get(r), summary),
840 "get(component_ref, storageClass)": (
841 lambda b, r=component_ref: b.get(r, storageClass=self.COMPONENT_STORAGE_CLASS),
842 converted,
843 ),
844 "get(component_datasetType, dataId)": (
845 lambda b, r=component_ref: b.get(
846 r.datasetType, self.DATA_ID, collections=self.COLLECTIONS
847 ),
848 summary,
849 ),
850 "getDeferred(component_ref).get()": (
851 lambda b, r=component_ref: b.getDeferred(r).get(),
852 summary,
853 ),
854 # The handle storage class applies to the composite and so
855 # selects the component, while the one given to get() applies
856 # to the component itself.
857 "getDeferred(ref, storageClass).get(component)": (
858 lambda b, r=ref: b.getDeferred(r, storageClass=self.COMPOSITE_STORAGE_CLASS).get(
859 component=self.COMPONENT
860 ),
861 summary,
862 ),
863 "getDeferred(ref, storageClass).get(component, storageClass)": (
864 lambda b, r=ref: b.getDeferred(r, storageClass=self.COMPOSITE_STORAGE_CLASS).get(
865 component=self.COMPONENT, storageClass=self.COMPONENT_STORAGE_CLASS
866 ),
867 converted,
868 ),
869 }
870 if self.COMPONENT in ref.datasetType.storageClass.allComponents():
871 # The write storage class knows the component, so the plain
872 # forms that carry no override also work.
873 cases["get('name.component', dataId)"] = (
874 lambda b, n=name: b.get(
875 f"{n}.{self.COMPONENT}", self.DATA_ID, collections=self.COLLECTIONS
876 ),
877 summary,
878 )
879 cases["get('name.component', dataId, storageClass)"] = (
880 lambda b, n=name: b.get(
881 f"{n}.{self.COMPONENT}",
882 self.DATA_ID,
883 collections=self.COLLECTIONS,
884 storageClass=self.COMPONENT_STORAGE_CLASS,
885 ),
886 converted,
887 )
888 cases["getDeferred(ref).get(component)"] = (
889 lambda b, r=ref: b.getDeferred(r).get(component=self.COMPONENT),
890 summary,
891 )
892 cases["getDeferred('name.component', dataId).get()"] = (
893 lambda b, n=name: b.getDeferred(
894 f"{n}.{self.COMPONENT}", self.DATA_ID, collections=self.COLLECTIONS
895 ).get(),
896 summary,
897 )
898 for label, (call, expected) in cases.items():
899 for butler_name, butler in self.butlers():
900 with self.subTest(dataset=name, case=label, butler=butler_name):
901 self.assertEqual(call(butler), expected)
903 def test_unconveyable_component_override_parity(self):
904 """Both butlers must raise for a component that only the read storage
905 class defines when the request cannot convey the composite override.
907 A component dataset type *name* has nowhere to record a storage class
908 override for the composite, and the ``storageClass`` argument applies
909 to the component, so there is no way to say which composite storage
910 class defines the component. Raising is the only correct answer; a
911 `DatasetRef` or `DatasetType` must be used instead.
912 """
913 name = "parity_no_components"
914 ref = self.refs[name]
915 self.assertNotIn(self.COMPONENT, ref.datasetType.storageClass.allComponents())
916 component_type = f"{name}.{self.COMPONENT}"
917 cases = {
918 "get('name.component', dataId)": lambda b: b.get(
919 component_type, self.DATA_ID, collections=self.COLLECTIONS
920 ),
921 "get('name.component', dataId, storageClass)": lambda b: b.get(
922 component_type,
923 self.DATA_ID,
924 collections=self.COLLECTIONS,
925 storageClass=self.COMPONENT_STORAGE_CLASS,
926 ),
927 "getDeferred('name.component', dataId)": lambda b: b.getDeferred(
928 component_type, self.DATA_ID, collections=self.COLLECTIONS
929 ),
930 "getDeferred('name.component', dataId, storageClass)": lambda b: b.getDeferred(
931 component_type,
932 self.DATA_ID,
933 collections=self.COLLECTIONS,
934 storageClass=self.COMPONENT_STORAGE_CLASS,
935 ),
936 "getDeferred(ref).get(component)": lambda b: b.getDeferred(ref).get(component=self.COMPONENT),
937 }
938 for label, call in cases.items():
939 for butler_name, butler in self.butlers():
940 with self.subTest(case=label, butler=butler_name):
941 with self.assertRaises(UnknownComponentError):
942 call(butler)
945@unittest.skipIf(not butler_server_is_available, butler_server_import_error)
946class ButlerClientServerAuthorizationTestCase(unittest.TestCase):
947 """Test authentication/authorization functionality."""
949 def test_group_authorization(self):
950 """Test that group membership repository authorization is checked when
951 repository is accessed.
952 """
953 with create_test_server(TESTDIR) as server_instance:
954 mock = MockGafaelfawrGroupAuthorizer()
955 server_instance.app.dependency_overrides[authorizer_dependency] = lambda: mock
956 server_instance.direct_butler.registry.registerDatasetType(
957 DatasetType("bias", [], "int", universe=server_instance.direct_butler.dimensions)
958 )
959 server_instance.direct_butler.collections.register("collection")
960 butler = server_instance.remote_butler
961 mock.set_response(False)
962 with self.assertRaises(ButlerServerError) as e:
963 butler.get_dataset_type("bias")
964 self.assertEqual(e.exception.status_code, 403)
965 with self.assertRaises(ButlerServerError) as e:
966 butler.query_datasets("bias", collections="*", find_first=False)
967 self.assertEqual(e.exception.status_code, 403)
969 mock.set_response(True)
970 self.assertEqual(butler.get_dataset_type("bias").name, "bias")
971 self.assertEqual(butler.query_datasets("bias", collections="collection", explain=False), [])
973 def test_cadc_auth(self) -> None:
974 """Test server running in CADC auth mode."""
975 with mock_config() as config:
976 config.authentication = "cadc"
977 config.gafaelfawr_url = "DISABLED"
978 with create_test_server(TESTDIR, server_config=config) as instance:
979 self.assertIsInstance(instance.remote_butler._connection.auth, CadcAuthenticationProvider)
981 # Set up a dataset backed by an HTTP URL.
982 # CADC uses a plain HTTP service, not S3, for hosting Butler
983 # artifacts.
984 dataset_type = DatasetType("test", [], "int", universe=instance.direct_butler.dimensions)
985 ref = DatasetRef(
986 datasetType=dataset_type,
987 dataId=DataCoordinate.makeEmpty(instance.direct_butler.dimensions),
988 run="ingest/run",
989 )
990 path = ResourcePath("https://fake-server.example/some-directory/file.json")
991 dataset = FileDataset(path, ref)
992 # ingest() insists on doing file existence checks, and we don't
993 # have an HTTP server to point it at.
994 with unittest.mock.patch(
995 "lsst.daf.butler.datastores.fileDatastore.FileDatastore._standardizeIngestPath"
996 ) as mock:
997 mock.return_value = path
998 instance.direct_butler.ingest(dataset, transfer="direct", record_validation_info=False)
1000 # At the CADC, paths used for file download should NOT be a
1001 # signed URL, and should have authentication headers attached.
1002 def check_path(path_to_check: ResourcePath):
1003 self.assertEqual(str(path_to_check), str(path))
1004 assert isinstance(path_to_check, HttpResourcePath)
1005 self.assertIsNotNone(path_to_check._extra_headers)
1006 self.assertIsNotNone(path_to_check._extra_headers.get("Authorization"))
1008 check_path(instance.remote_butler.getURI(ref))
1009 transfer_map = instance.remote_butler._file_transfer_source.get_file_info_for_transfer(
1010 [ref.id]
1011 )
1012 check_path(transfer_map[ref.id][0].location.pathInStore)
1015def _create_corrupted_dataset(repo: MetricTestRepo) -> DatasetRef:
1016 run = "corrupted-run"
1017 ref = repo.addDataset({"instrument": "DummyCamComp", "visit": 423}, run=run)
1018 uris = repo.butler.getURIs(ref)
1019 oneOfTheComponents = list(uris.componentURIs.values())[0]
1020 oneOfTheComponents.write("corrupted data")
1021 return ref
1024def _create_simple_dataset(butler: Butler) -> DatasetRef:
1025 dataset_type = addDatasetType(butler, "test_int", {"instrument", "visit"}, "int")
1026 ref = butler.put(123, dataset_type, dataId={"instrument": "DummyCamComp", "visit": 423}, run="ingest/run")
1027 return ref
1030def _timeout_twice():
1031 """Return a mock side-effect function that raises a timeout error the first
1032 two times it is called.
1033 """
1034 count = 0
1036 def timeout(*args):
1037 nonlocal count
1038 count += 1
1039 if count <= 2:
1040 raise TimeoutError()
1041 return DEFAULT
1043 return timeout
1046class _MockStreamingQuery:
1047 @contextmanager
1048 def setup(self) -> Generator[None]:
1049 self.cleanup_executed = False
1050 self.count = 0
1051 try:
1052 yield
1053 finally:
1054 self.cleanup_executed = True
1056 def execute(self, context: None) -> Iterator[QueryKeepAliveModel]:
1057 for _ in range(10):
1058 self.count += 1
1059 yield QueryKeepAliveModel()
1062@unittest.skipIf(not butler_server_is_available, butler_server_import_error)
1063class QueryStreamingTestCase(unittest.IsolatedAsyncioTestCase):
1064 """Test implementation details of query streaming code."""
1066 async def test_query_disconnects(self):
1067 query = _MockStreamingQuery()
1068 iter = _stream_query_pages(query, None)
1069 await anext(iter)
1070 await anext(iter)
1071 # FastAPI calls aclose() on the generator when disconnect occurs, so we
1072 # simulate that here.
1073 await iter.aclose()
1074 # We should have gone through the loop at least twice to yield the two
1075 # results we read. Can go up to 3-4 times depending on the timing of
1076 # the buffered results from the synchronous inner thread to the async
1077 # code.
1078 self.assertIn(query.count, (2, 3, 4))
1079 # Cleanup was run correctly, meaning the thread was given time to exit
1080 # before returning from aclose().
1081 self.assertTrue(query.cleanup_executed)
1084@unittest.skipIf(not butler_server_is_available, butler_server_import_error)
1085class QueryLimitsTestCase(unittest.IsolatedAsyncioTestCase):
1086 """Test details of the code that limits the maximum number of concurrent
1087 queries in the server.
1088 """
1090 async def test_query_limits(self):
1091 limits = lsst.daf.butler.remote_butler.server.handlers._query_limits.QueryLimits()
1093 await limits.enforce_query_limits("user1") # under limit, doesn't raise
1094 async with limits.track_query("user1"):
1095 await limits.enforce_query_limits("user1") # under limit, doesn't raise
1096 async with limits.track_query("user1"):
1097 with self.assertRaises(fastapi.HTTPException) as exc:
1098 await limits.enforce_query_limits("user1")
1099 self.assertEqual(exc.exception.status_code, 429)
1102if __name__ == "__main__":
1103 unittest.main()