Coverage for tests / test_butler.py: 13%

1765 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-12 01:12 -0800

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

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

7# for details of code ownership. 

8# 

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

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

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

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

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

14# 

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

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

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

18# (at your option) any later version. 

19# 

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

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

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

23# GNU General Public License for more details. 

24# 

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

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

27 

28"""Tests for Butler.""" 

29 

30from __future__ import annotations 

31 

32import json 

33import logging 

34import os 

35import pathlib 

36import pickle 

37import posixpath 

38import random 

39import re 

40import shutil 

41import string 

42import tempfile 

43import unittest 

44import unittest.mock 

45import uuid 

46import warnings 

47import weakref 

48from collections.abc import Callable, Mapping 

49from typing import TYPE_CHECKING, Any, cast 

50 

51try: 

52 import boto3 

53 import botocore 

54 

55 from lsst.resources.s3utils import clean_test_environment_for_s3 

56 

57 try: 

58 from moto import mock_aws # v5 

59 except ImportError: 

60 from moto import mock_s3 as mock_aws 

61except ImportError: 

62 boto3 = None 

63 

64 def mock_aws(*args: Any, **kwargs: Any) -> Any: # type: ignore[no-untyped-def] 

65 """No-op decorator in case moto mock_aws can not be imported.""" 

66 return None 

67 

68 

69import astropy.time 

70from sqlalchemy.exc import IntegrityError 

71 

72from lsst.daf.butler import ( 

73 Butler, 

74 ButlerConfig, 

75 ButlerMetrics, 

76 ButlerRepoIndex, 

77 CollectionCycleError, 

78 CollectionType, 

79 Config, 

80 DataCoordinate, 

81 DatasetExistence, 

82 DatasetNotFoundError, 

83 DatasetProvenance, 

84 DatasetRef, 

85 DatasetType, 

86 FileDataset, 

87 NoDefaultCollectionError, 

88 StorageClassFactory, 

89 ValidationError, 

90 script, 

91) 

92from lsst.daf.butler._rubin.file_datasets import transfer_datasets_to_datastore 

93from lsst.daf.butler.datastore import NullDatastore 

94from lsst.daf.butler.datastore.file_templates import FileTemplate, FileTemplateValidationError 

95from lsst.daf.butler.datastores.file_datastore.retrieve_artifacts import ZipIndex 

96from lsst.daf.butler.datastores.fileDatastore import FileDatastore 

97from lsst.daf.butler.direct_butler import DirectButler 

98from lsst.daf.butler.registry import ( 

99 CollectionError, 

100 CollectionTypeError, 

101 ConflictingDefinitionError, 

102 DataIdValueError, 

103 DatasetTypeExpressionError, 

104 MissingCollectionError, 

105 OrphanedRecordError, 

106) 

107from lsst.daf.butler.registry.sql_registry import SqlRegistry 

108from lsst.daf.butler.repo_relocation import BUTLER_ROOT_TAG 

109from lsst.daf.butler.tests import MetricsExample, MetricsExampleModel, MultiDetectorFormatter 

110from lsst.daf.butler.tests.postgresql import TemporaryPostgresInstance, setup_postgres_test_db 

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

112from lsst.daf.butler.tests.utils import ( 

113 MetricTestRepo, 

114 TestCaseMixin, 

115 makeTestTempDir, 

116 removeTestTempDir, 

117 safeTestTempDir, 

118) 

119from lsst.resources import ResourcePath 

120from lsst.resources.http import HttpResourcePath 

121from lsst.utils import doImportType 

122from lsst.utils.introspection import get_full_type_name 

123 

124if butler_server_is_available: 

125 from lsst.daf.butler.tests.server import create_test_server 

126 

127 

128if TYPE_CHECKING: 

129 import types 

130 

131 from lsst.daf.butler import DimensionGroup, Registry, StorageClass 

132 

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

134 

135 

136def clean_environment() -> None: 

137 """Remove external environment variables that affect the tests.""" 

138 for k in ("DAF_BUTLER_REPOSITORY_INDEX",): 

139 os.environ.pop(k, None) 

140 

141 

142def makeExampleMetrics() -> MetricsExample: 

143 """Return example dataset suitable for tests.""" 

144 return MetricsExample( 

145 {"AM1": 5.2, "AM2": 30.6}, 

146 {"a": [1, 2, 3], "b": {"blue": 5, "red": "green"}}, 

147 [563, 234, 456.7, 752, 8, 9, 27], 

148 ) 

149 

150 

151class TransactionTestError(Exception): 

152 """Specific error for testing transactions, to prevent misdiagnosing 

153 that might otherwise occur when a standard exception is used. 

154 """ 

155 

156 pass 

157 

158 

159class ButlerConfigTests(unittest.TestCase): 

160 """Simple tests for ButlerConfig that are not tested in any other test 

161 cases. 

162 """ 

163 

164 def testSearchPath(self) -> None: 

165 configFile = os.path.join(TESTDIR, "config", "basic", "butler.yaml") 

166 with self.assertLogs("lsst.daf.butler", level="DEBUG") as cm: 

167 config1 = ButlerConfig(configFile) 

168 self.assertNotIn("testConfigs", "\n".join(cm.output)) 

169 

170 overrideDirectory = os.path.join(TESTDIR, "config", "testConfigs") 

171 with self.assertLogs("lsst.daf.butler", level="DEBUG") as cm: 

172 config2 = ButlerConfig(configFile, searchPaths=[overrideDirectory]) 

173 self.assertIn("testConfigs", "\n".join(cm.output)) 

174 

175 key = ("datastore", "records", "table") 

176 self.assertNotEqual(config1[key], config2[key]) 

177 self.assertEqual(config2[key], "override_record") 

178 

179 

180class ButlerPutGetTests(TestCaseMixin): 

181 """Helper method for running a suite of put/get tests from different 

182 butler configurations. 

183 """ 

184 

185 root: str 

186 default_run = "ingésτ😺" 

187 storageClassFactory: StorageClassFactory 

188 configFile: str | None 

189 tmpConfigFile: str 

190 

191 @staticmethod 

192 def addDatasetType( 

193 datasetTypeName: str, dimensions: DimensionGroup, storageClass: StorageClass | str, registry: Registry 

194 ) -> DatasetType: 

195 """Create a DatasetType and register it""" 

196 datasetType = DatasetType(datasetTypeName, dimensions, storageClass) 

197 registry.registerDatasetType(datasetType) 

198 return datasetType 

199 

200 @classmethod 

201 def setUpClass(cls) -> None: 

202 cls.storageClassFactory = StorageClassFactory() 

203 if cls.configFile is not None: 

204 cls.storageClassFactory.addFromConfig(cls.configFile) 

205 

206 def assertGetComponents( 

207 self, 

208 butler: Butler, 

209 datasetRef: DatasetRef, 

210 components: tuple[str, ...], 

211 reference: Any, 

212 collections: Any = None, 

213 ) -> None: 

214 datasetType = datasetRef.datasetType 

215 dataId = datasetRef.dataId 

216 deferred = butler.getDeferred(datasetRef) 

217 

218 for component in components: 

219 compTypeName = datasetType.componentTypeName(component) 

220 result = butler.get(compTypeName, dataId, collections=collections) 

221 self.assertEqual(result, getattr(reference, component)) 

222 result_deferred = deferred.get(component=component) 

223 self.assertEqual(result_deferred, result) 

224 

225 def tearDown(self) -> None: 

226 if self.root is not None: 

227 removeTestTempDir(self.root) 

228 

229 def create_empty_butler( 

230 self, 

231 run: str | None = None, 

232 writeable: bool | None = None, 

233 metrics: ButlerMetrics | None = None, 

234 cleanup: bool = True, 

235 ): 

236 """Create a Butler for the test repository, without inserting test 

237 data. 

238 """ 

239 butler = Butler.from_config(self.tmpConfigFile, run=run, writeable=writeable, metrics=metrics) 

240 if cleanup: 

241 self.enterContext(butler) 

242 assert isinstance(butler, DirectButler), "Expect DirectButler in configuration" 

243 return butler 

244 

245 def create_butler( 

246 self, 

247 run: str, 

248 storageClass: StorageClass | str, 

249 datasetTypeName: str, 

250 metrics: ButlerMetrics | None = None, 

251 ) -> tuple[Butler, DatasetType]: 

252 """Create a Butler for the test repository and insert some test data 

253 into it. 

254 """ 

255 butler = self.create_empty_butler(run=run, metrics=metrics) 

256 

257 collections = set(butler.collections.query("*")) 

258 self.assertEqual(collections, {run}) 

259 # Create and register a DatasetType 

260 dimensions = butler.dimensions.conform(["instrument", "visit"]) 

261 

262 datasetType = self.addDatasetType(datasetTypeName, dimensions, storageClass, butler.registry) 

263 

264 # Add needed Dimensions 

265 butler.registry.insertDimensionData("instrument", {"name": "DummyCamComp"}) 

266 butler.registry.insertDimensionData( 

267 "physical_filter", {"instrument": "DummyCamComp", "name": "d-r", "band": "R"} 

268 ) 

269 butler.registry.insertDimensionData( 

270 "visit_system", {"instrument": "DummyCamComp", "id": 1, "name": "default"} 

271 ) 

272 butler.registry.insertDimensionData("day_obs", {"instrument": "DummyCamComp", "id": 20200101}) 

273 visit_start = astropy.time.Time("2020-01-01 08:00:00.123456789", scale="tai") 

274 visit_end = astropy.time.Time("2020-01-01 08:00:36.66", scale="tai") 

275 butler.registry.insertDimensionData( 

276 "visit", 

277 { 

278 "instrument": "DummyCamComp", 

279 "id": 423, 

280 "name": "fourtwentythree", 

281 "physical_filter": "d-r", 

282 "datetime_begin": visit_start, 

283 "datetime_end": visit_end, 

284 "day_obs": 20200101, 

285 }, 

286 ) 

287 

288 # Add more visits for some later tests 

289 for visit_id in (424, 425): 

290 butler.registry.insertDimensionData( 

291 "visit", 

292 { 

293 "instrument": "DummyCamComp", 

294 "id": visit_id, 

295 "name": f"fourtwentyfour_{visit_id}", 

296 "physical_filter": "d-r", 

297 "day_obs": 20200101, 

298 }, 

299 ) 

300 return butler, datasetType 

301 

302 def runPutGetTest(self, storageClass: StorageClass, datasetTypeName: str) -> Butler: 

303 # New datasets will be added to run and tag, but we will only look in 

304 # tag when looking up datasets. 

305 run = self.default_run 

306 butler, datasetType = self.create_butler(run, storageClass, datasetTypeName) 

307 assert butler.run is not None 

308 

309 # Create and store a dataset 

310 metric = makeExampleMetrics() 

311 dataId = butler.registry.expandDataId({"instrument": "DummyCamComp", "visit": 423}) 

312 

313 # Dataset should not exist if we haven't added it 

314 with self.assertRaises(DatasetNotFoundError): 

315 butler.get(datasetTypeName, dataId) 

316 

317 # Put and remove the dataset once as a DatasetRef, once as a dataId, 

318 # and once with a DatasetType 

319 

320 # Keep track of any collections we add and do not clean up 

321 expected_collections = {run} 

322 

323 counter = 0 

324 ref = DatasetRef(datasetType, dataId, id=uuid.UUID(int=1), run="put_run_1") 

325 args = tuple[DatasetRef] | tuple[str | DatasetType, DataCoordinate] 

326 for args in ((ref,), (datasetTypeName, dataId), (datasetType, dataId)): 

327 # Since we are using subTest we can get cascading failures 

328 # here with the first attempt failing and the others failing 

329 # immediately because the dataset already exists. Work around 

330 # this by using a distinct run collection each time 

331 counter += 1 

332 this_run = f"put_run_{counter}" 

333 butler.collections.register(this_run) 

334 expected_collections.update({this_run}) 

335 

336 with self.subTest(args=repr(args)): 

337 kwargs: dict[str, Any] = {} 

338 if not isinstance(args[0], DatasetRef): # type: ignore 

339 kwargs["run"] = this_run 

340 ref = butler.put(metric, *args, **kwargs) 

341 self.assertIsInstance(ref, DatasetRef) 

342 

343 # Test get of a ref. 

344 metricOut = butler.get(ref) 

345 self.assertEqual(metric, metricOut) 

346 # Test get 

347 metricOut = butler.get(ref.datasetType.name, dataId, collections=this_run) 

348 self.assertEqual(metric, metricOut) 

349 # Test get with a datasetRef 

350 metricOut = butler.get(ref) 

351 self.assertEqual(metric, metricOut) 

352 # Test getDeferred with dataId 

353 metricOut = butler.getDeferred(ref.datasetType.name, dataId, collections=this_run).get() 

354 self.assertEqual(metric, metricOut) 

355 # Test getDeferred with a ref 

356 metricOut = butler.getDeferred(ref).get() 

357 self.assertEqual(metric, metricOut) 

358 

359 # Check we can get components 

360 if storageClass.isComposite(): 

361 self.assertGetComponents( 

362 butler, ref, ("summary", "data", "output"), metric, collections=this_run 

363 ) 

364 

365 primary_uri, secondary_uris = butler.getURIs(ref) 

366 n_uris = len(secondary_uris) 

367 if primary_uri: 

368 n_uris += 1 

369 

370 # Can the artifacts themselves be retrieved? 

371 if not butler._datastore.isEphemeral: 

372 # Create a temporary directory to hold the retrieved 

373 # artifacts. 

374 with tempfile.TemporaryDirectory( 

375 prefix="butler-artifacts-", ignore_cleanup_errors=True 

376 ) as artifact_root: 

377 root_uri = ResourcePath(artifact_root, forceDirectory=True) 

378 

379 for preserve_path in (True, False): 

380 destination = root_uri.join(f"{preserve_path}_{counter}/") 

381 log = logging.getLogger("lsst.x") 

382 log.debug("Using destination %s for args %s", destination, args) 

383 # Use copy so that we can test that overwrite 

384 # protection works (using "auto" for File URIs 

385 # would use hard links and subsequent transfer 

386 # would work because it knows they are the same 

387 # file). 

388 transferred = butler.retrieveArtifacts( 

389 [ref], destination, preserve_path=preserve_path, transfer="copy" 

390 ) 

391 self.assertGreater(len(transferred), 0) 

392 artifacts = list(ResourcePath.findFileResources([destination])) 

393 # Filter out the index file. 

394 artifacts = [a for a in artifacts if a.basename() != ZipIndex.index_name] 

395 self.assertEqual(set(transferred), set(artifacts)) 

396 

397 for artifact in transferred: 

398 path_in_destination = artifact.relative_to(destination) 

399 self.assertIsNotNone(path_in_destination) 

400 assert path_in_destination is not None 

401 

402 # When path is not preserved there should not 

403 # be any path separators. 

404 num_seps = path_in_destination.count("/") 

405 if preserve_path: 

406 self.assertGreater(num_seps, 0) 

407 else: 

408 self.assertEqual(num_seps, 0) 

409 

410 self.assertEqual( 

411 len(artifacts), 

412 n_uris, 

413 "Comparing expected artifacts vs actual:" 

414 f" {artifacts} vs {primary_uri} and {secondary_uris}", 

415 ) 

416 

417 if preserve_path: 

418 # No need to run these twice 

419 with self.assertRaises(ValueError): 

420 butler.retrieveArtifacts([ref], destination, transfer="move") 

421 

422 with self.assertRaisesRegex( 

423 ValueError, "^Destination location must refer to a directory" 

424 ): 

425 butler.retrieveArtifacts( 

426 [ref], ResourcePath("/some/file.txt", forceDirectory=False) 

427 ) 

428 

429 with self.assertRaises(FileExistsError): 

430 butler.retrieveArtifacts([ref], destination) 

431 

432 transferred_again = butler.retrieveArtifacts( 

433 [ref], destination, preserve_path=preserve_path, overwrite=True 

434 ) 

435 self.assertEqual(set(transferred_again), set(transferred)) 

436 

437 # Now remove the dataset completely. 

438 butler.pruneDatasets([ref], purge=True, unstore=True) 

439 # Lookup with original args should still fail. 

440 kwargs = {"collections": this_run} 

441 if isinstance(args[0], DatasetRef): 

442 kwargs = {} # Prevent warning from being issued. 

443 self.assertFalse(butler.exists(*args, **kwargs)) 

444 # get() should still fail. 

445 with self.assertRaises((FileNotFoundError, DatasetNotFoundError)): 

446 butler.get(ref) 

447 # Registry shouldn't be able to find it by dataset_id anymore. 

448 self.assertIsNone(butler.get_dataset(ref.id)) 

449 

450 # Do explicit registry removal since we know they are 

451 # empty 

452 butler.collections.x_remove(this_run) 

453 expected_collections.remove(this_run) 

454 

455 # Create DatasetRef for put using default run. 

456 refIn = DatasetRef(datasetType, dataId, id=uuid.UUID(int=1), run=butler.run) 

457 

458 # Check that getDeferred fails with standalone ref. 

459 with self.assertRaises(LookupError): 

460 butler.getDeferred(refIn) 

461 

462 # Put the dataset again, since the last thing we did was remove it 

463 # and we want to use the default collection. 

464 ref = butler.put(metric, refIn) 

465 

466 # Get with parameters 

467 stop = 4 

468 sliced = butler.get(ref, parameters={"slice": slice(stop)}) 

469 self.assertNotEqual(metric, sliced) 

470 self.assertEqual(metric.summary, sliced.summary) 

471 self.assertEqual(metric.output, sliced.output) 

472 assert metric.data is not None # for mypy 

473 self.assertEqual(metric.data[:stop], sliced.data) 

474 # getDeferred with parameters 

475 sliced = butler.getDeferred(ref, parameters={"slice": slice(stop)}).get() 

476 self.assertNotEqual(metric, sliced) 

477 self.assertEqual(metric.summary, sliced.summary) 

478 self.assertEqual(metric.output, sliced.output) 

479 self.assertEqual(metric.data[:stop], sliced.data) 

480 # getDeferred with deferred parameters 

481 sliced = butler.getDeferred(ref).get(parameters={"slice": slice(stop)}) 

482 self.assertNotEqual(metric, sliced) 

483 self.assertEqual(metric.summary, sliced.summary) 

484 self.assertEqual(metric.output, sliced.output) 

485 self.assertEqual(metric.data[:stop], sliced.data) 

486 

487 if storageClass.isComposite(): 

488 # Check that components can be retrieved 

489 metricOut = butler.get(ref.datasetType.name, dataId) 

490 compNameS = ref.datasetType.componentTypeName("summary") 

491 compNameD = ref.datasetType.componentTypeName("data") 

492 summary = butler.get(compNameS, dataId) 

493 self.assertEqual(summary, metric.summary) 

494 data = butler.get(compNameD, dataId) 

495 self.assertEqual(data, metric.data) 

496 

497 if "counter" in storageClass.derivedComponents: 

498 count = butler.get(ref.datasetType.componentTypeName("counter"), dataId) 

499 self.assertEqual(count, len(data)) 

500 

501 count = butler.get( 

502 ref.datasetType.componentTypeName("counter"), dataId, parameters={"slice": slice(stop)} 

503 ) 

504 self.assertEqual(count, stop) 

505 

506 compRef = butler.find_dataset(compNameS, dataId, collections=butler.collections.defaults) 

507 assert compRef is not None 

508 summary = butler.get(compRef) 

509 self.assertEqual(summary, metric.summary) 

510 

511 # Create a Dataset type that has the same name but is inconsistent. 

512 inconsistentDatasetType = DatasetType( 

513 datasetTypeName, datasetType.dimensions, self.storageClassFactory.getStorageClass("Config") 

514 ) 

515 

516 # Getting with a dataset type that does not match registry fails 

517 with self.assertRaisesRegex( 

518 ValueError, 

519 "(Supplied dataset type .* inconsistent with registry)" 

520 "|(The new storage class .* is not compatible with the existing storage class)", 

521 ): 

522 butler.get(inconsistentDatasetType, dataId) 

523 

524 # Combining a DatasetRef with a dataId should fail 

525 with self.assertRaisesRegex(ValueError, "DatasetRef given, cannot use dataId as well"): 

526 butler.get(ref, dataId) 

527 # Getting with an explicit ref should fail if the id doesn't match. 

528 with self.assertRaises((FileNotFoundError, DatasetNotFoundError)): 

529 butler.get(DatasetRef(ref.datasetType, ref.dataId, id=uuid.UUID(int=101), run=butler.run)) 

530 

531 # Getting a dataset with unknown parameters should fail 

532 with self.assertRaisesRegex(KeyError, "Parameter 'unsupported' not understood"): 

533 butler.get(ref, parameters={"unsupported": True}) 

534 

535 # Check we have a collection 

536 collections = set(butler.collections.query("*")) 

537 self.assertEqual(collections, expected_collections) 

538 

539 # Clean up to check that we can remove something that may have 

540 # already had a component removed 

541 butler.pruneDatasets([ref], unstore=True, purge=True) 

542 

543 # Add the same ref again, so we can check that duplicate put fails. 

544 ref = butler.put(metric, datasetType, dataId) 

545 

546 # Repeat put will fail. 

547 with self.assertRaisesRegex( 

548 ConflictingDefinitionError, "A database constraint failure was triggered" 

549 ): 

550 butler.put(metric, datasetType, dataId) 

551 

552 # Remove the datastore entry. 

553 butler.pruneDatasets([ref], unstore=True, purge=False, disassociate=False) 

554 

555 # Put will still fail 

556 with self.assertRaisesRegex( 

557 ConflictingDefinitionError, "A database constraint failure was triggered" 

558 ): 

559 butler.put(metric, datasetType, dataId) 

560 

561 # Repeat the same sequence with resolved ref. 

562 butler.pruneDatasets([ref], unstore=True, purge=True) 

563 ref = butler.put(metric, refIn) 

564 

565 # Repeat put will fail. 

566 with self.assertRaisesRegex(ConflictingDefinitionError, "Datastore already contains dataset"): 

567 butler.put(metric, refIn) 

568 

569 # Remove the datastore entry. 

570 butler.pruneDatasets([ref], unstore=True, purge=False, disassociate=False) 

571 

572 # In case of resolved ref this write will succeed. 

573 ref = butler.put(metric, refIn) 

574 

575 # Leave the dataset in place since some downstream tests require 

576 # something to be present 

577 

578 return butler 

579 

580 def testDeferredCollectionPassing(self) -> None: 

581 # Construct a butler with no run or collection, but make it writeable. 

582 butler = self.create_empty_butler(writeable=True) 

583 # Create and register a DatasetType 

584 dimensions = butler.dimensions.conform(["instrument", "visit"]) 

585 datasetType = self.addDatasetType( 

586 "example", dimensions, self.storageClassFactory.getStorageClass("StructuredData"), butler.registry 

587 ) 

588 # Add needed Dimensions 

589 butler.registry.insertDimensionData("instrument", {"name": "DummyCamComp"}) 

590 butler.registry.insertDimensionData( 

591 "physical_filter", {"instrument": "DummyCamComp", "name": "d-r", "band": "R"} 

592 ) 

593 butler.registry.insertDimensionData("day_obs", {"instrument": "DummyCamComp", "id": 20250101}) 

594 butler.registry.insertDimensionData( 

595 "visit", 

596 { 

597 "instrument": "DummyCamComp", 

598 "id": 423, 

599 "name": "fourtwentythree", 

600 "physical_filter": "d-r", 

601 "day_obs": 20250101, 

602 }, 

603 ) 

604 dataId = {"instrument": "DummyCamComp", "visit": 423} 

605 # Create dataset. 

606 metric = makeExampleMetrics() 

607 # Register a new run and put dataset. 

608 run = "deferred" 

609 self.assertTrue(butler.collections.register(run)) 

610 # Second time it will be allowed but indicate no-op 

611 self.assertFalse(butler.collections.register(run)) 

612 ref = butler.put(metric, datasetType, dataId, run=run) 

613 # Putting with no run should fail with TypeError. 

614 with self.assertRaises(CollectionError): 

615 butler.put(metric, datasetType, dataId) 

616 # Dataset should exist. 

617 self.assertTrue(butler.exists(datasetType, dataId, collections=[run])) 

618 # We should be able to get the dataset back, but with and without 

619 # a deferred dataset handle. 

620 self.assertEqual(metric, butler.get(datasetType, dataId, collections=[run])) 

621 self.assertEqual(metric, butler.getDeferred(datasetType, dataId, collections=[run]).get()) 

622 # Trying to find the dataset without any collection is an error. 

623 with self.assertRaises(NoDefaultCollectionError): 

624 butler.exists(datasetType, dataId) 

625 with self.assertRaises(CollectionError): 

626 butler.get(datasetType, dataId) 

627 # Associate the dataset with a different collection. 

628 butler.collections.register("tagged", type=CollectionType.TAGGED) 

629 butler.registry.associate("tagged", [ref]) 

630 # Deleting the dataset from the new collection should make it findable 

631 # in the original collection. 

632 butler.pruneDatasets([ref], tags=["tagged"]) 

633 self.assertTrue(butler.exists(datasetType, dataId, collections=[run])) 

634 

635 

636class ButlerTests(ButlerPutGetTests): 

637 """Tests for Butler.""" 

638 

639 useTempRoot = True 

640 validationCanFail: bool 

641 fullConfigKey: str | None 

642 registryStr: str | None 

643 datastoreName: list[str] | None 

644 datastoreStr: list[str] 

645 predictionSupported = True 

646 """Does getURIs support 'prediction mode'?""" 

647 

648 def setUp(self) -> None: 

649 """Create a new butler root for each test.""" 

650 self.root = makeTestTempDir(TESTDIR) 

651 Butler.makeRepo(self.root, config=Config(self.configFile)) 

652 self.tmpConfigFile = os.path.join(self.root, "butler.yaml") 

653 

654 def are_uris_equivalent(self, uri1: ResourcePath, uri2: ResourcePath) -> bool: 

655 """Return True if two URIs refer to the same resource. 

656 

657 Subclasses may override to handle unique requirements. 

658 """ 

659 return uri1 == uri2 

660 

661 def testConstructor(self) -> None: 

662 """Independent test of constructor.""" 

663 butler = Butler.from_config(self.tmpConfigFile, run=self.default_run) 

664 self.enterContext(butler) 

665 self.assertIsInstance(butler, Butler) 

666 

667 # Check that butler.yaml is added automatically. 

668 if self.tmpConfigFile.endswith(end := "/butler.yaml"): 

669 config_dir = self.tmpConfigFile[: -len(end)] 

670 butler = Butler.from_config(config_dir, run=self.default_run) 

671 self.enterContext(butler) 

672 self.assertIsInstance(butler, Butler) 

673 

674 # Even with a ResourcePath. 

675 butler = Butler.from_config(ResourcePath(config_dir, forceDirectory=True), run=self.default_run) 

676 self.enterContext(butler) 

677 self.assertIsInstance(butler, Butler) 

678 

679 collections = set(butler.collections.query("*")) 

680 self.assertEqual(collections, {self.default_run}) 

681 

682 # Check that some special characters can be included in run name. 

683 special_run = "u@b.c-A" 

684 butler_special = Butler.from_config(butler=butler, run=special_run) 

685 self.enterContext(butler_special) 

686 collections = set(butler_special.registry.queryCollections("*@*")) 

687 self.assertEqual(collections, {special_run}) 

688 

689 butler2 = Butler.from_config(butler=butler, collections=["other"]) 

690 self.enterContext(butler2) 

691 self.assertEqual(butler2.collections.defaults, ("other",)) 

692 self.assertIsNone(butler2.run) 

693 self.assertEqual(type(butler._datastore), type(butler2._datastore)) 

694 self.assertEqual(butler._datastore.config, butler2._datastore.config) 

695 

696 # Test that we can use an environment variable to find this 

697 # repository. 

698 butler_index = Config() 

699 butler_index["label"] = self.tmpConfigFile 

700 for suffix in (".yaml", ".json"): 

701 # Ensure that the content differs so that we know that 

702 # we aren't reusing the cache. 

703 bad_label = f"file://bucket/not_real{suffix}" 

704 butler_index["bad_label"] = bad_label 

705 with ResourcePath.temporary_uri(suffix=suffix) as temp_file: 

706 butler_index.dumpToUri(temp_file) 

707 with unittest.mock.patch.dict(os.environ, {"DAF_BUTLER_REPOSITORY_INDEX": str(temp_file)}): 

708 self.assertEqual(Butler.get_known_repos(), {"label", "bad_label"}) 

709 uri = Butler.get_repo_uri("bad_label") 

710 self.assertEqual(uri, ResourcePath(bad_label)) 

711 uri = Butler.get_repo_uri("label") 

712 butler = Butler.from_config(uri, writeable=False) 

713 self.assertIsInstance(butler, Butler) 

714 butler.close() 

715 butler = Butler.from_config("label", writeable=False) 

716 self.assertIsInstance(butler, Butler) 

717 butler.close() 

718 with self.assertRaisesRegex(FileNotFoundError, "aliases:.*bad_label"): 

719 Butler.from_config("not_there", writeable=False) 

720 with self.assertRaisesRegex(FileNotFoundError, "resolved from alias 'bad_label'"): 

721 Butler.from_config("bad_label") 

722 with self.assertRaises(FileNotFoundError): 

723 # Should ignore aliases. 

724 Butler.from_config(ResourcePath("label", forceAbsolute=False)) 

725 with self.assertRaises(KeyError) as cm: 

726 Butler.get_repo_uri("missing") 

727 self.assertEqual( 

728 Butler.get_repo_uri("missing", True), ResourcePath("missing", forceAbsolute=False) 

729 ) 

730 self.assertIn("not known to", str(cm.exception)) 

731 # Should report no failure. 

732 self.assertEqual(ButlerRepoIndex.get_failure_reason(), "") 

733 with ResourcePath.temporary_uri(suffix=suffix) as temp_file: 

734 # Now with empty configuration. 

735 butler_index = Config() 

736 butler_index.dumpToUri(temp_file) 

737 with unittest.mock.patch.dict(os.environ, {"DAF_BUTLER_REPOSITORY_INDEX": str(temp_file)}): 

738 with self.assertRaisesRegex(FileNotFoundError, "(no known aliases)"): 

739 Butler.from_config("label") 

740 with ResourcePath.temporary_uri(suffix=suffix) as temp_file: 

741 # Now with bad contents. 

742 with open(temp_file.ospath, "w") as fh: 

743 print("'", file=fh) 

744 with unittest.mock.patch.dict(os.environ, {"DAF_BUTLER_REPOSITORY_INDEX": str(temp_file)}): 

745 with self.assertRaisesRegex(FileNotFoundError, "(no known aliases:.*could not be read)"): 

746 Butler.from_config("label") 

747 with unittest.mock.patch.dict(os.environ, {"DAF_BUTLER_REPOSITORY_INDEX": "file://not_found/x.yaml"}): 

748 with self.assertRaises(FileNotFoundError): 

749 Butler.get_repo_uri("label") 

750 self.assertEqual(Butler.get_known_repos(), set()) 

751 

752 with self.assertRaisesRegex(FileNotFoundError, "index file not found"): 

753 Butler.from_config("label") 

754 

755 # Check that we can create Butler when the alias file is not found. 

756 butler = Butler.from_config(self.tmpConfigFile, writeable=False) 

757 self.enterContext(butler) 

758 self.assertIsInstance(butler, Butler) 

759 with self.assertRaises(RuntimeError) as cm: 

760 # No environment variable set. 

761 Butler.get_repo_uri("label") 

762 self.assertEqual(Butler.get_repo_uri("label", True), ResourcePath("label", forceAbsolute=False)) 

763 self.assertIn("No repository index defined", str(cm.exception)) 

764 with self.assertRaisesRegex(FileNotFoundError, "no known aliases.*No repository index"): 

765 # No aliases registered. 

766 Butler.from_config("not_there") 

767 self.assertEqual(Butler.get_known_repos(), set()) 

768 

769 def testClose(self): 

770 butler = self.create_empty_butler(cleanup=False) 

771 is_direct_butler = isinstance(butler, DirectButler) 

772 if is_direct_butler: 

773 self.assertFalse(butler._closed) 

774 

775 with butler as butler_from_context_manager: 

776 self.assertIs(butler, butler_from_context_manager) 

777 if is_direct_butler: 

778 self.assertTrue(butler._closed) 

779 with self.assertRaisesRegex(RuntimeError, "has been closed"): 

780 butler.get_dataset_type("raw") 

781 

782 # Close may be called multiple times. 

783 butler.close() 

784 if is_direct_butler: 

785 self.assertTrue(butler._closed) 

786 

787 def testGarbageCollection(self): 

788 """Test that Butler does not have any circular references that prevent 

789 it from being garbage collected immediately when it goes out of scope. 

790 """ 

791 butler = self.create_empty_butler(cleanup=False) 

792 is_direct_butler = isinstance(butler, DirectButler) 

793 butler_ref = weakref.ref(butler) 

794 if is_direct_butler: 

795 registry_ref = weakref.ref(butler._registry) 

796 managers_ref = weakref.ref(butler._registry._managers) 

797 datastore_ref = weakref.ref(butler._datastore) 

798 db_ref = weakref.ref(butler._registry._db) 

799 engine_ref = weakref.ref(butler._registry._db._engine) 

800 

801 with warnings.catch_warnings(): 

802 # Hide warnings from unclosed database handles. 

803 warnings.simplefilter("ignore", ResourceWarning) 

804 del butler 

805 self.assertIsNone(butler_ref(), "Butler should have been garbage collected") 

806 if is_direct_butler: 

807 self.assertIsNone(registry_ref(), "SqlRegistry should have been garbage collected") 

808 self.assertIsNone(managers_ref(), "Registry managers should have been garbage collected") 

809 self.assertIsNone(datastore_ref(), "Datastore should have been garbage collected") 

810 self.assertIsNone(db_ref(), "Database should have been garbage collected") 

811 # SQLAlchemy has internal reference cycles, so the Engine instance 

812 # is not cleaned up promptly even if we release our reference to 

813 # it. Explicitly clean it up here to avoid file handles leaking. 

814 if is_direct_butler: 

815 engine = engine_ref() 

816 if engine is not None: 

817 engine.dispose() 

818 

819 def testDafButlerRepositories(self): 

820 with unittest.mock.patch.dict( 

821 os.environ, 

822 {"DAF_BUTLER_REPOSITORIES": "label: 'https://someuri.com'\notherLabel: 'https://otheruri.com'\n"}, 

823 ): 

824 self.assertEqual(str(Butler.get_repo_uri("label")), "https://someuri.com") 

825 

826 with unittest.mock.patch.dict( 

827 os.environ, 

828 { 

829 "DAF_BUTLER_REPOSITORIES": "label: https://someuri.com", 

830 "DAF_BUTLER_REPOSITORY_INDEX": "https://someuri.com", 

831 }, 

832 ): 

833 with self.assertRaisesRegex(RuntimeError, "Only one of the environment variables"): 

834 Butler.get_repo_uri("label") 

835 

836 with unittest.mock.patch.dict( 

837 os.environ, 

838 {"DAF_BUTLER_REPOSITORIES": "invalid"}, 

839 ): 

840 with self.assertRaisesRegex(ValueError, "Repository index not in expected format"): 

841 Butler.get_repo_uri("label") 

842 

843 def testBasicPutGet(self) -> None: 

844 storageClass = self.storageClassFactory.getStorageClass("StructuredDataNoComponents") 

845 self.runPutGetTest(storageClass, "test_metric") 

846 

847 def testCompositePutGetConcrete(self) -> None: 

848 storageClass = self.storageClassFactory.getStorageClass("StructuredCompositeReadCompNoDisassembly") 

849 butler = self.runPutGetTest(storageClass, "test_metric") 

850 

851 # Should *not* be disassembled 

852 datasets = list(butler.registry.queryDatasets(..., collections=self.default_run)) 

853 self.assertEqual(len(datasets), 1) 

854 uri, components = butler.getURIs(datasets[0]) 

855 self.assertIsInstance(uri, ResourcePath) 

856 self.assertFalse(components) 

857 self.assertEqual(uri.fragment, "", f"Checking absence of fragment in {uri}") 

858 self.assertIn("423", str(uri), f"Checking visit is in URI {uri}") 

859 

860 # Predicted dataset 

861 if self.predictionSupported: 

862 dataId = {"instrument": "DummyCamComp", "visit": 424} 

863 uri, components = butler.getURIs(datasets[0].datasetType, dataId=dataId, predict=True) 

864 self.assertFalse(components) 

865 self.assertIsInstance(uri, ResourcePath) 

866 self.assertIn("424", str(uri), f"Checking visit is in URI {uri}") 

867 self.assertEqual(uri.fragment, "predicted", f"Checking for fragment in {uri}") 

868 

869 def testCompositePutGetVirtual(self) -> None: 

870 storageClass = self.storageClassFactory.getStorageClass("StructuredCompositeReadComp") 

871 butler = self.runPutGetTest(storageClass, "test_metric_comp") 

872 

873 # Should be disassembled 

874 datasets = list(butler.registry.queryDatasets(..., collections=self.default_run)) 

875 self.assertEqual(len(datasets), 1) 

876 uri, components = butler.getURIs(datasets[0]) 

877 

878 if butler._datastore.isEphemeral: 

879 # Never disassemble in-memory datastore 

880 self.assertIsInstance(uri, ResourcePath) 

881 self.assertFalse(components) 

882 self.assertEqual(uri.fragment, "", f"Checking absence of fragment in {uri}") 

883 self.assertIn("423", str(uri), f"Checking visit is in URI {uri}") 

884 else: 

885 self.assertIsNone(uri) 

886 self.assertEqual(set(components), set(storageClass.components)) 

887 for compuri in components.values(): 

888 self.assertIsInstance(compuri, ResourcePath) 

889 self.assertIn("423", str(compuri), f"Checking visit is in URI {compuri}") 

890 self.assertEqual(compuri.fragment, "", f"Checking absence of fragment in {compuri}") 

891 

892 if self.predictionSupported: 

893 # Predicted dataset 

894 dataId = {"instrument": "DummyCamComp", "visit": 424} 

895 uri, components = butler.getURIs(datasets[0].datasetType, dataId=dataId, predict=True) 

896 

897 if butler._datastore.isEphemeral: 

898 # Never disassembled 

899 self.assertIsInstance(uri, ResourcePath) 

900 self.assertFalse(components) 

901 self.assertIn("424", str(uri), f"Checking visit is in URI {uri}") 

902 self.assertEqual(uri.fragment, "predicted", f"Checking for fragment in {uri}") 

903 else: 

904 self.assertIsNone(uri) 

905 self.assertEqual(set(components), set(storageClass.components)) 

906 for compuri in components.values(): 

907 self.assertIsInstance(compuri, ResourcePath) 

908 self.assertIn("424", str(compuri), f"Checking visit is in URI {compuri}") 

909 self.assertEqual(compuri.fragment, "predicted", f"Checking for fragment in {compuri}") 

910 

911 def testStorageClassOverrideGet(self) -> None: 

912 """Test storage class conversion on get with override.""" 

913 storageClass = self.storageClassFactory.getStorageClass("StructuredData") 

914 datasetTypeName = "anything" 

915 run = self.default_run 

916 

917 butler, datasetType = self.create_butler(run, storageClass, datasetTypeName) 

918 

919 # Create and store a dataset. 

920 metric = makeExampleMetrics() 

921 dataId = {"instrument": "DummyCamComp", "visit": 423} 

922 

923 ref = butler.put(metric, datasetType, dataId) 

924 

925 # Return native type. 

926 retrieved = butler.get(ref) 

927 self.assertEqual(retrieved, metric) 

928 

929 # Specify an override. 

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

931 model = butler.get(ref, storageClass=new_sc) 

932 self.assertNotEqual(type(model), type(retrieved)) 

933 self.assertIs(type(model), new_sc.pytype) 

934 self.assertEqual(retrieved, model) 

935 

936 # Defer but override later. 

937 deferred = butler.getDeferred(ref) 

938 model = deferred.get(storageClass=new_sc) 

939 self.assertIs(type(model), new_sc.pytype) 

940 self.assertEqual(retrieved, model) 

941 

942 # Defer but override up front. 

943 deferred = butler.getDeferred(ref, storageClass=new_sc) 

944 model = deferred.get() 

945 self.assertIs(type(model), new_sc.pytype) 

946 self.assertEqual(retrieved, model) 

947 

948 # Retrieve a component. Should be a tuple. 

949 data = butler.get("anything.data", dataId, storageClass="StructuredDataDataTestTuple") 

950 self.assertIs(type(data), tuple) 

951 self.assertEqual(data, tuple(retrieved.data)) 

952 

953 # Parameter on the write storage class should work regardless 

954 # of read storage class. 

955 data = butler.get( 

956 "anything.data", 

957 dataId, 

958 storageClass="StructuredDataDataTestTuple", 

959 parameters={"slice": slice(2, 4)}, 

960 ) 

961 self.assertEqual(len(data), 2) 

962 

963 # Try a parameter that is known to the read storage class but not 

964 # the write storage class. 

965 with self.assertRaises(KeyError): 

966 butler.get( 

967 "anything.data", 

968 dataId, 

969 storageClass="StructuredDataDataTestTuple", 

970 parameters={"xslice": slice(2, 4)}, 

971 ) 

972 

973 def testPytypePutCoercion(self) -> None: 

974 """Test python type coercion on Butler.get and put.""" 

975 # Store some data with the normal example storage class. 

976 storageClass = self.storageClassFactory.getStorageClass("StructuredDataNoComponents") 

977 datasetTypeName = "test_metric" 

978 butler, _ = self.create_butler(self.default_run, storageClass, datasetTypeName) 

979 

980 dataId = {"instrument": "DummyCamComp", "visit": 423} 

981 

982 # Put a dict and this should coerce to a MetricsExample 

983 test_dict = {"summary": {"a": 1}, "output": {"b": 2}} 

984 metric_ref = butler.put(test_dict, datasetTypeName, dataId=dataId, visit=424) 

985 test_metric = butler.get(metric_ref) 

986 self.assertEqual(get_full_type_name(test_metric), "lsst.daf.butler.tests.MetricsExample") 

987 self.assertEqual(test_metric.summary, test_dict["summary"]) 

988 self.assertEqual(test_metric.output, test_dict["output"]) 

989 

990 # Check that the put still works if a DatasetType is given with 

991 # a definition matching this python type. 

992 registry_type = butler.get_dataset_type(datasetTypeName) 

993 this_type = DatasetType(datasetTypeName, registry_type.dimensions, "StructuredDataDictJson") 

994 metric2_ref = butler.put(test_dict, this_type, dataId=dataId, visit=425) 

995 self.assertEqual(metric2_ref.datasetType, registry_type) 

996 

997 # The get will return the type expected by registry. 

998 test_metric2 = butler.get(metric2_ref) 

999 self.assertEqual(get_full_type_name(test_metric2), "lsst.daf.butler.tests.MetricsExample") 

1000 

1001 # Make a new DatasetRef with the compatible but different DatasetType. 

1002 # This should now return a dict. 

1003 new_ref = DatasetRef(this_type, metric2_ref.dataId, id=metric2_ref.id, run=metric2_ref.run) 

1004 test_dict2 = butler.get(new_ref) 

1005 self.assertEqual(get_full_type_name(test_dict2), "dict") 

1006 

1007 # Get it again with the wrong dataset type definition using get() 

1008 # rather than get(). This should be consistent with get() 

1009 # behavior and return the type of the DatasetType. 

1010 test_dict3 = butler.get(this_type, dataId=dataId, visit=425) 

1011 self.assertEqual(get_full_type_name(test_dict3), "dict") 

1012 

1013 def test_ingest_zip(self) -> None: 

1014 """Create butler, export data, delete data, import from Zip.""" 

1015 butler, dataset_type = self.create_butler( 

1016 run=self.default_run, storageClass="StructuredData", datasetTypeName="metrics" 

1017 ) 

1018 

1019 metric = makeExampleMetrics() 

1020 refs = [] 

1021 for visit in (423, 424, 425): 

1022 ref = butler.put(metric, dataset_type, instrument="DummyCamComp", visit=visit) 

1023 refs.append(ref) 

1024 

1025 # Retrieve a Zip file. 

1026 with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdir: 

1027 zip = butler.retrieve_artifacts_zip(refs, destination=tmpdir) 

1028 

1029 # Ingest will fail. 

1030 with self.assertRaises(ConflictingDefinitionError): 

1031 butler.ingest_zip(zip) 

1032 

1033 # Clear out the collection. 

1034 butler.removeRuns([self.default_run]) 

1035 self.assertFalse(butler.exists(refs[0])) 

1036 

1037 butler.ingest_zip(zip, transfer="copy") 

1038 

1039 # Create an entirely new local file butler in this temp directory. 

1040 new_butler_cfg = Butler.makeRepo(tmpdir) 

1041 new_butler = Butler.from_config(new_butler_cfg, writeable=True) 

1042 self.enterContext(new_butler) 

1043 

1044 # This will fail since dimensions records are missing. 

1045 with self.assertRaises(ConflictingDefinitionError): 

1046 new_butler.ingest_zip(zip, transfer="copy") 

1047 

1048 # Dry run should work. 

1049 new_butler.ingest_zip(zip, transfer="copy", dry_run=True) 

1050 

1051 new_butler.ingest_zip(zip, transfer="copy", transfer_dimensions=True) 

1052 self.assertTrue(butler.exists(refs[0])) 

1053 

1054 # Check that the refs can be read again. 

1055 _ = [butler.get(ref) for ref in refs] 

1056 

1057 uri = butler.getURI(refs[2]) 

1058 self.assertTrue(uri.exists()) 

1059 

1060 # Delete one dataset. The Zip file should still exist and allow 

1061 # remaining refs to be read. 

1062 butler.pruneDatasets([refs[0]], purge=True, unstore=True) 

1063 self.assertTrue(uri.exists()) 

1064 

1065 metric2 = butler.get(refs[1]) 

1066 self.assertEqual(metric2, metric, msg=f"{metric2} != {metric}") 

1067 

1068 butler.removeRuns([self.default_run]) 

1069 self.assertFalse(uri.exists()) 

1070 self.assertFalse(butler.exists(refs[-1])) 

1071 

1072 with self.assertRaises(ValueError): 

1073 butler.retrieve_artifacts_zip([], destination=".") 

1074 

1075 def testIngest(self) -> None: 

1076 butler = self.create_empty_butler(run=self.default_run) 

1077 

1078 # Create and register a DatasetType 

1079 dimensions = butler.dimensions.conform(["instrument", "visit", "detector"]) 

1080 

1081 storageClass = self.storageClassFactory.getStorageClass("StructuredDataDictYaml") 

1082 datasetTypeName = "metric" 

1083 

1084 datasetType = self.addDatasetType(datasetTypeName, dimensions, storageClass, butler.registry) 

1085 

1086 # Add needed Dimensions 

1087 butler.registry.insertDimensionData("instrument", {"name": "DummyCamComp"}) 

1088 butler.registry.insertDimensionData( 

1089 "physical_filter", {"instrument": "DummyCamComp", "name": "d-r", "band": "R"} 

1090 ) 

1091 butler.registry.insertDimensionData("day_obs", {"instrument": "DummyCamComp", "id": 20250101}) 

1092 for detector in (1, 2): 

1093 butler.registry.insertDimensionData( 

1094 "detector", {"instrument": "DummyCamComp", "id": detector, "full_name": f"detector{detector}"} 

1095 ) 

1096 

1097 butler.registry.insertDimensionData( 

1098 "visit", 

1099 { 

1100 "instrument": "DummyCamComp", 

1101 "id": 423, 

1102 "name": "fourtwentythree", 

1103 "physical_filter": "d-r", 

1104 "day_obs": 20250101, 

1105 }, 

1106 { 

1107 "instrument": "DummyCamComp", 

1108 "id": 424, 

1109 "name": "fourtwentyfour", 

1110 "physical_filter": "d-r", 

1111 "day_obs": 20250101, 

1112 }, 

1113 ) 

1114 

1115 formatter = doImportType("lsst.daf.butler.formatters.yaml.YamlFormatter") 

1116 dataRoot = os.path.join(TESTDIR, "data", "basic") 

1117 datasets = [] 

1118 # Test one DatasetRef with a run that exists, and the other with a run 

1119 # that doesn't exist, to verify that run collections are created when 

1120 # required. 

1121 runs = {1: self.default_run, 2: "a/new/run"} 

1122 for detector in (1, 2): 

1123 detector_name = f"detector_{detector}" 

1124 metricFile = os.path.join(dataRoot, f"{detector_name}.yaml") 

1125 dataId = butler.registry.expandDataId( 

1126 {"instrument": "DummyCamComp", "visit": 423, "detector": detector} 

1127 ) 

1128 # Create a DatasetRef for ingest 

1129 refIn = DatasetRef(datasetType, dataId, run=runs[detector]) 

1130 

1131 datasets.append(FileDataset(path=metricFile, refs=[refIn], formatter=formatter)) 

1132 

1133 butler.ingest(*datasets, transfer="copy") 

1134 

1135 dataId1 = {"instrument": "DummyCamComp", "detector": 1, "visit": 423} 

1136 dataId2 = {"instrument": "DummyCamComp", "detector": 2, "visit": 423} 

1137 

1138 metrics1 = butler.get(datasetTypeName, dataId1) 

1139 metrics2 = butler.get(datasetTypeName, dataId2, collections="a/new/run") 

1140 self.assertNotEqual(metrics1, metrics2) 

1141 

1142 # Compare URIs 

1143 uri1 = butler.getURI(datasetTypeName, dataId1) 

1144 uri2 = butler.getURI(datasetTypeName, dataId2, collections="a/new/run") 

1145 self.assertFalse(self.are_uris_equivalent(uri1, uri2), f"Cf. {uri1} with {uri2}") 

1146 

1147 # Re-ingesting the same datasets raises an error with 

1148 # skip_existing=False. 

1149 with self.assertRaises(ConflictingDefinitionError): 

1150 butler.ingest(*datasets, transfer="copy") 

1151 # skip_existing=True makes it a no-op to re-ingest the same datasets. 

1152 butler.ingest(*datasets, transfer="copy", skip_existing=True) 

1153 

1154 # Now do a multi-dataset but single file ingest 

1155 metricFile = os.path.join(dataRoot, "detectors.yaml") 

1156 refs = [] 

1157 for detector in (1, 2): 

1158 detector_name = f"detector_{detector}" 

1159 dataId = butler.registry.expandDataId( 

1160 {"instrument": "DummyCamComp", "visit": 424, "detector": detector} 

1161 ) 

1162 # Create a DatasetRef for ingest 

1163 refs.append(DatasetRef(datasetType, dataId, run=self.default_run)) 

1164 

1165 # Test "move" transfer to ensure that the files themselves 

1166 # have disappeared following ingest. 

1167 with ResourcePath.temporary_uri(suffix=".yaml") as tempFile: 

1168 tempFile.transfer_from(ResourcePath(metricFile), transfer="copy") 

1169 

1170 datasets = [] 

1171 datasets.append(FileDataset(path=tempFile, refs=refs, formatter=MultiDetectorFormatter)) 

1172 

1173 # For first ingest use copy. 

1174 butler.ingest(*datasets, transfer="copy", record_validation_info=False) 

1175 

1176 # Now try to ingest again in "execution butler" mode where 

1177 # the registry entries exist but the datastore does not have 

1178 # the files. We also need to strip the dimension records to ensure 

1179 # that they will be re-added by the ingest. 

1180 ref = datasets[0].refs[0] 

1181 datasets[0].refs = [ 

1182 cast( 

1183 DatasetRef, 

1184 butler.find_dataset(ref.datasetType, data_id=ref.dataId, collections=ref.run), 

1185 ) 

1186 for ref in datasets[0].refs 

1187 ] 

1188 all_refs = [] 

1189 for dataset in datasets: 

1190 refs = [] 

1191 for ref in dataset.refs: 

1192 # Create a dict from the dataId to drop the records. 

1193 new_data_id = dict(ref.dataId.required) 

1194 new_ref = butler.find_dataset(ref.datasetType, new_data_id, collections=ref.run) 

1195 assert new_ref is not None 

1196 self.assertFalse(new_ref.dataId.hasRecords()) 

1197 refs.append(new_ref) 

1198 dataset.refs = refs 

1199 all_refs.extend(dataset.refs) 

1200 butler.pruneDatasets(all_refs, disassociate=False, unstore=True, purge=False) 

1201 

1202 # Use move mode to test that the file is deleted. Also 

1203 # disable recording of file size. 

1204 butler.ingest(*datasets, transfer="move", record_validation_info=False) 

1205 

1206 # Check that every ref now has records. 

1207 for dataset in datasets: 

1208 for ref in dataset.refs: 

1209 self.assertTrue(ref.dataId.hasRecords()) 

1210 

1211 # Ensure that the file has disappeared. 

1212 self.assertFalse(tempFile.exists()) 

1213 

1214 # Check that the datastore recorded no file size. 

1215 # Not all datastores can support this. 

1216 try: 

1217 infos = butler._datastore.getStoredItemsInfo(datasets[0].refs[0]) # type: ignore[attr-defined] 

1218 self.assertEqual(infos[0].file_size, -1) 

1219 except AttributeError: 

1220 pass 

1221 

1222 dataId1 = {"instrument": "DummyCamComp", "detector": 1, "visit": 424} 

1223 dataId2 = {"instrument": "DummyCamComp", "detector": 2, "visit": 424} 

1224 

1225 multi1 = butler.get(datasetTypeName, dataId1) 

1226 multi2 = butler.get(datasetTypeName, dataId2) 

1227 

1228 self.assertEqual(multi1, metrics1) 

1229 self.assertEqual(multi2, metrics2) 

1230 

1231 # Compare URIs 

1232 uri1 = butler.getURI(datasetTypeName, dataId1) 

1233 uri2 = butler.getURI(datasetTypeName, dataId2) 

1234 self.assertTrue(self.are_uris_equivalent(uri1, uri2), f"Cf. {uri1} with {uri2}") 

1235 

1236 # Test that removing one does not break the second 

1237 # This line will issue a warning log message for a ChainedDatastore 

1238 # that uses an InMemoryDatastore since in-memory can not ingest 

1239 # files. 

1240 butler.pruneDatasets([datasets[0].refs[0]], unstore=True, disassociate=False) 

1241 self.assertFalse(butler.exists(datasetTypeName, dataId1)) 

1242 self.assertTrue(butler.exists(datasetTypeName, dataId2)) 

1243 multi2b = butler.get(datasetTypeName, dataId2) 

1244 self.assertEqual(multi2, multi2b) 

1245 

1246 # Ensure we can ingest 0 datasets 

1247 datasets = [] 

1248 butler.ingest(*datasets) 

1249 

1250 def testPickle(self) -> None: 

1251 """Test pickle support.""" 

1252 butler = self.create_empty_butler(run=self.default_run) 

1253 assert isinstance(butler, DirectButler), "Expect DirectButler in configuration" 

1254 butlerOut = pickle.loads(pickle.dumps(butler)) 

1255 self.enterContext(butlerOut) 

1256 self.assertIsInstance(butlerOut, Butler) 

1257 self.assertEqual(butlerOut._config, butler._config) 

1258 self.assertEqual(list(butlerOut.collections.defaults), list(butler.collections.defaults)) 

1259 self.assertEqual(butlerOut.run, butler.run) 

1260 

1261 def testGetDatasetTypes(self) -> None: 

1262 butler = self.create_empty_butler(run=self.default_run) 

1263 dimensions = butler.dimensions.conform(["instrument", "visit", "physical_filter"]) 

1264 dimensionEntries: list[tuple[str, list[Mapping[str, Any]]]] = [ 

1265 ( 

1266 "instrument", 

1267 [ 

1268 {"instrument": "DummyCam"}, 

1269 {"instrument": "DummyHSC"}, 

1270 {"instrument": "DummyCamComp"}, 

1271 ], 

1272 ), 

1273 ("physical_filter", [{"instrument": "DummyCam", "name": "d-r", "band": "R"}]), 

1274 ("day_obs", [{"instrument": "DummyCam", "id": 20250101}]), 

1275 ( 

1276 "visit", 

1277 [ 

1278 { 

1279 "instrument": "DummyCam", 

1280 "id": 42, 

1281 "name": "fortytwo", 

1282 "physical_filter": "d-r", 

1283 "day_obs": 20250101, 

1284 } 

1285 ], 

1286 ), 

1287 ] 

1288 storageClass = self.storageClassFactory.getStorageClass("StructuredData") 

1289 # Add needed Dimensions 

1290 for element, data in dimensionEntries: 

1291 butler.registry.insertDimensionData(element, *data) 

1292 

1293 # When a DatasetType is added to the registry entries are not created 

1294 # for components but querying them can return the components. 

1295 datasetTypeNames = {"metric", "metric2", "metric4", "metric33", "pvi", "paramtest"} 

1296 components = set() 

1297 for datasetTypeName in datasetTypeNames: 

1298 # Create and register a DatasetType 

1299 self.addDatasetType(datasetTypeName, dimensions, storageClass, butler.registry) 

1300 

1301 for componentName in storageClass.components: 

1302 components.add(DatasetType.nameWithComponent(datasetTypeName, componentName)) 

1303 

1304 fromRegistry: set[DatasetType] = set() 

1305 for parent_dataset_type in butler.registry.queryDatasetTypes(): 

1306 fromRegistry.add(parent_dataset_type) 

1307 fromRegistry.update(parent_dataset_type.makeAllComponentDatasetTypes()) 

1308 self.assertEqual({d.name for d in fromRegistry}, datasetTypeNames | components) 

1309 

1310 # Query with wildcard. 

1311 dataset_types = butler.registry.queryDatasetTypes("metric*") 

1312 self.assertEqual(len(dataset_types), 4, f"Got: {dataset_types}") 

1313 # but not regex. 

1314 with self.assertRaises(DatasetTypeExpressionError): 

1315 butler.registry.queryDatasetTypes(["pvi", re.compile("metric.*")]) 

1316 

1317 # Now that we have some dataset types registered, validate them 

1318 butler.validateConfiguration( 

1319 ignore=[ 

1320 "test_metric_comp", 

1321 "metric3", 

1322 "metric5", 

1323 "calexp", 

1324 "DummySC", 

1325 "datasetType.component", 

1326 "random_data", 

1327 "random_data_2", 

1328 ] 

1329 ) 

1330 

1331 # Add a new datasetType that will fail template validation 

1332 self.addDatasetType("test_metric_comp", dimensions, storageClass, butler.registry) 

1333 if self.validationCanFail: 

1334 with self.assertRaises(ValidationError): 

1335 butler.validateConfiguration() 

1336 

1337 # Rerun validation but with a subset of dataset type names 

1338 butler.validateConfiguration(datasetTypeNames=["metric4"]) 

1339 

1340 # Rerun validation but ignore the bad datasetType 

1341 butler.validateConfiguration( 

1342 ignore=[ 

1343 "test_metric_comp", 

1344 "metric3", 

1345 "metric5", 

1346 "calexp", 

1347 "DummySC", 

1348 "datasetType.component", 

1349 "random_data", 

1350 "random_data_2", 

1351 ] 

1352 ) 

1353 

1354 def testTransaction(self) -> None: 

1355 butler = self.create_empty_butler(run=self.default_run) 

1356 datasetTypeName = "test_metric" 

1357 dimensions = butler.dimensions.conform(["instrument", "visit"]) 

1358 dimensionEntries: tuple[tuple[str, Mapping[str, Any]], ...] = ( 

1359 ("instrument", {"instrument": "DummyCam"}), 

1360 ("physical_filter", {"instrument": "DummyCam", "name": "d-r", "band": "R"}), 

1361 ("day_obs", {"instrument": "DummyCam", "id": 20250101}), 

1362 ( 

1363 "visit", 

1364 { 

1365 "instrument": "DummyCam", 

1366 "id": 42, 

1367 "name": "fortytwo", 

1368 "physical_filter": "d-r", 

1369 "day_obs": 20250101, 

1370 }, 

1371 ), 

1372 ) 

1373 storageClass = self.storageClassFactory.getStorageClass("StructuredData") 

1374 metric = makeExampleMetrics() 

1375 dataId = {"instrument": "DummyCam", "visit": 42} 

1376 # Create and register a DatasetType 

1377 datasetType = self.addDatasetType(datasetTypeName, dimensions, storageClass, butler.registry) 

1378 with self.assertRaises(TransactionTestError): 

1379 with butler.transaction(): 

1380 # Add needed Dimensions 

1381 for args in dimensionEntries: 

1382 butler.registry.insertDimensionData(*args) 

1383 # Store a dataset 

1384 ref = butler.put(metric, datasetTypeName, dataId) 

1385 self.assertIsInstance(ref, DatasetRef) 

1386 # Test get of a ref. 

1387 metricOut = butler.get(ref) 

1388 self.assertEqual(metric, metricOut) 

1389 # Test get 

1390 metricOut = butler.get(datasetTypeName, dataId) 

1391 self.assertEqual(metric, metricOut) 

1392 # Check we can get components 

1393 self.assertGetComponents(butler, ref, ("summary", "data", "output"), metric) 

1394 raise TransactionTestError("This should roll back the entire transaction") 

1395 with self.assertRaises(DataIdValueError, msg=f"Check can't expand DataId {dataId}"): 

1396 butler.registry.expandDataId(dataId) 

1397 # Should raise LookupError for missing data ID value 

1398 with self.assertRaises(LookupError, msg=f"Check can't get by {datasetTypeName} and {dataId}"): 

1399 butler.get(datasetTypeName, dataId) 

1400 # Also check explicitly if Dataset entry is missing 

1401 self.assertIsNone(butler.find_dataset(datasetType, dataId, collections=butler.collections.defaults)) 

1402 # Direct retrieval should not find the file in the Datastore 

1403 with self.assertRaises(FileNotFoundError, msg=f"Check {ref} can't be retrieved directly"): 

1404 butler.get(ref) 

1405 

1406 def testMakeRepo(self) -> None: 

1407 """Test that we can write butler configuration to a new repository via 

1408 the Butler.makeRepo interface and then instantiate a butler from the 

1409 repo root. 

1410 """ 

1411 # Do not run the test if we know this datastore configuration does 

1412 # not support a file system root 

1413 if self.fullConfigKey is None: 

1414 return 

1415 

1416 # create two separate directories 

1417 root1 = tempfile.mkdtemp(dir=self.root) 

1418 root2 = tempfile.mkdtemp(dir=self.root) 

1419 

1420 butlerConfig = Butler.makeRepo(root1, config=Config(self.configFile)) 

1421 limited = Config(self.configFile) 

1422 butler1 = Butler.from_config(butlerConfig) 

1423 self.enterContext(butler1) 

1424 assert isinstance(butler1, DirectButler), "Expect DirectButler in configuration" 

1425 butlerConfig = Butler.makeRepo(root2, standalone=True, config=Config(self.configFile)) 

1426 full = Config(self.tmpConfigFile) 

1427 butler2 = Butler.from_config(butlerConfig) 

1428 self.enterContext(butler2) 

1429 assert isinstance(butler2, DirectButler), "Expect DirectButler in configuration" 

1430 # Butlers should have the same configuration regardless of whether 

1431 # defaults were expanded. 

1432 self.assertEqual(butler1._config, butler2._config) 

1433 # Config files loaded directly should not be the same. 

1434 self.assertNotEqual(limited, full) 

1435 # Make sure "limited" doesn't have a few keys we know it should be 

1436 # inheriting from defaults. 

1437 self.assertIn(self.fullConfigKey, full) 

1438 self.assertNotIn(self.fullConfigKey, limited) 

1439 

1440 # Collections don't appear until something is put in them 

1441 collections1 = set(butler1.registry.queryCollections()) 

1442 self.assertEqual(collections1, set()) 

1443 self.assertEqual(set(butler2.registry.queryCollections()), collections1) 

1444 

1445 # Check that a config with no associated file name will not 

1446 # work properly with relocatable Butler repo 

1447 butlerConfig.configFile = None 

1448 with self.assertRaises(ValueError): 

1449 Butler.from_config(butlerConfig) 

1450 

1451 with self.assertRaises(FileExistsError): 

1452 Butler.makeRepo(self.root, standalone=True, config=Config(self.configFile), overwrite=False) 

1453 

1454 def testStringification(self) -> None: 

1455 butler = Butler.from_config(self.tmpConfigFile, run=self.default_run) 

1456 self.enterContext(butler) 

1457 butlerStr = str(butler) 

1458 

1459 if self.datastoreStr is not None: 

1460 for testStr in self.datastoreStr: 

1461 self.assertIn(testStr, butlerStr) 

1462 if self.registryStr is not None: 

1463 self.assertIn(self.registryStr, butlerStr) 

1464 

1465 datastoreName = butler._datastore.name 

1466 if self.datastoreName is not None: 

1467 for testStr in self.datastoreName: 

1468 self.assertIn(testStr, datastoreName) 

1469 

1470 def testButlerRewriteDataId(self) -> None: 

1471 """Test that dataIds can be rewritten based on dimension records.""" 

1472 butler = self.create_empty_butler(run=self.default_run) 

1473 

1474 storageClass = self.storageClassFactory.getStorageClass("StructuredDataDict") 

1475 datasetTypeName = "random_data" 

1476 

1477 # Create dimension records. 

1478 butler.registry.insertDimensionData("instrument", {"name": "DummyCamComp"}) 

1479 butler.registry.insertDimensionData( 

1480 "physical_filter", {"instrument": "DummyCamComp", "name": "d-r", "band": "R"} 

1481 ) 

1482 butler.registry.insertDimensionData( 

1483 "detector", {"instrument": "DummyCamComp", "id": 1, "full_name": "det1"} 

1484 ) 

1485 

1486 dimensions = butler.dimensions.conform(["instrument", "exposure"]) 

1487 datasetType = DatasetType(datasetTypeName, dimensions, storageClass) 

1488 butler.registry.registerDatasetType(datasetType) 

1489 

1490 n_exposures = 5 

1491 dayobs = 20210530 

1492 

1493 # Create records for multiple day_obs but same seq_num to test that 

1494 # we are constraining gets properly when day_obs/seq_num is used 

1495 # for an exposure. Second day is year in future but is not used. 

1496 for day_obs in (dayobs, dayobs + 1_00_00): 

1497 butler.registry.insertDimensionData("day_obs", {"instrument": "DummyCamComp", "id": day_obs}) 

1498 

1499 for i in range(n_exposures): 

1500 group_name = f"group_{day_obs}_{i}" 

1501 butler.registry.insertDimensionData( 

1502 "group", {"instrument": "DummyCamComp", "name": group_name} 

1503 ) 

1504 butler.registry.insertDimensionData( 

1505 "exposure", 

1506 { 

1507 "instrument": "DummyCamComp", 

1508 "id": day_obs + i, 

1509 "obs_id": f"exp_{day_obs}_{i}", 

1510 "seq_num": i, 

1511 "day_obs": day_obs, 

1512 "physical_filter": "d-r", 

1513 "group": group_name, 

1514 }, 

1515 ) 

1516 

1517 # Write some data. 

1518 for i in range(n_exposures): 

1519 metric = {"something": i, "other": "metric", "list": [2 * x for x in range(i)]} 

1520 

1521 # Use the seq_num for the put to test rewriting. 

1522 dataId = {"seq_num": i, "day_obs": dayobs, "instrument": "DummyCamComp", "physical_filter": "d-r"} 

1523 ref = butler.put(metric, datasetTypeName, dataId=dataId) 

1524 

1525 # Check that the exposure is correct in the dataId 

1526 self.assertEqual(ref.dataId["exposure"], dayobs + i) 

1527 

1528 # and check that we can get the dataset back with the same dataId 

1529 new_metric = butler.get(datasetTypeName, dataId=dataId) 

1530 self.assertEqual(new_metric, metric) 

1531 

1532 # Check that we can find the datasets using the day_obs or the 

1533 # exposure.day_obs. 

1534 datasets_1 = list( 

1535 butler.registry.queryDatasets( 

1536 datasetType, 

1537 collections=self.default_run, 

1538 where="day_obs = :dayObs AND instrument = :instr", 

1539 bind={"dayObs": dayobs, "instr": "DummyCamComp"}, 

1540 ) 

1541 ) 

1542 datasets_2 = list( 

1543 butler.registry.queryDatasets( 

1544 datasetType, 

1545 collections=self.default_run, 

1546 where="exposure.day_obs = :dayObs AND instrument = :instr", 

1547 bind={"dayObs": dayobs, "instr": "DummyCamComp"}, 

1548 ) 

1549 ) 

1550 self.assertEqual(datasets_1, datasets_2) 

1551 

1552 def testGetDatasetCollectionCaching(self): 

1553 # Prior to DM-41117, there was a bug where get_dataset would throw 

1554 # MissingCollectionError if you tried to fetch a dataset that was added 

1555 # after the collection cache was last updated. 

1556 reader_butler, datasetType = self.create_butler(self.default_run, "int", "datasettypename") 

1557 writer_butler = self.create_empty_butler(writeable=True, run="new_run") 

1558 dataId = {"instrument": "DummyCamComp", "visit": 423} 

1559 put_ref = writer_butler.put(123, datasetType, dataId) 

1560 get_ref = reader_butler.get_dataset(put_ref.id) 

1561 self.assertEqual(get_ref.id, put_ref.id) 

1562 # Also works when looking up via a hexadecimal string instead of a UUID 

1563 # instance. 

1564 hex_ref = reader_butler.get_dataset(put_ref.id.hex) 

1565 self.assertEqual(hex_ref.id, put_ref.id) 

1566 

1567 def testCollectionChainRedefine(self): 

1568 butler = self._setup_to_test_collection_chain() 

1569 

1570 butler.collections.redefine_chain("chain", "a") 

1571 self._check_chain(butler, ["a"]) 

1572 

1573 # Duplicates are removed from the list of children 

1574 butler.collections.redefine_chain("chain", ["c", "b", "c"]) 

1575 self._check_chain(butler, ["c", "b"]) 

1576 

1577 # Empty list clears the chain 

1578 butler.collections.redefine_chain("chain", []) 

1579 self._check_chain(butler, []) 

1580 

1581 self._test_common_chain_functionality(butler, butler.collections.redefine_chain) 

1582 

1583 def testCollectionChainPrepend(self): 

1584 butler = self._setup_to_test_collection_chain() 

1585 

1586 # Duplicates are removed from the list of children 

1587 butler.collections.prepend_chain("chain", ["c", "b", "c"]) 

1588 self._check_chain(butler, ["c", "b"]) 

1589 

1590 # Prepend goes on the front of existing chain 

1591 butler.collections.prepend_chain("chain", ["a"]) 

1592 self._check_chain(butler, ["a", "c", "b"]) 

1593 

1594 # Empty prepend does nothing 

1595 butler.collections.prepend_chain("chain", []) 

1596 self._check_chain(butler, ["a", "c", "b"]) 

1597 

1598 # Prepending children that already exist in the chain removes them from 

1599 # their current position. 

1600 butler.collections.prepend_chain("chain", ["d", "b", "c"]) 

1601 self._check_chain(butler, ["d", "b", "c", "a"]) 

1602 

1603 self._test_common_chain_functionality(butler, butler.collections.prepend_chain) 

1604 

1605 def testCollectionChainExtend(self): 

1606 butler = self._setup_to_test_collection_chain() 

1607 

1608 # Duplicates are removed from the list of children 

1609 butler.collections.extend_chain("chain", ["c", "b", "c"]) 

1610 self._check_chain(butler, ["c", "b"]) 

1611 

1612 # Extend goes on the end of existing chain 

1613 butler.collections.extend_chain("chain", ["a"]) 

1614 self._check_chain(butler, ["c", "b", "a"]) 

1615 

1616 # Empty extend does nothing 

1617 butler.collections.extend_chain("chain", []) 

1618 self._check_chain(butler, ["c", "b", "a"]) 

1619 

1620 # Extending children that already exist in the chain removes them from 

1621 # their current position. 

1622 butler.collections.extend_chain("chain", ["d", "b", "c"]) 

1623 self._check_chain(butler, ["a", "d", "b", "c"]) 

1624 

1625 self._test_common_chain_functionality(butler, butler.collections.extend_chain) 

1626 

1627 def testCollectionChainRemove(self) -> None: 

1628 butler = self._setup_to_test_collection_chain() 

1629 

1630 butler.collections.redefine_chain("chain", ["a", "b", "c", "d"]) 

1631 

1632 butler.collections.remove_from_chain("chain", "c") 

1633 self._check_chain(butler, ["a", "b", "d"]) 

1634 

1635 # Duplicates are allowed in the list of children 

1636 butler.collections.remove_from_chain("chain", ["b", "b", "a"]) 

1637 self._check_chain(butler, ["d"]) 

1638 

1639 # Empty remove does nothing 

1640 butler.collections.remove_from_chain("chain", []) 

1641 self._check_chain(butler, ["d"]) 

1642 

1643 # Removing children that aren't in the chain does nothing 

1644 butler.collections.remove_from_chain("chain", ["a", "chain"]) 

1645 self._check_chain(butler, ["d"]) 

1646 

1647 self._test_common_chain_functionality( 

1648 butler, butler.collections.remove_from_chain, skip_cycle_check=True 

1649 ) 

1650 

1651 def _setup_to_test_collection_chain(self) -> Butler: 

1652 butler = self.create_empty_butler(writeable=True) 

1653 

1654 butler.collections.register("chain", CollectionType.CHAINED) 

1655 

1656 runs = ["a", "b", "c", "d"] 

1657 for run in runs: 

1658 butler.collections.register(run) 

1659 

1660 butler.collections.register("staticchain", CollectionType.CHAINED) 

1661 butler.collections.redefine_chain("staticchain", ["a", "b"]) 

1662 

1663 return butler 

1664 

1665 def _check_chain(self, butler: Butler, expected: list[str]) -> None: 

1666 children = butler.collections.get_info("chain").children 

1667 self.assertEqual(expected, list(children)) 

1668 

1669 def _test_common_chain_functionality( 

1670 self, butler, func: Callable[[str, str | list[str]], Any], *, skip_cycle_check=False 

1671 ) -> None: 

1672 # Missing parent collection 

1673 with self.assertRaises(MissingCollectionError): 

1674 func("doesnotexist", []) 

1675 # Missing child collection 

1676 with self.assertRaises(MissingCollectionError): 

1677 func("chain", ["doesnotexist"]) 

1678 # Forbid operations on non-chained collections 

1679 with self.assertRaises(CollectionTypeError): 

1680 func("d", ["a"]) 

1681 

1682 # Prevent collection cycles 

1683 if not skip_cycle_check: 

1684 butler.collections.register("chain2", CollectionType.CHAINED) 

1685 func("chain2", "chain") 

1686 with self.assertRaises(CollectionCycleError): 

1687 func("chain", "chain2") 

1688 

1689 # Make sure none of the earlier operations interfered with unrelated 

1690 # chains. 

1691 self.assertEqual(["a", "b"], list(butler.collections.get_info("staticchain").children)) 

1692 

1693 with butler._caching_context(): 

1694 with self.assertRaisesRegex(RuntimeError, "Chained collection modification not permitted"): 

1695 func("chain", "a") 

1696 

1697 

1698class FileDatastoreButlerTests(ButlerTests): 

1699 """Common tests and specialization of ButlerTests for butlers backed 

1700 by datastores that inherit from FileDatastore. 

1701 """ 

1702 

1703 trustModeSupported = True 

1704 

1705 def checkFileExists(self, root: str | ResourcePath, relpath: str | ResourcePath) -> bool: 

1706 """Check if file exists at a given path (relative to root). 

1707 

1708 Test testPutTemplates verifies actual physical existance of the files 

1709 in the requested location. 

1710 """ 

1711 uri = ResourcePath(root, forceDirectory=True) 

1712 return uri.join(relpath).exists() 

1713 

1714 def testPutTemplates(self) -> None: 

1715 storageClass = self.storageClassFactory.getStorageClass("StructuredDataNoComponents") 

1716 butler = self.create_empty_butler(run=self.default_run) 

1717 

1718 # Add needed Dimensions 

1719 butler.registry.insertDimensionData("instrument", {"name": "DummyCamComp"}) 

1720 butler.registry.insertDimensionData( 

1721 "physical_filter", {"instrument": "DummyCamComp", "name": "d-r", "band": "R"} 

1722 ) 

1723 butler.registry.insertDimensionData("day_obs", {"instrument": "DummyCamComp", "id": 20250101}) 

1724 butler.registry.insertDimensionData( 

1725 "visit", 

1726 { 

1727 "instrument": "DummyCamComp", 

1728 "id": 423, 

1729 "name": "v423", 

1730 "physical_filter": "d-r", 

1731 "day_obs": 20250101, 

1732 }, 

1733 ) 

1734 butler.registry.insertDimensionData( 

1735 "visit", 

1736 { 

1737 "instrument": "DummyCamComp", 

1738 "id": 425, 

1739 "name": "v425", 

1740 "physical_filter": "d-r", 

1741 "day_obs": 20250101, 

1742 }, 

1743 ) 

1744 

1745 # Create and store a dataset 

1746 metric = makeExampleMetrics() 

1747 

1748 # Create two almost-identical DatasetTypes (both will use default 

1749 # template) 

1750 dimensions = butler.dimensions.conform(["instrument", "visit"]) 

1751 butler.registry.registerDatasetType(DatasetType("metric1", dimensions, storageClass)) 

1752 butler.registry.registerDatasetType(DatasetType("metric2", dimensions, storageClass)) 

1753 butler.registry.registerDatasetType(DatasetType("metric3", dimensions, storageClass)) 

1754 

1755 dataId1 = {"instrument": "DummyCamComp", "visit": 423} 

1756 dataId2 = {"instrument": "DummyCamComp", "visit": 423, "physical_filter": "d-r"} 

1757 

1758 # Put with exactly the data ID keys needed 

1759 ref = butler.put(metric, "metric1", dataId1) 

1760 uri = butler.getURI(ref) 

1761 self.assertTrue(uri.exists()) 

1762 self.assertTrue( 

1763 uri.unquoted_path.endswith(f"{self.default_run}/metric1/??#?/d-r/DummyCamComp_423.pickle") 

1764 ) 

1765 

1766 # Check the template based on dimensions 

1767 if hasattr(butler._datastore, "templates"): 

1768 butler._datastore.templates.validateTemplates([ref]) 

1769 

1770 # Put with extra data ID keys (physical_filter is an optional 

1771 # dependency); should not change template (at least the way we're 

1772 # defining them to behave now; the important thing is that they 

1773 # must be consistent). 

1774 ref = butler.put(metric, "metric2", dataId2) 

1775 uri = butler.getURI(ref) 

1776 self.assertTrue(uri.exists()) 

1777 self.assertTrue( 

1778 uri.unquoted_path.endswith(f"{self.default_run}/metric2/d-r/DummyCamComp_v423.pickle") 

1779 ) 

1780 

1781 # Check the template based on dimensions 

1782 if hasattr(butler._datastore, "templates"): 

1783 butler._datastore.templates.validateTemplates([ref]) 

1784 

1785 # Use a template that has a typo in dimension record metadata. 

1786 # Easier to test with a butler that has a ref with records attached. 

1787 template = FileTemplate("a/{visit.name}/{id}_{visit.namex:?}.fits") 

1788 with self.assertLogs("lsst.daf.butler.datastore.file_templates", "INFO"): 

1789 path = template.format(ref) 

1790 self.assertEqual(path, f"a/v423/{ref.id}_fits") 

1791 

1792 template = FileTemplate("a/{visit.name}/{id}_{visit.namex}.fits") 

1793 with self.assertRaises(KeyError): 

1794 with self.assertLogs("lsst.daf.butler.datastore.file_templates", "INFO"): 

1795 template.format(ref) 

1796 

1797 # Now use a file template that will not result in unique filenames 

1798 with self.assertRaises(FileTemplateValidationError): 

1799 butler.put(metric, "metric3", dataId1) 

1800 

1801 def testImportExport(self) -> None: 

1802 # Run put/get tests just to create and populate a repo. 

1803 storageClass = self.storageClassFactory.getStorageClass("StructuredDataNoComponents") 

1804 self.runImportExportTest(storageClass) 

1805 

1806 @unittest.expectedFailure 

1807 def testImportExportVirtualComposite(self) -> None: 

1808 # Run put/get tests just to create and populate a repo. 

1809 storageClass = self.storageClassFactory.getStorageClass("StructuredComposite") 

1810 self.runImportExportTest(storageClass) 

1811 

1812 def runImportExportTest(self, storageClass: StorageClass) -> None: 

1813 """Test exporting and importing. 

1814 

1815 This test does an export to a temp directory and an import back 

1816 into a new temp directory repo. It does not assume a posix datastore. 

1817 """ 

1818 exportButler = self.runPutGetTest(storageClass, "test_metric") 

1819 

1820 # Test that we must have a file extension. 

1821 with self.assertRaises(ValueError): 

1822 with exportButler.export(filename="dump", directory=".") as export: 

1823 pass 

1824 

1825 # Test that unknown format is not allowed. 

1826 with self.assertRaises(ValueError): 

1827 with exportButler.export(filename="dump.fits", directory=".") as export: 

1828 pass 

1829 

1830 # Test that the repo actually has at least one dataset. 

1831 datasets = list(exportButler.registry.queryDatasets(..., collections=...)) 

1832 self.assertGreater(len(datasets), 0) 

1833 # Add a DimensionRecord that's unused by those datasets. 

1834 skymapRecord = {"name": "example_skymap", "hash": (50).to_bytes(8, byteorder="little")} 

1835 exportButler.registry.insertDimensionData("skymap", skymapRecord) 

1836 # Export and then import datasets. 

1837 with safeTestTempDir(TESTDIR) as exportDir: 

1838 exportFile = os.path.join(exportDir, "exports.yaml") 

1839 with exportButler.export(filename=exportFile, directory=exportDir, transfer="auto") as export: 

1840 export.saveDatasets(datasets) 

1841 # Export the same datasets again. This should quietly do 

1842 # nothing because of internal deduplication, and it shouldn't 

1843 # complain about being asked to export the "htm7" elements even 

1844 # though there aren't any in these datasets or in the database. 

1845 export.saveDatasets(datasets, elements=["htm7"]) 

1846 # Save one of the data IDs again; this should be harmless 

1847 # because of internal deduplication. 

1848 export.saveDataIds([datasets[0].dataId]) 

1849 # Save some dimension records directly. 

1850 export.saveDimensionData("skymap", [skymapRecord]) 

1851 self.assertTrue(os.path.exists(exportFile)) 

1852 with safeTestTempDir(TESTDIR) as importDir: 

1853 # We always want this to be a local posix butler 

1854 Butler.makeRepo(importDir, config=Config(os.path.join(TESTDIR, "config/basic/butler.yaml"))) 

1855 # Calling script.butlerImport tests the implementation of the 

1856 # butler command line interface "import" subcommand. Functions 

1857 # in the script folder are generally considered protected and 

1858 # should not be used as public api. 

1859 with open(exportFile) as f: 

1860 script.butlerImport( 

1861 importDir, 

1862 export_file=f, 

1863 directory=exportDir, 

1864 transfer="auto", 

1865 skip_dimensions=None, 

1866 ) 

1867 importButler = Butler.from_config(importDir, run=self.default_run) 

1868 self.enterContext(importButler) 

1869 for ref in datasets: 

1870 with self.subTest(ref=repr(ref)): 

1871 # Test for existence by passing in the DatasetType and 

1872 # data ID separately, to avoid lookup by dataset_id. 

1873 self.assertTrue(importButler.exists(ref.datasetType, ref.dataId)) 

1874 self.assertEqual( 

1875 list(importButler.registry.queryDimensionRecords("skymap")), 

1876 [importButler.dimensions["skymap"].RecordClass(**skymapRecord)], 

1877 ) 

1878 

1879 def testRemoveRuns(self) -> None: 

1880 storageClass = self.storageClassFactory.getStorageClass("StructuredDataNoComponents") 

1881 butler = self.create_empty_butler(writeable=True) 

1882 # Load registry data with dimensions to hang datasets off of. 

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

1884 # Add some RUN-type collection. 

1885 run1 = "run1" 

1886 butler.collections.register(run1) 

1887 run2 = "run2" 

1888 butler.collections.register(run2) 

1889 # put a dataset in each 

1890 metric = makeExampleMetrics() 

1891 dimensions = butler.dimensions.conform(["instrument", "physical_filter"]) 

1892 datasetType = self.addDatasetType( 

1893 "prune_collections_test_dataset", dimensions, storageClass, butler.registry 

1894 ) 

1895 ref1 = butler.put(metric, datasetType, {"instrument": "Cam1", "physical_filter": "Cam1-G"}, run=run1) 

1896 ref2 = butler.put(metric, datasetType, {"instrument": "Cam1", "physical_filter": "Cam1-G"}, run=run2) 

1897 uri1 = butler.getURI(ref1) 

1898 uri2 = butler.getURI(ref2) 

1899 

1900 # Put one of the runs in a chain. 

1901 butler.collections.register("Chain", CollectionType.CHAINED) 

1902 butler.collections.extend_chain("Chain", run1) 

1903 

1904 with self.assertRaises(OrphanedRecordError): 

1905 butler.registry.removeDatasetType(datasetType.name) 

1906 

1907 # Remove a non-run. 

1908 with self.assertRaises(TypeError): 

1909 butler.removeRuns(["Chain"]) 

1910 

1911 # Remove without unlinking from chain should fail. 

1912 with self.assertRaises(IntegrityError): 

1913 butler.removeRuns([run1]) 

1914 

1915 # Remove from both runs. No longer use unstore parameter since it 

1916 # always purges. 

1917 butler.removeRuns([run1, run2], unlink_from_chains=True) 

1918 

1919 # Should be nothing in registry for either one, and datastore should 

1920 # not think either exists. 

1921 with self.assertRaises(MissingCollectionError): 

1922 butler.collections.get_info(run1) 

1923 with self.assertRaises(MissingCollectionError): 

1924 butler.collections.get_info(run1) 

1925 self.assertFalse(butler.stored(ref1)) 

1926 self.assertFalse(butler.stored(ref2)) 

1927 # We always unstore so both URIs should be gone. 

1928 self.assertFalse(uri1.exists()) 

1929 self.assertFalse(uri2.exists()) 

1930 

1931 # Now that the collections have been pruned we can remove the 

1932 # dataset type 

1933 butler.registry.removeDatasetType(datasetType.name) 

1934 

1935 with self.assertLogs("lsst.daf.butler.registry", "INFO") as cm: 

1936 butler.registry.removeDatasetType(("test*", "test*")) 

1937 self.assertIn("not defined", "\n".join(cm.output)) 

1938 

1939 def remove_dataset_out_of_band(self, butler: Butler, ref: DatasetRef) -> None: 

1940 """Simulate an external actor removing a file outside of Butler's 

1941 knowledge. 

1942 

1943 Subclasses may override to handle more complicated datastore 

1944 configurations. 

1945 """ 

1946 uri = butler.getURI(ref) 

1947 uri.remove() 

1948 datastore = cast(FileDatastore, butler._datastore) 

1949 datastore.cacheManager.remove_from_cache(ref) 

1950 

1951 def testPruneDatasets(self) -> None: 

1952 storageClass = self.storageClassFactory.getStorageClass("StructuredDataNoComponents") 

1953 butler = self.create_empty_butler(writeable=True) 

1954 # Load registry data with dimensions to hang datasets off of. 

1955 butler.import_(filename=_get_test_data_path("base.yaml")) 

1956 # Add some RUN-type collections. 

1957 run1 = "run1" 

1958 butler.collections.register(run1) 

1959 run2 = "run2" 

1960 butler.collections.register(run2) 

1961 # put some datasets. ref1 and ref2 have the same data ID, and are in 

1962 # different runs. ref3 has a different data ID. 

1963 metric = makeExampleMetrics() 

1964 dimensions = butler.dimensions.conform(["instrument", "physical_filter"]) 

1965 datasetType = self.addDatasetType( 

1966 "prune_collections_test_dataset", dimensions, storageClass, butler.registry 

1967 ) 

1968 ref1 = butler.put(metric, datasetType, {"instrument": "Cam1", "physical_filter": "Cam1-G"}, run=run1) 

1969 ref2 = butler.put(metric, datasetType, {"instrument": "Cam1", "physical_filter": "Cam1-G"}, run=run2) 

1970 ref3 = butler.put(metric, datasetType, {"instrument": "Cam1", "physical_filter": "Cam1-R1"}, run=run1) 

1971 

1972 many_stored = butler.stored_many([ref1, ref2, ref3]) 

1973 for ref, stored in many_stored.items(): 

1974 self.assertTrue(stored, f"Ref {ref} should be stored") 

1975 

1976 many_exists = butler._exists_many([ref1, ref2, ref3]) 

1977 for ref, exists in many_exists.items(): 

1978 self.assertTrue(exists, f"Checking ref {ref} exists.") 

1979 self.assertEqual(exists, DatasetExistence.VERIFIED, f"Ref {ref} should be stored") 

1980 

1981 # Simple prune. 

1982 butler.pruneDatasets([ref1, ref2, ref3], purge=True, unstore=True) 

1983 self.assertFalse(butler.exists(ref1.datasetType, ref1.dataId, collections=run1)) 

1984 

1985 many_stored = butler.stored_many([ref1, ref2, ref3]) 

1986 for ref, stored in many_stored.items(): 

1987 self.assertFalse(stored, f"Ref {ref} should not be stored") 

1988 

1989 many_exists = butler._exists_many([ref1, ref2, ref3]) 

1990 for ref, exists in many_exists.items(): 

1991 self.assertEqual(exists, DatasetExistence.UNRECOGNIZED, f"Ref {ref} should not be stored") 

1992 

1993 # Put data back. 

1994 ref1_new = butler.put(metric, ref1) 

1995 self.assertEqual(ref1_new, ref1) # Reuses original ID. 

1996 ref2 = butler.put(metric, ref2) 

1997 

1998 many_stored = butler.stored_many([ref1, ref2, ref3]) 

1999 self.assertTrue(many_stored[ref1]) 

2000 self.assertTrue(many_stored[ref2]) 

2001 self.assertFalse(many_stored[ref3]) 

2002 

2003 ref3 = butler.put(metric, ref3) 

2004 

2005 many_exists = butler._exists_many([ref1, ref2, ref3]) 

2006 for ref, exists in many_exists.items(): 

2007 self.assertTrue(exists, f"Ref {ref} should not be stored") 

2008 

2009 # Clear out the datasets from registry and start again. 

2010 refs = [ref1, ref2, ref3] 

2011 butler.pruneDatasets(refs, purge=True, unstore=True) 

2012 for ref in refs: 

2013 butler.put(metric, ref) 

2014 

2015 # Confirm we can retrieve deferred. 

2016 dref1 = butler.getDeferred(ref1) # known and exists 

2017 metric1 = dref1.get() 

2018 self.assertEqual(metric1, metric) 

2019 

2020 # Test different forms of file availability. 

2021 # Need to be in a state where: 

2022 # - one ref just has registry record. 

2023 # - one ref has a missing file but a datastore record. 

2024 # - one ref has a missing datastore record but file is there. 

2025 # - one ref does not exist anywhere. 

2026 # Do not need to test a ref that has everything since that is tested 

2027 # above. 

2028 ref0 = DatasetRef( 

2029 datasetType, 

2030 DataCoordinate.standardize( 

2031 {"instrument": "Cam1", "physical_filter": "Cam1-G"}, universe=butler.dimensions 

2032 ), 

2033 run=run1, 

2034 ) 

2035 

2036 # Delete from datastore and retain in Registry. 

2037 butler.pruneDatasets([ref1], purge=False, unstore=True, disassociate=False) 

2038 

2039 # File has been removed. 

2040 self.remove_dataset_out_of_band(butler, ref2) 

2041 

2042 # Datastore has lost track. 

2043 butler._datastore.forget([ref3]) 

2044 

2045 # First test with a standard butler. 

2046 exists_many = butler._exists_many([ref0, ref1, ref2, ref3], full_check=True) 

2047 self.assertEqual(exists_many[ref0], DatasetExistence.UNRECOGNIZED) 

2048 self.assertEqual(exists_many[ref1], DatasetExistence.RECORDED) 

2049 self.assertEqual(exists_many[ref2], DatasetExistence.RECORDED | DatasetExistence.DATASTORE) 

2050 self.assertEqual(exists_many[ref3], DatasetExistence.RECORDED) 

2051 

2052 exists_many = butler._exists_many([ref0, ref1, ref2, ref3], full_check=False) 

2053 self.assertEqual(exists_many[ref0], DatasetExistence.UNRECOGNIZED) 

2054 self.assertEqual(exists_many[ref1], DatasetExistence.RECORDED | DatasetExistence._ASSUMED) 

2055 self.assertEqual(exists_many[ref2], DatasetExistence.KNOWN) 

2056 self.assertEqual(exists_many[ref3], DatasetExistence.RECORDED | DatasetExistence._ASSUMED) 

2057 self.assertTrue(exists_many[ref2]) 

2058 

2059 # Check that per-ref query gives the same answer as many query. 

2060 for ref, exists in exists_many.items(): 

2061 self.assertEqual(butler.exists(ref, full_check=False), exists) 

2062 

2063 # Get deferred checks for existence before it allows it to be 

2064 # retrieved. 

2065 with self.assertRaises(LookupError): 

2066 butler.getDeferred(ref3) # not known, file exists 

2067 dref2 = butler.getDeferred(ref2) # known but file missing 

2068 with self.assertRaises(FileNotFoundError): 

2069 dref2.get() 

2070 

2071 # Test again with a trusting butler. 

2072 if self.trustModeSupported: 

2073 butler._datastore.trustGetRequest = True 

2074 exists_many = butler._exists_many([ref0, ref1, ref2, ref3], full_check=True) 

2075 self.assertEqual(exists_many[ref0], DatasetExistence.UNRECOGNIZED) 

2076 self.assertEqual(exists_many[ref1], DatasetExistence.RECORDED) 

2077 self.assertEqual(exists_many[ref2], DatasetExistence.RECORDED | DatasetExistence.DATASTORE) 

2078 self.assertEqual(exists_many[ref3], DatasetExistence.RECORDED | DatasetExistence._ARTIFACT) 

2079 

2080 # When trusting we can get a deferred dataset handle that is not 

2081 # known but does exist. 

2082 dref3 = butler.getDeferred(ref3) 

2083 metric3 = dref3.get() 

2084 self.assertEqual(metric3, metric) 

2085 

2086 # Check that per-ref query gives the same answer as many query. 

2087 for ref, exists in exists_many.items(): 

2088 self.assertEqual(butler.exists(ref, full_check=True), exists) 

2089 

2090 # Create a ref that surprisingly has the UUID of an existing ref 

2091 # but is not the same. 

2092 ref_bad = DatasetRef(datasetType, dataId=ref3.dataId, run=ref3.run, id=ref2.id) 

2093 with self.assertRaises(ValueError): 

2094 butler.exists(ref_bad) 

2095 

2096 # Create a ref that has a compatible storage class. 

2097 ref_compat = ref2.overrideStorageClass("StructuredDataDict") 

2098 exists = butler.exists(ref_compat) 

2099 self.assertEqual(exists, exists_many[ref2]) 

2100 

2101 # Remove everything and start from scratch. 

2102 butler._datastore.trustGetRequest = False 

2103 butler.pruneDatasets(refs, purge=True, unstore=True) 

2104 for ref in refs: 

2105 butler.put(metric, ref) 

2106 

2107 # These tests mess directly with the trash table and can leave the 

2108 # datastore in an odd state. Do them at the end. 

2109 # Check that in normal mode, deleting the record will lead to 

2110 # trash not touching the file. 

2111 uri1 = butler.getURI(ref1) 

2112 butler._datastore.bridge.moveToTrash( 

2113 [ref1], transaction=None 

2114 ) # Update the dataset_location table 

2115 butler._datastore.forget([ref1]) 

2116 butler._datastore.trash(ref1) 

2117 butler._datastore.emptyTrash() 

2118 self.assertTrue(uri1.exists()) 

2119 uri1.remove() # Clean it up. 

2120 

2121 # Simulate execution butler setup by deleting the datastore 

2122 # record but keeping the file around and trusting. 

2123 butler._datastore.trustGetRequest = True 

2124 uris = butler.get_many_uris([ref2, ref3]) 

2125 uri2 = uris[ref2].primaryURI 

2126 uri3 = uris[ref3].primaryURI 

2127 self.assertTrue(uri2.exists()) 

2128 self.assertTrue(uri3.exists()) 

2129 

2130 # Remove the datastore record. 

2131 butler._datastore.bridge.moveToTrash( 

2132 [ref2], transaction=None 

2133 ) # Update the dataset_location table 

2134 butler._datastore.forget([ref2]) 

2135 self.assertTrue(uri2.exists()) 

2136 butler._datastore.trash([ref2, ref3]) 

2137 # Immediate removal for ref2 file 

2138 self.assertFalse(uri2.exists()) 

2139 # But ref3 has to wait for the empty. 

2140 self.assertTrue(uri3.exists()) 

2141 butler._datastore.emptyTrash() 

2142 self.assertFalse(uri3.exists()) 

2143 

2144 # Clear out the datasets from registry. 

2145 butler.pruneDatasets([ref1, ref2, ref3], purge=True, unstore=True) 

2146 

2147 def test_butler_metrics(self): 

2148 """Test that metrics are collected.""" 

2149 run = "test_run" 

2150 metrics = ButlerMetrics() 

2151 butler, datasetType = self.create_butler( 

2152 run, "MetricsExampleModelProvenance", "prov_metric", metrics=metrics 

2153 ) 

2154 data = MetricsExampleModel( 

2155 summary={"AM1": 5.2, "AM2": 30.6}, 

2156 output={"a": [1, 2, 3], "b": {"blue": 5, "red": "green"}}, 

2157 data=[563, 234, 456.7, 752, 8, 9, 27], 

2158 ) 

2159 

2160 data_ref = butler.put(data, datasetType, visit=424, instrument="DummyCamComp") 

2161 butler.get(data_ref) 

2162 butler.get(data_ref) 

2163 self.assertEqual(metrics.n_get, 2) 

2164 self.assertGreater(metrics.time_in_get, 0.0) 

2165 self.assertEqual(metrics.n_put, 1) 

2166 self.assertGreater(metrics.time_in_put, 0.0) 

2167 

2168 deferred = butler.getDeferred(data_ref) 

2169 deferred.get() 

2170 self.assertEqual(metrics.n_get, 3) 

2171 

2172 with butler.record_metrics() as new: 

2173 butler.put(data, datasetType, visit=425, instrument="DummyCamComp") 

2174 butler.get(data_ref) 

2175 self.assertEqual(new.n_get, 1) 

2176 self.assertEqual(new.n_put, 1) 

2177 

2178 

2179class PosixDatastoreButlerTestCase(FileDatastoreButlerTests, unittest.TestCase): 

2180 """PosixDatastore specialization of a butler""" 

2181 

2182 configFile = os.path.join(TESTDIR, "config/basic/butler.yaml") 

2183 fullConfigKey: str | None = ".datastore.formatters" 

2184 validationCanFail = True 

2185 datastoreStr = ["/tmp"] 

2186 datastoreName = [f"FileDatastore@{BUTLER_ROOT_TAG}"] 

2187 registryStr = "/gen3.sqlite3" 

2188 

2189 def testPathConstructor(self) -> None: 

2190 """Independent test of constructor using PathLike.""" 

2191 butler = Butler.from_config(self.tmpConfigFile, run=self.default_run) 

2192 self.enterContext(butler) 

2193 self.assertIsInstance(butler, Butler) 

2194 

2195 # And again with a Path object with the butler yaml 

2196 path = pathlib.Path(self.tmpConfigFile) 

2197 butler = Butler.from_config(path, writeable=False) 

2198 self.enterContext(butler) 

2199 self.assertIsInstance(butler, Butler) 

2200 

2201 # And again with a Path object without the butler yaml 

2202 # (making sure we skip it if the tmp config doesn't end 

2203 # in butler.yaml -- which is the case for a subclass) 

2204 if self.tmpConfigFile.endswith("butler.yaml"): 

2205 path = pathlib.Path(os.path.dirname(self.tmpConfigFile)) 

2206 butler = Butler.from_config(path, writeable=False) 

2207 self.enterContext(butler) 

2208 self.assertIsInstance(butler, Butler) 

2209 

2210 def testExportTransferCopy(self) -> None: 

2211 """Test local export using all transfer modes""" 

2212 storageClass = self.storageClassFactory.getStorageClass("StructuredDataNoComponents") 

2213 exportButler = self.runPutGetTest(storageClass, "test_metric") 

2214 # Test that the repo actually has at least one dataset. 

2215 datasets = list(exportButler.registry.queryDatasets(..., collections=...)) 

2216 self.assertGreater(len(datasets), 0) 

2217 uris = [exportButler.getURI(d) for d in datasets] 

2218 assert isinstance(exportButler._datastore, FileDatastore) 

2219 datastoreRoot = exportButler.get_datastore_roots()[exportButler.get_datastore_names()[0]] 

2220 

2221 pathsInStore = [uri.relative_to(datastoreRoot) for uri in uris] 

2222 

2223 for path in pathsInStore: 

2224 # Assume local file system 

2225 assert path is not None 

2226 self.assertTrue(self.checkFileExists(datastoreRoot, path), f"Checking path {path}") 

2227 

2228 for transfer in ("copy", "link", "symlink", "relsymlink"): 

2229 with safeTestTempDir(TESTDIR) as exportDir: 

2230 with exportButler.export(directory=exportDir, format="yaml", transfer=transfer) as export: 

2231 export.saveDatasets(datasets) 

2232 for path in pathsInStore: 

2233 assert path is not None 

2234 self.assertTrue( 

2235 self.checkFileExists(exportDir, path), 

2236 f"Check that mode {transfer} exported files", 

2237 ) 

2238 

2239 def testPytypeCoercion(self) -> None: 

2240 """Test python type coercion on Butler.get and put.""" 

2241 # Store some data with the normal example storage class. 

2242 storageClass = self.storageClassFactory.getStorageClass("StructuredDataNoComponents") 

2243 datasetTypeName = "test_metric" 

2244 butler = self.runPutGetTest(storageClass, datasetTypeName) 

2245 

2246 dataId = {"instrument": "DummyCamComp", "visit": 423} 

2247 metric = butler.get(datasetTypeName, dataId=dataId) 

2248 self.assertEqual(get_full_type_name(metric), "lsst.daf.butler.tests.MetricsExample") 

2249 

2250 datasetType_ori = butler.get_dataset_type(datasetTypeName) 

2251 self.assertEqual(datasetType_ori.storageClass.name, "StructuredDataNoComponents") 

2252 

2253 # Now need to hack the registry dataset type definition. 

2254 # There is no API for this. 

2255 assert isinstance(butler._registry, SqlRegistry) 

2256 manager = butler._registry._managers.datasets 

2257 assert hasattr(manager, "_db") and hasattr(manager, "_static") 

2258 manager._db.update( 

2259 manager._static.dataset_type, 

2260 {"name": datasetTypeName}, 

2261 {datasetTypeName: datasetTypeName, "storage_class": "StructuredDataNoComponentsModel"}, 

2262 ) 

2263 

2264 # Force reset of dataset type cache 

2265 butler.registry.refresh() 

2266 

2267 datasetType_new = butler.get_dataset_type(datasetTypeName) 

2268 self.assertEqual(datasetType_new.name, datasetType_ori.name) 

2269 self.assertEqual(datasetType_new.storageClass.name, "StructuredDataNoComponentsModel") 

2270 

2271 metric_model = butler.get(datasetTypeName, dataId=dataId) 

2272 self.assertNotEqual(type(metric_model), type(metric)) 

2273 self.assertEqual(get_full_type_name(metric_model), "lsst.daf.butler.tests.MetricsExampleModel") 

2274 

2275 # Put the model and read it back to show that everything now 

2276 # works as normal. 

2277 metric_ref = butler.put(metric_model, datasetTypeName, dataId=dataId, visit=424) 

2278 metric_model_new = butler.get(metric_ref) 

2279 self.assertEqual(metric_model_new, metric_model) 

2280 

2281 # Hack the storage class again to something that will fail on the 

2282 # get with no conversion class. 

2283 manager._db.update( 

2284 manager._static.dataset_type, 

2285 {"name": datasetTypeName}, 

2286 {datasetTypeName: datasetTypeName, "storage_class": "StructuredDataListYaml"}, 

2287 ) 

2288 butler.registry.refresh() 

2289 

2290 with self.assertRaises(ValueError): 

2291 butler.get(datasetTypeName, dataId=dataId) 

2292 

2293 def test_provenance(self): 

2294 """Test that provenance is attached on put.""" 

2295 run = "test_run" 

2296 butler, datasetType = self.create_butler(run, "MetricsExampleModelProvenance", "prov_metric") 

2297 metric = MetricsExampleModel( 

2298 summary={"AM1": 5.2, "AM2": 30.6}, 

2299 output={"a": [1, 2, 3], "b": {"blue": 5, "red": "green"}}, 

2300 data=[563, 234, 456.7, 752, 8, 9, 27], 

2301 ) 

2302 # Provenance can be attached to the object being put. Whether 

2303 # it is or not is dependent on the formatter. For this test we 

2304 # copy on adding provenance to ensure they differ. 

2305 self.assertIsNone(metric.dataset_id) 

2306 metric_ref = butler.put(metric, datasetType, visit=424, instrument="DummyCamComp") 

2307 self.assertIsNone(metric.dataset_id) 

2308 metric_2 = butler.get(metric_ref) 

2309 self.assertEqual(metric_2.data, metric.data) 

2310 self.assertEqual(metric_2.dataset_id, metric_ref.id) 

2311 self.assertIsNone(metric_2.provenance) 

2312 

2313 # Put with provenance. 

2314 prov = DatasetProvenance(quantum_id=uuid.uuid4()) 

2315 prov.add_input(metric_ref) 

2316 prov.add_extra_provenance(metric_ref.id, {"answer": 42}) 

2317 metric_ref2 = butler.put(metric, datasetType, visit=423, instrument="DummyCamComp", provenance=prov) 

2318 metric_3 = butler.get(metric_ref2) 

2319 self.assertEqual(metric_3.provenance, prov) 

2320 

2321 # Check that we can extract provenance from dict form. 

2322 prov_dict = prov.to_flat_dict(metric_ref2) 

2323 prov_from_prov, ref_from_prov = DatasetProvenance.from_flat_dict(prov_dict, butler) 

2324 self.assertEqual(ref_from_prov, metric_ref2) 

2325 # Direct __eq__ of the provenance does not work because one side 

2326 # includes dimension records. 

2327 self.assertEqual({ref.id for ref in prov_from_prov.inputs}, {ref.id for ref in prov.inputs}) 

2328 self.assertEqual(prov_from_prov.quantum_id, prov.quantum_id) 

2329 self.assertEqual(prov_from_prov.extras, prov.extras) 

2330 

2331 # Force a bad ID into the dict. 

2332 prov_dict["id"] = uuid.uuid4() 

2333 with self.assertRaises(ValueError): 

2334 DatasetProvenance.from_flat_dict(prov_dict, butler) 

2335 del prov_dict["id"] 

2336 prov_dict["input 0 id"] = uuid.uuid4() 

2337 with self.assertRaises(ValueError): 

2338 DatasetProvenance.from_flat_dict(prov_dict, butler) 

2339 

2340 # Check that simple types can be reconstructed with non-standard 

2341 # separators. 

2342 prov_dict = prov.to_flat_dict(metric_ref2, prefix="XYZ", sep="😎", simple_types=True) 

2343 prov_from_prov, ref_from_prov = DatasetProvenance.from_flat_dict(prov_dict, butler) 

2344 self.assertEqual(ref_from_prov, metric_ref2) 

2345 self.assertEqual({ref.id for ref in prov_from_prov.inputs}, {ref.id for ref in prov.inputs}) 

2346 

2347 with self.assertRaises(ValueError): 

2348 DatasetProvenance.from_flat_dict({"unknown": 42}, butler) 

2349 

2350 def test_specialized_file_datasets_functions(self): 

2351 """Test a workflow used in Prompt Processing where we export datasets 

2352 from one repository and write them in-place to the datastore of 

2353 another, without immediately inserting registry entries for the 

2354 datasets. 

2355 """ 

2356 repo = MetricTestRepo.create_from_butler( 

2357 self.create_empty_butler(writeable=True), 

2358 self.tmpConfigFile, 

2359 "StructuredCompositeReadCompNoDisassembly", 

2360 ) 

2361 source_butler = repo.butler 

2362 

2363 # Test writing outputs to a FileDatastore. 

2364 with tempfile.TemporaryDirectory() as tempdir: 

2365 target_repo_config = Butler.makeRepo(tempdir) 

2366 refs = [repo.ref1, repo.ref2] 

2367 datasets = transfer_datasets_to_datastore(source_butler, ButlerConfig(target_repo_config), refs) 

2368 self.assertEqual(len(datasets), 2) 

2369 self.assertEqual({ref.id for ref in refs}, {dataset.refs[0].id for dataset in datasets}) 

2370 for dataset in datasets: 

2371 path = ResourcePath(dataset.path, forceAbsolute=False) 

2372 # Paths should be relative paths to the target datastore. 

2373 self.assertFalse(path.isabs()) 

2374 # Files should have been copied into the target datastore 

2375 self.assertTrue(ResourcePath(tempdir).join(path).exists()) 

2376 

2377 # Make sure the target Butler can ingest the datasets. 

2378 target_butler = Butler(target_repo_config, writeable=True) 

2379 self.enterContext(target_butler) 

2380 target_butler.transfer_dimension_records_from(source_butler, refs) 

2381 target_butler.ingest(*datasets, transfer=None) 

2382 self.assertIsNotNone(target_butler.get(repo.ref1)) 

2383 self.assertIsNotNone(target_butler.get(repo.ref2)) 

2384 

2385 # Giving an empty list of files is a no-op. 

2386 no_datasets = transfer_datasets_to_datastore(source_butler, ButlerConfig(target_repo_config), []) 

2387 self.assertEqual(len(no_datasets), 0) 

2388 

2389 # Test writing outputs to a ChainedDatastore. 

2390 with tempfile.TemporaryDirectory() as tempdir: 

2391 # Set up a second dataset type, so we can split the files across 

2392 # multiple datastore roots. 

2393 dt1 = repo.datasetType 

2394 dt2 = DatasetType("other", dt1.dimensions, dt1.storageClass) 

2395 source_butler.registry.registerDatasetType(dt2) 

2396 other_ref = repo.addDataset(repo.ref1.dataId, datasetType=dt2) 

2397 config = Config.fromString( 

2398 f""" 

2399 datastore: 

2400 cls: lsst.daf.butler.datastores.chainedDatastore.ChainedDatastore 

2401 datastore_constraints: 

2402 - constraints: 

2403 accept: 

2404 - {dt1.name} 

2405 - constraints: 

2406 accept: 

2407 - {dt2.name} 

2408 datastores: 

2409 - datastore: 

2410 cls: lsst.daf.butler.datastores.fileDatastore.FileDatastore 

2411 root: <butlerRoot>/FileDatastore_0 

2412 - datastore: 

2413 cls: lsst.daf.butler.datastores.fileDatastore.FileDatastore 

2414 root: <butlerRoot>/FileDatastore_1 

2415 """ 

2416 ) 

2417 target_repo_config = Butler.makeRepo(tempdir, config) 

2418 refs = [repo.ref1, repo.ref2, other_ref] 

2419 datasets = transfer_datasets_to_datastore(source_butler, ButlerConfig(target_repo_config), refs) 

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

2421 self.assertEqual({ref.id for ref in refs}, {dataset.refs[0].id for dataset in datasets}) 

2422 for dataset in datasets: 

2423 path = ResourcePath(dataset.path, forceAbsolute=False) 

2424 # Paths should be relative paths to the target datastore. 

2425 self.assertFalse(path.isabs()) 

2426 # Files should have been split up between the two datastores 

2427 # in the chain. 

2428 datastore_root = ResourcePath(tempdir) 

2429 if dataset.refs[0].datasetType.name == dt1.name: 

2430 datastore_root = datastore_root.join("FileDatastore_0") 

2431 else: 

2432 datastore_root = datastore_root.join("FileDatastore_1") 

2433 self.assertTrue(datastore_root.join(path).exists()) 

2434 

2435 # Make sure the target Butler can ingest the datasets. 

2436 target_butler = Butler(target_repo_config, writeable=True) 

2437 self.enterContext(target_butler) 

2438 target_butler.transfer_dimension_records_from(source_butler, refs) 

2439 target_butler.ingest(*datasets, transfer=None) 

2440 self.assertIsNotNone(target_butler.get(repo.ref1)) 

2441 self.assertIsNotNone(target_butler.get(repo.ref2)) 

2442 self.assertIsNotNone(target_butler.get(other_ref)) 

2443 

2444 

2445class PostgresPosixDatastoreButlerTestCase(FileDatastoreButlerTests, unittest.TestCase): 

2446 """PosixDatastore specialization of a butler using Postgres""" 

2447 

2448 configFile = os.path.join(TESTDIR, "config/basic/butler.yaml") 

2449 fullConfigKey = ".datastore.formatters" 

2450 validationCanFail = True 

2451 datastoreStr = ["/tmp"] 

2452 datastoreName = [f"FileDatastore@{BUTLER_ROOT_TAG}"] 

2453 registryStr = "PostgreSQL@test" 

2454 

2455 @classmethod 

2456 def setUpClass(cls) -> None: 

2457 cls.postgresql = cls.enterClassContext(setup_postgres_test_db()) 

2458 super().setUpClass() 

2459 

2460 def setUp(self) -> None: 

2461 # Need to add a registry section to the config. 

2462 self._temp_config = False 

2463 config = Config(self.configFile) 

2464 self.postgresql.patch_butler_config(config) 

2465 with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as fh: 

2466 config.dump(fh) 

2467 self.configFile = fh.name 

2468 self._temp_config = True 

2469 super().setUp() 

2470 

2471 def tearDown(self) -> None: 

2472 if self._temp_config and os.path.exists(self.configFile): 

2473 os.remove(self.configFile) 

2474 super().tearDown() 

2475 

2476 def testMakeRepo(self) -> None: 

2477 # The base class test assumes that it's using sqlite and assumes 

2478 # the config file is acceptable to sqlite. 

2479 raise unittest.SkipTest("Postgres config is not compatible with this test.") 

2480 

2481 

2482class ClonedPostgresPosixDatastoreButlerTestCase(PostgresPosixDatastoreButlerTestCase, unittest.TestCase): 

2483 """Test that Butler with a Postgres registry still works after cloning.""" 

2484 

2485 def create_butler( 

2486 self, 

2487 run: str, 

2488 storageClass: StorageClass | str, 

2489 datasetTypeName: str, 

2490 metrics: ButlerMetrics | None = None, 

2491 ) -> tuple[DirectButler, DatasetType]: 

2492 butler, datasetType = super().create_butler(run, storageClass, datasetTypeName, metrics=metrics) 

2493 return butler.clone(run=run, metrics=metrics), datasetType 

2494 

2495 

2496class InMemoryDatastoreButlerTestCase(ButlerTests, unittest.TestCase): 

2497 """InMemoryDatastore specialization of a butler""" 

2498 

2499 configFile = os.path.join(TESTDIR, "config/basic/butler-inmemory.yaml") 

2500 fullConfigKey = None 

2501 useTempRoot = False 

2502 validationCanFail = False 

2503 datastoreStr = ["datastore='InMemory"] 

2504 datastoreName = ["InMemoryDatastore@"] 

2505 registryStr = "/gen3.sqlite3" 

2506 

2507 def testIngest(self) -> None: 

2508 pass 

2509 

2510 def test_ingest_zip(self) -> None: 

2511 pass 

2512 

2513 

2514class ClonedSqliteButlerTestCase(InMemoryDatastoreButlerTestCase, unittest.TestCase): 

2515 """Test that a Butler with a Sqlite registry still works after cloning.""" 

2516 

2517 def create_butler( 

2518 self, 

2519 run: str, 

2520 storageClass: StorageClass | str, 

2521 datasetTypeName: str, 

2522 metrics: ButlerMetrics | None = None, 

2523 ) -> tuple[DirectButler, DatasetType]: 

2524 butler, datasetType = super().create_butler(run, storageClass, datasetTypeName, metrics=metrics) 

2525 return butler.clone(run=run), datasetType 

2526 

2527 

2528class ChainedDatastoreButlerTestCase(FileDatastoreButlerTests, unittest.TestCase): 

2529 """PosixDatastore specialization""" 

2530 

2531 configFile = os.path.join(TESTDIR, "config/basic/butler-chained.yaml") 

2532 fullConfigKey = ".datastore.datastores.1.formatters" 

2533 validationCanFail = True 

2534 datastoreStr = ["datastore='InMemory", "/FileDatastore_1/,", "/FileDatastore_2/'"] 

2535 datastoreName = [ 

2536 "InMemoryDatastore@", 

2537 f"FileDatastore@{BUTLER_ROOT_TAG}/FileDatastore_1", 

2538 "SecondDatastore", 

2539 ] 

2540 registryStr = "/gen3.sqlite3" 

2541 

2542 def testPruneDatasets(self) -> None: 

2543 # This test relies on manipulating files out-of-band, which is 

2544 # impossible for this configuration because of the InMemoryDatastore in 

2545 # the ChainedDatastore. 

2546 pass 

2547 

2548 

2549class ButlerExplicitRootTestCase(PosixDatastoreButlerTestCase): 

2550 """Test that a yaml file in one location can refer to a root in another.""" 

2551 

2552 datastoreStr = ["dir1"] 

2553 # Disable the makeRepo test since we are deliberately not using 

2554 # butler.yaml as the config name. 

2555 fullConfigKey = None 

2556 

2557 def setUp(self) -> None: 

2558 self.root = makeTestTempDir(TESTDIR) 

2559 

2560 # Make a new repository in one place 

2561 self.dir1 = os.path.join(self.root, "dir1") 

2562 Butler.makeRepo(self.dir1, config=Config(self.configFile)) 

2563 

2564 # Move the yaml file to a different place and add a "root" 

2565 self.dir2 = os.path.join(self.root, "dir2") 

2566 os.makedirs(self.dir2, exist_ok=True) 

2567 configFile1 = os.path.join(self.dir1, "butler.yaml") 

2568 config = Config(configFile1) 

2569 config["root"] = self.dir1 

2570 configFile2 = os.path.join(self.dir2, "butler2.yaml") 

2571 config.dumpToUri(configFile2) 

2572 os.remove(configFile1) 

2573 self.tmpConfigFile = configFile2 

2574 

2575 def testFileLocations(self) -> None: 

2576 self.assertNotEqual(self.dir1, self.dir2) 

2577 self.assertTrue(os.path.exists(os.path.join(self.dir2, "butler2.yaml"))) 

2578 self.assertFalse(os.path.exists(os.path.join(self.dir1, "butler.yaml"))) 

2579 self.assertTrue(os.path.exists(os.path.join(self.dir1, "gen3.sqlite3"))) 

2580 

2581 

2582class ButlerMakeRepoOutfileTestCase(ButlerPutGetTests, unittest.TestCase): 

2583 """Test that a config file created by makeRepo outside of repo works.""" 

2584 

2585 configFile = os.path.join(TESTDIR, "config/basic/butler.yaml") 

2586 

2587 def setUp(self) -> None: 

2588 self.root = makeTestTempDir(TESTDIR) 

2589 self.root2 = makeTestTempDir(TESTDIR) 

2590 

2591 self.tmpConfigFile = os.path.join(self.root2, "different.yaml") 

2592 Butler.makeRepo(self.root, config=Config(self.configFile), outfile=self.tmpConfigFile) 

2593 

2594 def tearDown(self) -> None: 

2595 if os.path.exists(self.root2): 

2596 shutil.rmtree(self.root2, ignore_errors=True) 

2597 super().tearDown() 

2598 

2599 def testConfigExistence(self) -> None: 

2600 c = Config(self.tmpConfigFile) 

2601 uri_config = ResourcePath(c["root"]) 

2602 uri_expected = ResourcePath(self.root, forceDirectory=True) 

2603 self.assertEqual(uri_config.geturl(), uri_expected.geturl()) 

2604 self.assertNotIn(":", uri_config.path, "Check for URI concatenated with normal path") 

2605 

2606 def testPutGet(self) -> None: 

2607 storageClass = self.storageClassFactory.getStorageClass("StructuredDataNoComponents") 

2608 self.runPutGetTest(storageClass, "test_metric") 

2609 

2610 

2611class ButlerMakeRepoOutfileDirTestCase(ButlerMakeRepoOutfileTestCase): 

2612 """Test that a config file created by makeRepo outside of repo works.""" 

2613 

2614 configFile = os.path.join(TESTDIR, "config/basic/butler.yaml") 

2615 

2616 def setUp(self) -> None: 

2617 self.root = makeTestTempDir(TESTDIR) 

2618 self.root2 = makeTestTempDir(TESTDIR) 

2619 

2620 self.tmpConfigFile = self.root2 

2621 Butler.makeRepo(self.root, config=Config(self.configFile), outfile=self.tmpConfigFile) 

2622 

2623 def testConfigExistence(self) -> None: 

2624 # Append the yaml file else Config constructor does not know the file 

2625 # type. 

2626 self.tmpConfigFile = os.path.join(self.tmpConfigFile, "butler.yaml") 

2627 super().testConfigExistence() 

2628 

2629 

2630class ButlerMakeRepoOutfileUriTestCase(ButlerMakeRepoOutfileTestCase): 

2631 """Test that a config file created by makeRepo outside of repo works.""" 

2632 

2633 configFile = os.path.join(TESTDIR, "config/basic/butler.yaml") 

2634 

2635 def setUp(self) -> None: 

2636 self.root = makeTestTempDir(TESTDIR) 

2637 self.root2 = makeTestTempDir(TESTDIR) 

2638 

2639 self.tmpConfigFile = ResourcePath(os.path.join(self.root2, "something.yaml")).geturl() 

2640 Butler.makeRepo(self.root, config=Config(self.configFile), outfile=self.tmpConfigFile) 

2641 

2642 

2643@unittest.skipIf(not boto3, "Warning: boto3 AWS SDK not found!") 

2644class S3DatastoreButlerTestCase(FileDatastoreButlerTests, unittest.TestCase): 

2645 """S3Datastore specialization of a butler; an S3 storage Datastore + 

2646 a local in-memory SqlRegistry. 

2647 """ 

2648 

2649 configFile = os.path.join(TESTDIR, "config/basic/butler-s3store.yaml") 

2650 fullConfigKey = None 

2651 validationCanFail = True 

2652 

2653 bucketName = "anybucketname" 

2654 """Name of the Bucket that will be used in the tests. The name is read from 

2655 the config file used with the tests during set-up. 

2656 """ 

2657 

2658 root = "butlerRoot/" 

2659 """Root repository directory expected to be used in case useTempRoot=False. 

2660 Otherwise the root is set to a 20 characters long randomly generated string 

2661 during set-up. 

2662 """ 

2663 

2664 datastoreStr = [f"datastore={root}"] 

2665 """Contains all expected root locations in a format expected to be 

2666 returned by Butler stringification. 

2667 """ 

2668 

2669 datastoreName = ["FileDatastore@s3://{bucketName}/{root}"] 

2670 """The expected format of the S3 Datastore string.""" 

2671 

2672 registryStr = "/gen3.sqlite3" 

2673 """Expected format of the Registry string.""" 

2674 

2675 mock_aws = mock_aws() 

2676 """The mocked s3 interface from moto.""" 

2677 

2678 def genRoot(self) -> str: 

2679 """Return a random string of len 20 to serve as a root 

2680 name for the temporary bucket repo. 

2681 

2682 This is equivalent to tempfile.mkdtemp as this is what self.root 

2683 becomes when useTempRoot is True. 

2684 """ 

2685 rndstr = "".join(random.choice(string.ascii_uppercase + string.digits) for _ in range(20)) 

2686 return rndstr + "/" 

2687 

2688 def setUp(self) -> None: 

2689 config = Config(self.configFile) 

2690 uri = ResourcePath(config[".datastore.datastore.root"]) 

2691 self.bucketName = uri.netloc 

2692 

2693 # Enable S3 mocking of tests. 

2694 self.enterContext(clean_test_environment_for_s3()) 

2695 self.mock_aws.start() 

2696 

2697 if self.useTempRoot: 

2698 self.root = self.genRoot() 

2699 rooturi = f"s3://{self.bucketName}/{self.root}" 

2700 config.update({"datastore": {"datastore": {"root": rooturi}}}) 

2701 

2702 # need local folder to store registry database 

2703 self.reg_dir = makeTestTempDir(TESTDIR) 

2704 config["registry", "db"] = f"sqlite:///{self.reg_dir}/gen3.sqlite3" 

2705 

2706 # MOTO needs to know that we expect Bucket bucketname to exist 

2707 # (this used to be the class attribute bucketName) 

2708 s3 = boto3.resource("s3") 

2709 s3.create_bucket(Bucket=self.bucketName) 

2710 

2711 self.datastoreStr = [f"datastore='{rooturi}'"] 

2712 self.datastoreName = [f"FileDatastore@{rooturi}"] 

2713 Butler.makeRepo(rooturi, config=config, forceConfigRoot=False) 

2714 self.tmpConfigFile = posixpath.join(rooturi, "butler.yaml") 

2715 

2716 def tearDown(self) -> None: 

2717 s3 = boto3.resource("s3") 

2718 bucket = s3.Bucket(self.bucketName) 

2719 try: 

2720 bucket.objects.all().delete() 

2721 except botocore.exceptions.ClientError as e: 

2722 if e.response["Error"]["Code"] == "404": 

2723 # the key was not reachable - pass 

2724 pass 

2725 else: 

2726 raise 

2727 

2728 bucket = s3.Bucket(self.bucketName) 

2729 bucket.delete() 

2730 

2731 # Stop the S3 mock. 

2732 self.mock_aws.stop() 

2733 

2734 if self.reg_dir is not None and os.path.exists(self.reg_dir): 

2735 shutil.rmtree(self.reg_dir, ignore_errors=True) 

2736 

2737 if self.useTempRoot and os.path.exists(self.root): 

2738 shutil.rmtree(self.root, ignore_errors=True) 

2739 

2740 super().tearDown() 

2741 

2742 

2743class DatastoreTransfers(TestCaseMixin): 

2744 """Base test setup for data transfers between butlers. The concrete tests 

2745 for specific configurations are in other classes, below. 

2746 """ 

2747 

2748 storageClassFactory: StorageClassFactory 

2749 

2750 @classmethod 

2751 def setUpClass(cls) -> None: 

2752 cls.storageClassFactory = StorageClassFactory() 

2753 

2754 def setUp(self) -> None: 

2755 self.root = makeTestTempDir(TESTDIR) 

2756 self.config = Config(self.configFile) 

2757 

2758 # Some tests cause convertors to be replaced so ensure 

2759 # the storage class factory is reset each time. 

2760 self.storageClassFactory.reset() 

2761 self.storageClassFactory.addFromConfig(self.configFile) 

2762 

2763 def tearDown(self) -> None: 

2764 removeTestTempDir(self.root) 

2765 

2766 def create_butler(self, manager: str | None, label: str, config_file: str | None = None) -> Butler: 

2767 if manager is None: 

2768 manager = ( 

2769 "lsst.daf.butler.registry.datasets.byDimensions.ByDimensionsDatasetRecordStorageManagerUUID" 

2770 ) 

2771 config = Config(config_file if config_file is not None else self.configFile) 

2772 config["registry", "managers", "datasets"] = manager 

2773 butler = Butler.from_config( 

2774 Butler.makeRepo(f"{self.root}/butler{label}", config=config), writeable=True 

2775 ) 

2776 self.enterContext(butler) 

2777 return butler 

2778 

2779 def assertButlerTransfers( 

2780 self, 

2781 purge: bool = False, 

2782 storageClassName: str = "StructuredData", 

2783 storageClassNameTarget: str | None = None, 

2784 ) -> None: 

2785 """Test that a run can be transferred to another butler.""" 

2786 storageClass = self.storageClassFactory.getStorageClass(storageClassName) 

2787 if storageClassNameTarget is not None: 

2788 storageClassTarget = self.storageClassFactory.getStorageClass(storageClassNameTarget) 

2789 else: 

2790 storageClassTarget = storageClass 

2791 

2792 datasetTypeName = "random_data" 

2793 

2794 # Test will create 3 collections and we will want to transfer 

2795 # two of those three. 

2796 runs = ["run1", "run2", "other"] 

2797 

2798 # Also want to use two different dataset types to ensure that 

2799 # grouping works. 

2800 datasetTypeNames = ["random_data", "random_data_2"] 

2801 

2802 # Create the run collections in the source butler. 

2803 for run in runs: 

2804 self.source_butler.collections.register(run) 

2805 

2806 # Create dimensions in source butler. 

2807 n_exposures = 30 

2808 self.source_butler.registry.insertDimensionData("instrument", {"name": "DummyCamComp"}) 

2809 self.source_butler.registry.insertDimensionData( 

2810 "physical_filter", {"instrument": "DummyCamComp", "name": "d-r", "band": "R"} 

2811 ) 

2812 self.source_butler.registry.insertDimensionData( 

2813 "detector", {"instrument": "DummyCamComp", "id": 1, "full_name": "det1"} 

2814 ) 

2815 self.source_butler.registry.insertDimensionData( 

2816 "day_obs", 

2817 { 

2818 "instrument": "DummyCamComp", 

2819 "id": 20250101, 

2820 }, 

2821 ) 

2822 

2823 for i in range(n_exposures): 

2824 self.source_butler.registry.insertDimensionData( 

2825 "group", {"instrument": "DummyCamComp", "name": f"group{i}"} 

2826 ) 

2827 self.source_butler.registry.insertDimensionData( 

2828 "exposure", 

2829 { 

2830 "instrument": "DummyCamComp", 

2831 "id": i, 

2832 "obs_id": f"exp{i}", 

2833 "physical_filter": "d-r", 

2834 "group": f"group{i}", 

2835 "day_obs": 20250101, 

2836 }, 

2837 ) 

2838 

2839 # Create dataset types in the source butler. 

2840 dimensions = self.source_butler.dimensions.conform(["instrument", "exposure"]) 

2841 for datasetTypeName in datasetTypeNames: 

2842 datasetType = DatasetType(datasetTypeName, dimensions, storageClass) 

2843 self.source_butler.registry.registerDatasetType(datasetType) 

2844 

2845 # Write a dataset to an unrelated run -- this will ensure that 

2846 # we are rewriting integer dataset ids in the target if necessary. 

2847 # Will not be relevant for UUID. 

2848 run = "distraction" 

2849 butler = Butler.from_config(butler=self.source_butler, run=run) 

2850 self.enterContext(butler) 

2851 butler.put( 

2852 makeExampleMetrics(), 

2853 datasetTypeName, 

2854 exposure=1, 

2855 instrument="DummyCamComp", 

2856 physical_filter="d-r", 

2857 ) 

2858 

2859 # Write some example metrics to the source 

2860 butler = Butler.from_config(butler=self.source_butler) 

2861 self.enterContext(butler) 

2862 

2863 # Set of DatasetRefs that should be in the list of refs to transfer 

2864 # but which will not be transferred. 

2865 deleted: set[DatasetRef] = set() 

2866 

2867 n_expected = 20 # Number of datasets expected to be transferred 

2868 source_refs = [] 

2869 for i in range(n_exposures): 

2870 # Put a third of datasets into each collection, only retain 

2871 # two thirds. 

2872 index = i % 3 

2873 run = runs[index] 

2874 datasetTypeName = datasetTypeNames[i % 2] 

2875 

2876 metric = MetricsExample( 

2877 summary={"counter": i}, output={"text": "metric"}, data=[2 * x for x in range(i)] 

2878 ) 

2879 dataId = {"exposure": i, "instrument": "DummyCamComp", "physical_filter": "d-r"} 

2880 ref = butler.put(metric, datasetTypeName, dataId=dataId, run=run) 

2881 

2882 # Remove the datastore record using low-level API, but only 

2883 # for a specific index. 

2884 if purge and index == 1: 

2885 # For one of these delete the file as well. 

2886 # This allows the "missing" code to filter the 

2887 # file out. 

2888 # Access the individual datastores. 

2889 datastores = [] 

2890 if hasattr(butler._datastore, "datastores"): 

2891 datastores.extend(butler._datastore.datastores) 

2892 else: 

2893 datastores.append(butler._datastore) 

2894 

2895 if not deleted: 

2896 # For a chained datastore we need to remove 

2897 # files in each chain. 

2898 for datastore in datastores: 

2899 # The file might not be known to the datastore 

2900 # if constraints are used. 

2901 try: 

2902 primary, uris = datastore.getURIs(ref) 

2903 except FileNotFoundError: 

2904 continue 

2905 if primary and primary.scheme != "mem": 

2906 primary.remove() 

2907 for uri in uris.values(): 

2908 if uri.scheme != "mem": 

2909 uri.remove() 

2910 n_expected -= 1 

2911 deleted.add(ref) 

2912 

2913 # Remove the datastore record. 

2914 for datastore in datastores: 

2915 if hasattr(datastore, "removeStoredItemInfo"): 

2916 datastore.removeStoredItemInfo(ref) 

2917 

2918 if index < 2: 

2919 source_refs.append(ref) 

2920 if ref not in deleted: 

2921 new_metric = butler.get(ref) 

2922 self.assertEqual(new_metric, metric) 

2923 

2924 # Create some bad dataset types to ensure we check for inconsistent 

2925 # definitions. 

2926 badStorageClass = self.storageClassFactory.getStorageClass("StructuredDataList") 

2927 for datasetTypeName in datasetTypeNames: 

2928 datasetType = DatasetType(datasetTypeName, dimensions, badStorageClass) 

2929 self.target_butler.registry.registerDatasetType(datasetType) 

2930 with self.assertRaises(ConflictingDefinitionError) as cm: 

2931 self.target_butler.transfer_from(self.source_butler, source_refs) 

2932 self.assertIn("dataset type differs", str(cm.exception)) 

2933 

2934 # And remove the bad definitions. 

2935 for datasetTypeName in datasetTypeNames: 

2936 self.target_butler.registry.removeDatasetType(datasetTypeName) 

2937 

2938 # Transfer without creating dataset types should fail. 

2939 with self.assertRaises(KeyError): 

2940 self.target_butler.transfer_from(self.source_butler, source_refs) 

2941 

2942 # Transfer without creating dimensions should fail. 

2943 with self.assertRaises(ConflictingDefinitionError) as cm: 

2944 self.target_butler.transfer_from(self.source_butler, source_refs, register_dataset_types=True) 

2945 self.assertIn("dimension", str(cm.exception)) 

2946 

2947 # The dry run test requires dataset types to exist. If we have 

2948 # been given distinct storage classes for the target we have 

2949 # to redefine at least one of the dataset types in the target butler. 

2950 if storageClass != storageClassTarget: 

2951 self.target_butler.registry.removeDatasetType(datasetTypeNames[0]) 

2952 datasetType = DatasetType(datasetTypeNames[0], dimensions, storageClassTarget) 

2953 self.target_butler.registry.registerDatasetType(datasetType) 

2954 

2955 # The failed transfer above leaves registry in an inconsistent 

2956 # state because the run is created but then rolled back without 

2957 # the collection cache being cleared. For now force a refresh. 

2958 # Can remove with DM-35498. 

2959 self.target_butler.registry.refresh() 

2960 

2961 # Do a dry run -- this should not have any effect on the target butler. 

2962 self.target_butler.transfer_from(self.source_butler, source_refs, dry_run=True) 

2963 

2964 # Transfer the records for one ref to test the alternative API. 

2965 with self.assertLogs(logger="lsst", level=logging.DEBUG) as log_cm: 

2966 self.target_butler.transfer_dimension_records_from(self.source_butler, [source_refs[0]]) 

2967 self.assertIn("number of records transferred: 1", ";".join(log_cm.output)) 

2968 

2969 # Now transfer them to the second butler, including dimensions. 

2970 with self.assertLogs(logger="lsst", level=logging.DEBUG) as log_cm: 

2971 transferred = self.target_butler.transfer_from( 

2972 self.source_butler, 

2973 source_refs, 

2974 register_dataset_types=True, 

2975 transfer_dimensions=True, 

2976 ) 

2977 self.assertEqual(len(transferred), n_expected) 

2978 log_output = ";".join(log_cm.output) 

2979 

2980 # A ChainedDatastore will use the in-memory datastore for mexists 

2981 # so we can not rely on the mexists log message. 

2982 self.assertIn("Number of datastore records found in source", log_output) 

2983 self.assertIn("Creating output run", log_output) 

2984 

2985 # Do the transfer twice to ensure that it will do nothing extra. 

2986 # Only do this if purge=True because it does not work for int 

2987 # dataset_id. 

2988 if purge: 

2989 # This should not need to register dataset types. 

2990 transferred = self.target_butler.transfer_from(self.source_butler, source_refs) 

2991 self.assertEqual(len(transferred), n_expected) 

2992 

2993 with self.assertRaises((TypeError, AttributeError)): 

2994 self.target_butler._datastore.transfer_from(self.source_butler, source_refs) # type: ignore 

2995 

2996 with self.assertRaises(ValueError): 

2997 self.target_butler._datastore.transfer_from( 

2998 self.source_butler._datastore, source_refs, transfer="split" 

2999 ) 

3000 

3001 # Now try to get the same refs from the new butler. 

3002 for ref in source_refs: 

3003 if ref not in deleted: 

3004 new_metric = self.target_butler.get(ref) 

3005 old_metric = self.source_butler.get(ref) 

3006 self.assertEqual(new_metric, old_metric) 

3007 

3008 # Try again without implicit storage class conversion 

3009 # triggered by using the source ref. This will do conversion 

3010 # since the formatter will be returning the source python type. 

3011 target_ref = self.target_butler.get_dataset(ref.id) 

3012 if target_ref.datasetType.storageClass != ref.datasetType.storageClass: 

3013 new_metric = self.target_butler.get(target_ref) 

3014 self.assertNotEqual(type(new_metric), type(old_metric)) 

3015 

3016 # Remove the dataset from the target and put it again 

3017 # as if it was the right type all along for this butler. 

3018 self.target_butler.pruneDatasets( 

3019 [target_ref], unstore=True, purge=True, disassociate=True 

3020 ) 

3021 self.target_butler.put(new_metric, target_ref) 

3022 new_new_metric = self.target_butler.get(target_ref) 

3023 new_old_metric = self.target_butler.get( 

3024 target_ref, storageClass=ref.datasetType.storageClass 

3025 ) 

3026 self.assertEqual(new_new_metric, new_metric) 

3027 self.assertEqual(new_old_metric, old_metric) 

3028 

3029 # Now prune run2 collection and create instead a CHAINED collection. 

3030 # This should block the transfer. 

3031 self.target_butler.removeRuns(["run2"]) 

3032 self.target_butler.collections.register("run2", CollectionType.CHAINED) 

3033 with self.assertRaises(CollectionTypeError): 

3034 # Re-importing the run1 datasets can be problematic if they 

3035 # use integer IDs so filter those out. 

3036 to_transfer = [ref for ref in source_refs if ref.run == "run2"] 

3037 self.target_butler.transfer_from(self.source_butler, to_transfer) 

3038 

3039 

3040class PosixDatastoreTransfers(DatastoreTransfers, unittest.TestCase): 

3041 """Test data transfers between butlers. 

3042 

3043 Test for different managers. UUID to UUID and integer to integer are 

3044 tested. UUID to integer is not supported since we do not currently 

3045 want to allow that. Integer to UUID is supported with the caveat 

3046 that UUID4 will be generated and this will be incorrect for raw 

3047 dataset types. The test ignores that. 

3048 """ 

3049 

3050 configFile = os.path.join(TESTDIR, "config/basic/butler.yaml") 

3051 

3052 def create_butlers( 

3053 self, manager1: str | None = None, manager2: str | None = None, source_config: str | None = None 

3054 ) -> None: 

3055 self.source_butler = self.create_butler(manager1, "1", config_file=source_config) 

3056 self.target_butler = self.create_butler(manager2, "2") 

3057 

3058 def testTransferUuidToUuid(self) -> None: 

3059 self.create_butlers() 

3060 self.assertButlerTransfers() 

3061 

3062 def testTransferFromChainedUuidToUuid(self) -> None: 

3063 """Force the source butler to be a ChainedDatastore.""" 

3064 self.create_butlers(source_config=os.path.join(TESTDIR, "config/basic/butler-chained.yaml")) 

3065 self.assertButlerTransfers() 

3066 

3067 def testTransferFromIncompatibleUuidToUuid(self) -> None: 

3068 """Force the source butler to be a incompatible datastore.""" 

3069 self.create_butlers(source_config=os.path.join(TESTDIR, "config/basic/butler-inmemory.yaml")) 

3070 with self.assertRaises(NotImplementedError): 

3071 self.assertButlerTransfers() 

3072 

3073 def testTransferFromIncompatibleChainUuidToUuid(self) -> None: 

3074 """Force the source butler to be a incompatible datastore.""" 

3075 self.create_butlers(source_config=os.path.join(TESTDIR, "config/basic/butler-inmemory-chain.yaml")) 

3076 with self.assertRaises(TypeError): 

3077 self.assertButlerTransfers() 

3078 

3079 def testTransferFromFileUuidToUuid(self) -> None: 

3080 """Force the source butler to be a FileDatastore.""" 

3081 self.create_butlers(source_config=os.path.join(TESTDIR, "config/basic/butler.yaml")) 

3082 self.assertButlerTransfers() 

3083 

3084 def testTransferMissing(self) -> None: 

3085 """Test transfers where datastore records are missing. 

3086 

3087 This is how execution butler works. 

3088 """ 

3089 self.create_butlers() 

3090 

3091 # Configure the source butler to allow trust. 

3092 self.source_butler._datastore._set_trust_mode(True) 

3093 

3094 self.assertButlerTransfers(purge=True) 

3095 

3096 def testTransferMissingDisassembly(self) -> None: 

3097 """Test transfers where datastore records are missing. 

3098 

3099 This is how execution butler works. 

3100 """ 

3101 self.create_butlers() 

3102 

3103 # Configure the source butler to allow trust. 

3104 self.source_butler._datastore._set_trust_mode(True) 

3105 

3106 # Test disassembly. 

3107 self.assertButlerTransfers(purge=True, storageClassName="StructuredComposite") 

3108 

3109 def testTransferDifferingStorageClasses(self) -> None: 

3110 """Test transfers when the source butler dataset type has a different 

3111 but compatible storage class. 

3112 """ 

3113 self.create_butlers() 

3114 

3115 self.assertButlerTransfers(storageClassNameTarget="MetricsConversion") 

3116 

3117 def testTransferDifferingStorageClassesDisassembly(self) -> None: 

3118 """Test transfers when the source butler dataset type has a different 

3119 but compatible storage class and where the source butler has 

3120 disassembled. 

3121 """ 

3122 self.create_butlers() 

3123 

3124 self.assertButlerTransfers( 

3125 storageClassName="StructuredComposite", storageClassNameTarget="MetricsConversion" 

3126 ) 

3127 

3128 def testAbsoluteURITransferDirect(self) -> None: 

3129 """Test transfer using an absolute URI.""" 

3130 self._absolute_transfer("auto") 

3131 

3132 def testAbsoluteURITransferCopy(self) -> None: 

3133 """Test transfer using an absolute URI.""" 

3134 self._absolute_transfer("copy") 

3135 

3136 def _absolute_transfer(self, transfer: str) -> None: 

3137 self.create_butlers() 

3138 

3139 storageClassName = "StructuredData" 

3140 storageClass = self.storageClassFactory.getStorageClass(storageClassName) 

3141 datasetTypeName = "random_data" 

3142 run = "run1" 

3143 self.source_butler.collections.register(run) 

3144 

3145 dimensions = self.source_butler.dimensions.conform(()) 

3146 datasetType = DatasetType(datasetTypeName, dimensions, storageClass) 

3147 self.source_butler.registry.registerDatasetType(datasetType) 

3148 

3149 metrics = makeExampleMetrics() 

3150 with ResourcePath.temporary_uri(suffix=".json") as temp: 

3151 dataId = DataCoordinate.make_empty(self.source_butler.dimensions) 

3152 source_refs = [DatasetRef(datasetType, dataId, run=run)] 

3153 temp.write(json.dumps(metrics.exportAsDict()).encode()) 

3154 dataset = FileDataset(path=temp, refs=source_refs) 

3155 self.source_butler.ingest(dataset, transfer="direct") 

3156 

3157 self.target_butler.transfer_from( 

3158 self.source_butler, dataset.refs, register_dataset_types=True, transfer=transfer 

3159 ) 

3160 

3161 uri = self.target_butler.getURI(dataset.refs[0]) 

3162 if transfer == "auto": 

3163 self.assertEqual(uri, temp) 

3164 else: 

3165 self.assertNotEqual(uri, temp) 

3166 

3167 def test_shared_dimension_group(self): 

3168 """Test internal logic that divides dataset types by dimension group 

3169 when doing registry updates. 

3170 """ 

3171 self.create_butlers() 

3172 self.source_butler.import_(filename=_get_test_data_path("base.yaml"), without_datastore=True) 

3173 self.source_butler.import_(filename=_get_test_data_path("datasets.yaml"), without_datastore=True) 

3174 

3175 source_butler = self.source_butler 

3176 target_butler = self.target_butler 

3177 

3178 # Create a dataset type with the same dimensions as the 'bias' dataset 

3179 # type from base.yaml 

3180 dataset_type = DatasetType( 

3181 "test_type", ["instrument", "detector"], "int", universe=source_butler.dimensions 

3182 ) 

3183 source_butler.registry.registerDatasetType(dataset_type) 

3184 # This has the same data ID as one of the bias datasets in 

3185 # datasets.yaml. 

3186 test_ref = source_butler.registry.insertDatasets( 

3187 "test_type", [{"instrument": "Cam1", "detector": 2}], run="imported_g" 

3188 )[0] 

3189 

3190 biases = source_butler.query_datasets("bias", ["imported_g", "imported_r"]) 

3191 flats = source_butler.query_datasets("flat", ["imported_g", "imported_r"]) 

3192 refs = [test_ref, *biases, *flats] 

3193 

3194 # Test setup will be even more convoluted if we want the datastore to 

3195 # actually transfer files. For testing the dimension group behavior, 

3196 # we really only care about the registry. 

3197 with unittest.mock.patch.object(target_butler._datastore, "transfer_from") as mock: 

3198 mock.return_value = (set(refs), set()) 

3199 target_butler.transfer_from( 

3200 source_butler, 

3201 refs, 

3202 transfer=None, 

3203 register_dataset_types=True, 

3204 skip_missing=False, 

3205 transfer_dimensions=True, 

3206 ) 

3207 

3208 transferred_test_ref = target_butler.find_dataset( 

3209 "test_type", {"instrument": "Cam1", "detector": 2}, collections="imported_g" 

3210 ) 

3211 self.assertEqual(transferred_test_ref.id, test_ref.id) 

3212 

3213 transferred_bias = target_butler.find_dataset( 

3214 "bias", {"instrument": "Cam1", "detector": 2}, collections="imported_g" 

3215 ) 

3216 self.assertEqual(transferred_bias.id, uuid.UUID("51352db4-a47a-447c-b12d-a50b206b17cd")) 

3217 

3218 transferred_flat = target_butler.find_dataset( 

3219 "flat", 

3220 {"instrument": "Cam1", "detector": 2, "physical_filter": "Cam1-R1", "band": "r"}, 

3221 collections="imported_r", 

3222 ) 

3223 self.assertEqual(transferred_flat.id, uuid.UUID("c1296796-56c5-4acf-9b49-40d920c6f840")) 

3224 

3225 

3226class ChainedDatastoreTransfers(PosixDatastoreTransfers): 

3227 """Test transfers using a chained datastore.""" 

3228 

3229 configFile = os.path.join(TESTDIR, "config/basic/butler-chained.yaml") 

3230 

3231 

3232@unittest.skipIf(not butler_server_is_available, butler_server_import_error) 

3233class ButlerServerDatastoreTransfers(DatastoreTransfers, unittest.TestCase): 

3234 """Test ``transfer_from`` involving Butler server.""" 

3235 

3236 configFile = os.path.join(TESTDIR, "config/basic/butler.yaml") 

3237 

3238 def test_transfers_from_remote_to_direct(self) -> None: 

3239 from lsst.daf.butler.remote_butler._remote_file_transfer_source import ( 

3240 mock_file_transfer_uris_for_unit_test, 

3241 ) 

3242 

3243 self.target_butler = self.create_butler(None, "2") 

3244 with create_test_server(TESTDIR) as server: 

3245 self.source_butler = server.hybrid_butler 

3246 

3247 def _remap_transfer_url(path: HttpResourcePath) -> HttpResourcePath: 

3248 # The Butler server returns HTTP URIs with a domain name that 

3249 # is not resolvable because there is no actual HTTP server 

3250 # involved in these tests. Strip this first layer of 

3251 # indirection, and return the target of the redirect instead. 

3252 response = server.client.get(str(path), follow_redirects=False, headers=path._extra_headers) 

3253 return ResourcePath(str(response.next_request.url)) 

3254 

3255 with mock_file_transfer_uris_for_unit_test(_remap_transfer_url): 

3256 self.assertButlerTransfers() 

3257 

3258 

3259class NullDatastoreTestCase(unittest.TestCase): 

3260 """Test that we can fall back to a null datastore.""" 

3261 

3262 # Need a good config to create the repo. 

3263 configFile = os.path.join(TESTDIR, "config/basic/butler.yaml") 

3264 storageClassFactory: StorageClassFactory 

3265 

3266 @classmethod 

3267 def setUpClass(cls) -> None: 

3268 cls.storageClassFactory = StorageClassFactory() 

3269 cls.storageClassFactory.addFromConfig(cls.configFile) 

3270 

3271 def setUp(self) -> None: 

3272 """Create a new butler root for each test.""" 

3273 self.root = makeTestTempDir(TESTDIR) 

3274 Butler.makeRepo(self.root, config=Config(self.configFile)) 

3275 

3276 def tearDown(self) -> None: 

3277 removeTestTempDir(self.root) 

3278 

3279 def test_fallback(self) -> None: 

3280 # Read the butler config and mess with the datastore section. 

3281 config_path = os.path.join(self.root, "butler.yaml") 

3282 bad_config = Config(config_path) 

3283 bad_config["datastore", "cls"] = "lsst.not.a.datastore.Datastore" 

3284 bad_config.dumpToUri(config_path) 

3285 

3286 with self.assertRaises(RuntimeError): 

3287 Butler(self.root, without_datastore=False) 

3288 

3289 with self.assertRaises(RuntimeError): 

3290 Butler.from_config(self.root, without_datastore=False) 

3291 

3292 butler = Butler.from_config(self.root, writeable=True, without_datastore=True) 

3293 self.enterContext(butler) 

3294 self.assertIsInstance(butler._datastore, NullDatastore) 

3295 

3296 # Check that registry is working. 

3297 butler.collections.register("MYRUN") 

3298 collections = butler.collections.query("*") 

3299 self.assertIn("MYRUN", set(collections)) 

3300 

3301 # Create a ref. 

3302 dimensions = butler.dimensions.conform([]) 

3303 storageClass = self.storageClassFactory.getStorageClass("StructuredDataDict") 

3304 datasetTypeName = "metric" 

3305 datasetType = DatasetType(datasetTypeName, dimensions, storageClass) 

3306 butler.registry.registerDatasetType(datasetType) 

3307 ref = DatasetRef(datasetType, {}, run="MYRUN") 

3308 

3309 # Check that datastore will complain. 

3310 with self.assertRaises(FileNotFoundError): 

3311 butler.get(ref) 

3312 with self.assertRaises(FileNotFoundError): 

3313 butler.getURI(ref) 

3314 

3315 

3316@unittest.skipIf(not butler_server_is_available, butler_server_import_error) 

3317class ButlerServerTests(FileDatastoreButlerTests): 

3318 """Test RemoteButler and Butler server.""" 

3319 

3320 configFile = None 

3321 predictionSupported = False 

3322 trustModeSupported = False 

3323 

3324 postgres: TemporaryPostgresInstance | None 

3325 

3326 def setUp(self): 

3327 self.server_instance = self.enterContext(create_test_server(TESTDIR)) 

3328 

3329 def tearDown(self): 

3330 pass 

3331 

3332 def are_uris_equivalent(self, uri1: ResourcePath, uri2: ResourcePath) -> bool: 

3333 # S3 pre-signed URLs may end up with differing expiration times in the 

3334 # query parameters, so ignore query parameters when comparing. 

3335 return uri1.scheme == uri2.scheme and uri1.netloc == uri2.netloc and uri1.path == uri2.path 

3336 

3337 def create_empty_butler( 

3338 self, 

3339 run: str | None = None, 

3340 writeable: bool | None = None, 

3341 metrics: ButlerMetrics | None = None, 

3342 cleanup: bool = True, 

3343 ) -> Butler: 

3344 return self.server_instance.hybrid_butler.clone(run=run, metrics=metrics) 

3345 

3346 def remove_dataset_out_of_band(self, butler: Butler, ref: DatasetRef) -> None: 

3347 # Can't delete a file via S3 signed URLs, so we need to reach in 

3348 # through DirectButler to delete the dataset. 

3349 uri = self.server_instance.direct_butler.getURI(ref) 

3350 uri.remove() 

3351 

3352 def testConstructor(self): 

3353 # RemoteButler constructor is tested in test_server.py and 

3354 # test_remote_butler.py. 

3355 pass 

3356 

3357 def testDafButlerRepositories(self): 

3358 # Loading of RemoteButler via repository index is tested in 

3359 # test_server.py. 

3360 pass 

3361 

3362 def testGetDatasetTypes(self) -> None: 

3363 # This is mostly a test of validateConfiguration, which is for 

3364 # validating Datastore configuration and thus isn't relevant to 

3365 # RemoteButler. 

3366 pass 

3367 

3368 def testMakeRepo(self) -> None: 

3369 # Only applies to DirectButler. 

3370 pass 

3371 

3372 # Pickling not yet implemented for RemoteButler/HybridButler. 

3373 @unittest.expectedFailure 

3374 def testPickle(self) -> None: 

3375 return super().testPickle() 

3376 

3377 def testStringification(self) -> None: 

3378 self.assertEqual( 

3379 str(self.server_instance.remote_butler), 

3380 "RemoteButler(https://test.example/api/butler/repo/testrepo/)", 

3381 ) 

3382 

3383 def testTransaction(self) -> None: 

3384 # Transactions will never be supported for RemoteButler. 

3385 pass 

3386 

3387 def testPutTemplates(self) -> None: 

3388 # The Butler server instance is configured with different file naming 

3389 # templates than this test is expecting. 

3390 pass 

3391 

3392 

3393@unittest.skipIf(not butler_server_is_available, butler_server_import_error) 

3394class ButlerServerSqliteTests(ButlerServerTests, unittest.TestCase): 

3395 """Tests for RemoteButler's registry shim, with a SQLite DB backing the 

3396 server. 

3397 """ 

3398 

3399 postgres = None 

3400 

3401 

3402@unittest.skipIf(not butler_server_is_available, butler_server_import_error) 

3403class ButlerServerPostgresTests(ButlerServerTests, unittest.TestCase): 

3404 """Tests for RemoteButler's registry shim, with a Postgres DB backing the 

3405 server. 

3406 """ 

3407 

3408 @classmethod 

3409 def setUpClass(cls): 

3410 cls.postgres = cls.enterClassContext(setup_postgres_test_db()) 

3411 super().setUpClass() 

3412 

3413 

3414def setup_module(module: types.ModuleType) -> None: 

3415 """Set up the module for pytest.""" 

3416 clean_environment() 

3417 

3418 

3419def _get_test_data_path(filename: str) -> ResourcePath: 

3420 return ResourcePath(f"resource://lsst.daf.butler/tests/registry_data/{filename}") 

3421 

3422 

3423if __name__ == "__main__": 

3424 clean_environment() 

3425 unittest.main()