Coverage for tests/test_butler.py: 14%

1700 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-20 01:50 -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 

46from collections.abc import Callable, Mapping 

47from typing import TYPE_CHECKING, Any, cast 

48 

49try: 

50 import boto3 

51 import botocore 

52 

53 from lsst.resources.s3utils import clean_test_environment_for_s3 

54 

55 try: 

56 from moto import mock_aws # v5 

57 except ImportError: 

58 from moto import mock_s3 as mock_aws 

59except ImportError: 

60 boto3 = None 

61 

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

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

64 return None 

65 

66 

67import astropy.time 

68from sqlalchemy.exc import IntegrityError 

69 

70from lsst.daf.butler import ( 

71 Butler, 

72 ButlerConfig, 

73 ButlerMetrics, 

74 ButlerRepoIndex, 

75 CollectionCycleError, 

76 CollectionType, 

77 Config, 

78 DataCoordinate, 

79 DatasetExistence, 

80 DatasetNotFoundError, 

81 DatasetProvenance, 

82 DatasetRef, 

83 DatasetType, 

84 FileDataset, 

85 NoDefaultCollectionError, 

86 StorageClassFactory, 

87 ValidationError, 

88 script, 

89) 

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

91from lsst.daf.butler.datastore import NullDatastore 

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

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

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

95from lsst.daf.butler.direct_butler import DirectButler 

96from lsst.daf.butler.registry import ( 

97 CollectionError, 

98 CollectionTypeError, 

99 ConflictingDefinitionError, 

100 DataIdValueError, 

101 DatasetTypeExpressionError, 

102 MissingCollectionError, 

103 OrphanedRecordError, 

104) 

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

106from lsst.daf.butler.repo_relocation import BUTLER_ROOT_TAG 

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

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

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

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

111 MetricTestRepo, 

112 TestCaseMixin, 

113 makeTestTempDir, 

114 removeTestTempDir, 

115 safeTestTempDir, 

116) 

117from lsst.resources import ResourcePath 

118from lsst.resources.http import HttpResourcePath 

119from lsst.utils import doImportType 

120from lsst.utils.introspection import get_full_type_name 

121 

122if butler_server_is_available: 

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

124 

125 

126if TYPE_CHECKING: 

127 import types 

128 

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

130 

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

132 

133 

134def clean_environment() -> None: 

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

136 for k in ("DAF_BUTLER_REPOSITORY_INDEX",): 

137 os.environ.pop(k, None) 

138 

139 

140def makeExampleMetrics() -> MetricsExample: 

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

142 return MetricsExample( 

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

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

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

146 ) 

147 

148 

149class TransactionTestError(Exception): 

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

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

152 """ 

153 

154 pass 

155 

156 

157class ButlerConfigTests(unittest.TestCase): 

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

159 cases. 

160 """ 

161 

162 def testSearchPath(self) -> None: 

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

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

165 config1 = ButlerConfig(configFile) 

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

167 

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

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

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

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

172 

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

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

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

176 

177 

178class ButlerPutGetTests(TestCaseMixin): 

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

180 butler configurations. 

181 """ 

182 

183 root: str 

184 default_run = "ingésτ😺" 

185 storageClassFactory: StorageClassFactory 

186 configFile: str | None 

187 tmpConfigFile: str 

188 

189 @staticmethod 

190 def addDatasetType( 

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

192 ) -> DatasetType: 

193 """Create a DatasetType and register it""" 

194 datasetType = DatasetType(datasetTypeName, dimensions, storageClass) 

195 registry.registerDatasetType(datasetType) 

196 return datasetType 

197 

198 @classmethod 

199 def setUpClass(cls) -> None: 

200 cls.storageClassFactory = StorageClassFactory() 

201 if cls.configFile is not None: 

202 cls.storageClassFactory.addFromConfig(cls.configFile) 

203 

204 def assertGetComponents( 

205 self, 

206 butler: Butler, 

207 datasetRef: DatasetRef, 

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

209 reference: Any, 

210 collections: Any = None, 

211 ) -> None: 

212 datasetType = datasetRef.datasetType 

213 dataId = datasetRef.dataId 

214 deferred = butler.getDeferred(datasetRef) 

215 

216 for component in components: 

217 compTypeName = datasetType.componentTypeName(component) 

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

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

220 result_deferred = deferred.get(component=component) 

221 self.assertEqual(result_deferred, result) 

222 

223 def tearDown(self) -> None: 

224 if self.root is not None: 

225 removeTestTempDir(self.root) 

226 

227 def create_empty_butler( 

228 self, run: str | None = None, writeable: bool | None = None, metrics: ButlerMetrics | None = None 

229 ): 

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

231 data. 

232 """ 

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

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

235 return butler 

236 

237 def create_butler( 

238 self, 

239 run: str, 

240 storageClass: StorageClass | str, 

241 datasetTypeName: str, 

242 metrics: ButlerMetrics | None = None, 

243 ) -> tuple[Butler, DatasetType]: 

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

245 into it. 

246 """ 

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

248 

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

250 self.assertEqual(collections, {run}) 

251 # Create and register a DatasetType 

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

253 

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

255 

256 # Add needed Dimensions 

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

258 butler.registry.insertDimensionData( 

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

260 ) 

261 butler.registry.insertDimensionData( 

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

263 ) 

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

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

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

267 butler.registry.insertDimensionData( 

268 "visit", 

269 { 

270 "instrument": "DummyCamComp", 

271 "id": 423, 

272 "name": "fourtwentythree", 

273 "physical_filter": "d-r", 

274 "datetime_begin": visit_start, 

275 "datetime_end": visit_end, 

276 "day_obs": 20200101, 

277 }, 

278 ) 

279 

280 # Add more visits for some later tests 

281 for visit_id in (424, 425): 

282 butler.registry.insertDimensionData( 

283 "visit", 

284 { 

285 "instrument": "DummyCamComp", 

286 "id": visit_id, 

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

288 "physical_filter": "d-r", 

289 "day_obs": 20200101, 

290 }, 

291 ) 

292 return butler, datasetType 

293 

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

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

296 # tag when looking up datasets. 

297 run = self.default_run 

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

299 assert butler.run is not None 

300 

301 # Create and store a dataset 

302 metric = makeExampleMetrics() 

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

304 

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

306 with self.assertRaises(DatasetNotFoundError): 

307 butler.get(datasetTypeName, dataId) 

308 

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

310 # and once with a DatasetType 

311 

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

313 expected_collections = {run} 

314 

315 counter = 0 

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

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

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

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

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

321 # immediately because the dataset already exists. Work around 

322 # this by using a distinct run collection each time 

323 counter += 1 

324 this_run = f"put_run_{counter}" 

325 butler.collections.register(this_run) 

326 expected_collections.update({this_run}) 

327 

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

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

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

331 kwargs["run"] = this_run 

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

333 self.assertIsInstance(ref, DatasetRef) 

334 

335 # Test get of a ref. 

336 metricOut = butler.get(ref) 

337 self.assertEqual(metric, metricOut) 

338 # Test get 

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

340 self.assertEqual(metric, metricOut) 

341 # Test get with a datasetRef 

342 metricOut = butler.get(ref) 

343 self.assertEqual(metric, metricOut) 

344 # Test getDeferred with dataId 

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

346 self.assertEqual(metric, metricOut) 

347 # Test getDeferred with a ref 

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

349 self.assertEqual(metric, metricOut) 

350 

351 # Check we can get components 

352 if storageClass.isComposite(): 

353 self.assertGetComponents( 

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

355 ) 

356 

357 primary_uri, secondary_uris = butler.getURIs(ref) 

358 n_uris = len(secondary_uris) 

359 if primary_uri: 

360 n_uris += 1 

361 

362 # Can the artifacts themselves be retrieved? 

363 if not butler._datastore.isEphemeral: 

364 # Create a temporary directory to hold the retrieved 

365 # artifacts. 

366 with tempfile.TemporaryDirectory( 

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

368 ) as artifact_root: 

369 root_uri = ResourcePath(artifact_root, forceDirectory=True) 

370 

371 for preserve_path in (True, False): 

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

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

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

375 # Use copy so that we can test that overwrite 

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

377 # would use hard links and subsequent transfer 

378 # would work because it knows they are the same 

379 # file). 

380 transferred = butler.retrieveArtifacts( 

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

382 ) 

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

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

385 # Filter out the index file. 

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

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

388 

389 for artifact in transferred: 

390 path_in_destination = artifact.relative_to(destination) 

391 self.assertIsNotNone(path_in_destination) 

392 assert path_in_destination is not None 

393 

394 # When path is not preserved there should not 

395 # be any path separators. 

396 num_seps = path_in_destination.count("/") 

397 if preserve_path: 

398 self.assertGreater(num_seps, 0) 

399 else: 

400 self.assertEqual(num_seps, 0) 

401 

402 self.assertEqual( 

403 len(artifacts), 

404 n_uris, 

405 "Comparing expected artifacts vs actual:" 

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

407 ) 

408 

409 if preserve_path: 

410 # No need to run these twice 

411 with self.assertRaises(ValueError): 

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

413 

414 with self.assertRaisesRegex( 

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

416 ): 

417 butler.retrieveArtifacts( 

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

419 ) 

420 

421 with self.assertRaises(FileExistsError): 

422 butler.retrieveArtifacts([ref], destination) 

423 

424 transferred_again = butler.retrieveArtifacts( 

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

426 ) 

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

428 

429 # Now remove the dataset completely. 

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

431 # Lookup with original args should still fail. 

432 kwargs = {"collections": this_run} 

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

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

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

436 # get() should still fail. 

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

438 butler.get(ref) 

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

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

441 

442 # Do explicit registry removal since we know they are 

443 # empty 

444 butler.collections.x_remove(this_run) 

445 expected_collections.remove(this_run) 

446 

447 # Create DatasetRef for put using default run. 

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

449 

450 # Check that getDeferred fails with standalone ref. 

451 with self.assertRaises(LookupError): 

452 butler.getDeferred(refIn) 

453 

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

455 # and we want to use the default collection. 

456 ref = butler.put(metric, refIn) 

457 

458 # Get with parameters 

459 stop = 4 

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

461 self.assertNotEqual(metric, sliced) 

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

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

464 assert metric.data is not None # for mypy 

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

466 # getDeferred with parameters 

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

468 self.assertNotEqual(metric, sliced) 

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

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

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

472 # getDeferred with deferred parameters 

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

474 self.assertNotEqual(metric, sliced) 

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

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

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

478 

479 if storageClass.isComposite(): 

480 # Check that components can be retrieved 

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

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

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

484 summary = butler.get(compNameS, dataId) 

485 self.assertEqual(summary, metric.summary) 

486 data = butler.get(compNameD, dataId) 

487 self.assertEqual(data, metric.data) 

488 

489 if "counter" in storageClass.derivedComponents: 

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

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

492 

493 count = butler.get( 

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

495 ) 

496 self.assertEqual(count, stop) 

497 

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

499 assert compRef is not None 

500 summary = butler.get(compRef) 

501 self.assertEqual(summary, metric.summary) 

502 

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

504 inconsistentDatasetType = DatasetType( 

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

506 ) 

507 

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

509 with self.assertRaisesRegex( 

510 ValueError, 

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

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

513 ): 

514 butler.get(inconsistentDatasetType, dataId) 

515 

516 # Combining a DatasetRef with a dataId should fail 

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

518 butler.get(ref, dataId) 

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

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

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

522 

523 # Getting a dataset with unknown parameters should fail 

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

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

526 

527 # Check we have a collection 

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

529 self.assertEqual(collections, expected_collections) 

530 

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

532 # already had a component removed 

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

534 

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

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

537 

538 # Repeat put will fail. 

539 with self.assertRaisesRegex( 

540 ConflictingDefinitionError, "A database constraint failure was triggered" 

541 ): 

542 butler.put(metric, datasetType, dataId) 

543 

544 # Remove the datastore entry. 

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

546 

547 # Put will still fail 

548 with self.assertRaisesRegex( 

549 ConflictingDefinitionError, "A database constraint failure was triggered" 

550 ): 

551 butler.put(metric, datasetType, dataId) 

552 

553 # Repeat the same sequence with resolved ref. 

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

555 ref = butler.put(metric, refIn) 

556 

557 # Repeat put will fail. 

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

559 butler.put(metric, refIn) 

560 

561 # Remove the datastore entry. 

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

563 

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

565 ref = butler.put(metric, refIn) 

566 

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

568 # something to be present 

569 

570 return butler 

571 

572 def testDeferredCollectionPassing(self) -> None: 

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

574 butler = self.create_empty_butler(writeable=True) 

575 # Create and register a DatasetType 

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

577 datasetType = self.addDatasetType( 

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

579 ) 

580 # Add needed Dimensions 

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

582 butler.registry.insertDimensionData( 

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

584 ) 

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

586 butler.registry.insertDimensionData( 

587 "visit", 

588 { 

589 "instrument": "DummyCamComp", 

590 "id": 423, 

591 "name": "fourtwentythree", 

592 "physical_filter": "d-r", 

593 "day_obs": 20250101, 

594 }, 

595 ) 

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

597 # Create dataset. 

598 metric = makeExampleMetrics() 

599 # Register a new run and put dataset. 

600 run = "deferred" 

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

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

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

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

605 # Putting with no run should fail with TypeError. 

606 with self.assertRaises(CollectionError): 

607 butler.put(metric, datasetType, dataId) 

608 # Dataset should exist. 

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

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

611 # a deferred dataset handle. 

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

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

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

615 with self.assertRaises(NoDefaultCollectionError): 

616 butler.exists(datasetType, dataId) 

617 with self.assertRaises(CollectionError): 

618 butler.get(datasetType, dataId) 

619 # Associate the dataset with a different collection. 

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

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

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

623 # in the original collection. 

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

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

626 

627 

628class ButlerTests(ButlerPutGetTests): 

629 """Tests for Butler.""" 

630 

631 useTempRoot = True 

632 validationCanFail: bool 

633 fullConfigKey: str | None 

634 registryStr: str | None 

635 datastoreName: list[str] | None 

636 datastoreStr: list[str] 

637 predictionSupported = True 

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

639 

640 def setUp(self) -> None: 

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

642 self.root = makeTestTempDir(TESTDIR) 

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

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

645 

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

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

648 

649 Subclasses may override to handle unique requirements. 

650 """ 

651 return uri1 == uri2 

652 

653 def testConstructor(self) -> None: 

654 """Independent test of constructor.""" 

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

656 self.assertIsInstance(butler, Butler) 

657 

658 # Check that butler.yaml is added automatically. 

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

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

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

662 self.assertIsInstance(butler, Butler) 

663 

664 # Even with a ResourcePath. 

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

666 self.assertIsInstance(butler, Butler) 

667 

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

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

670 

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

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

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

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

675 self.assertEqual(collections, {special_run}) 

676 

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

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

679 self.assertIsNone(butler2.run) 

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

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

682 

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

684 # repository. 

685 butler_index = Config() 

686 butler_index["label"] = self.tmpConfigFile 

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

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

689 # we aren't reusing the cache. 

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

691 butler_index["bad_label"] = bad_label 

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

693 butler_index.dumpToUri(temp_file) 

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

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

696 uri = Butler.get_repo_uri("bad_label") 

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

698 uri = Butler.get_repo_uri("label") 

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

700 self.assertIsInstance(butler, Butler) 

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

702 self.assertIsInstance(butler, Butler) 

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

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

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

706 Butler.from_config("bad_label") 

707 with self.assertRaises(FileNotFoundError): 

708 # Should ignore aliases. 

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

710 with self.assertRaises(KeyError) as cm: 

711 Butler.get_repo_uri("missing") 

712 self.assertEqual( 

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

714 ) 

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

716 # Should report no failure. 

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

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

719 # Now with empty configuration. 

720 butler_index = Config() 

721 butler_index.dumpToUri(temp_file) 

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

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

724 Butler.from_config("label") 

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

726 # Now with bad contents. 

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

728 print("'", file=fh) 

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

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

731 Butler.from_config("label") 

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

733 with self.assertRaises(FileNotFoundError): 

734 Butler.get_repo_uri("label") 

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

736 

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

738 Butler.from_config("label") 

739 

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

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

742 self.assertIsInstance(butler, Butler) 

743 with self.assertRaises(RuntimeError) as cm: 

744 # No environment variable set. 

745 Butler.get_repo_uri("label") 

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

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

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

749 # No aliases registered. 

750 Butler.from_config("not_there") 

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

752 

753 def testDafButlerRepositories(self): 

754 with unittest.mock.patch.dict( 

755 os.environ, 

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

757 ): 

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

759 

760 with unittest.mock.patch.dict( 

761 os.environ, 

762 { 

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

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

765 }, 

766 ): 

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

768 Butler.get_repo_uri("label") 

769 

770 with unittest.mock.patch.dict( 

771 os.environ, 

772 {"DAF_BUTLER_REPOSITORIES": "invalid"}, 

773 ): 

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

775 Butler.get_repo_uri("label") 

776 

777 def testBasicPutGet(self) -> None: 

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

779 self.runPutGetTest(storageClass, "test_metric") 

780 

781 def testCompositePutGetConcrete(self) -> None: 

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

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

784 

785 # Should *not* be disassembled 

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

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

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

789 self.assertIsInstance(uri, ResourcePath) 

790 self.assertFalse(components) 

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

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

793 

794 # Predicted dataset 

795 if self.predictionSupported: 

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

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

798 self.assertFalse(components) 

799 self.assertIsInstance(uri, ResourcePath) 

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

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

802 

803 def testCompositePutGetVirtual(self) -> None: 

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

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

806 

807 # Should be disassembled 

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

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

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

811 

812 if butler._datastore.isEphemeral: 

813 # Never disassemble in-memory datastore 

814 self.assertIsInstance(uri, ResourcePath) 

815 self.assertFalse(components) 

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

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

818 else: 

819 self.assertIsNone(uri) 

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

821 for compuri in components.values(): 

822 self.assertIsInstance(compuri, ResourcePath) 

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

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

825 

826 if self.predictionSupported: 

827 # Predicted dataset 

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

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

830 

831 if butler._datastore.isEphemeral: 

832 # Never disassembled 

833 self.assertIsInstance(uri, ResourcePath) 

834 self.assertFalse(components) 

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

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

837 else: 

838 self.assertIsNone(uri) 

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

840 for compuri in components.values(): 

841 self.assertIsInstance(compuri, ResourcePath) 

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

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

844 

845 def testStorageClassOverrideGet(self) -> None: 

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

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

848 datasetTypeName = "anything" 

849 run = self.default_run 

850 

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

852 

853 # Create and store a dataset. 

854 metric = makeExampleMetrics() 

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

856 

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

858 

859 # Return native type. 

860 retrieved = butler.get(ref) 

861 self.assertEqual(retrieved, metric) 

862 

863 # Specify an override. 

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

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

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

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

868 self.assertEqual(retrieved, model) 

869 

870 # Defer but override later. 

871 deferred = butler.getDeferred(ref) 

872 model = deferred.get(storageClass=new_sc) 

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

874 self.assertEqual(retrieved, model) 

875 

876 # Defer but override up front. 

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

878 model = deferred.get() 

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

880 self.assertEqual(retrieved, model) 

881 

882 # Retrieve a component. Should be a tuple. 

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

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

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

886 

887 # Parameter on the write storage class should work regardless 

888 # of read storage class. 

889 data = butler.get( 

890 "anything.data", 

891 dataId, 

892 storageClass="StructuredDataDataTestTuple", 

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

894 ) 

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

896 

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

898 # the write storage class. 

899 with self.assertRaises(KeyError): 

900 butler.get( 

901 "anything.data", 

902 dataId, 

903 storageClass="StructuredDataDataTestTuple", 

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

905 ) 

906 

907 def testPytypePutCoercion(self) -> None: 

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

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

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

911 datasetTypeName = "test_metric" 

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

913 

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

915 

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

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

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

919 test_metric = butler.get(metric_ref) 

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

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

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

923 

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

925 # a definition matching this python type. 

926 registry_type = butler.get_dataset_type(datasetTypeName) 

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

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

929 self.assertEqual(metric2_ref.datasetType, registry_type) 

930 

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

932 test_metric2 = butler.get(metric2_ref) 

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

934 

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

936 # This should now return a dict. 

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

938 test_dict2 = butler.get(new_ref) 

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

940 

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

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

943 # behavior and return the type of the DatasetType. 

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

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

946 

947 def test_ingest_zip(self) -> None: 

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

949 butler, dataset_type = self.create_butler( 

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

951 ) 

952 

953 metric = makeExampleMetrics() 

954 refs = [] 

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

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

957 refs.append(ref) 

958 

959 # Retrieve a Zip file. 

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

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

962 

963 # Ingest will fail. 

964 with self.assertRaises(ConflictingDefinitionError): 

965 butler.ingest_zip(zip) 

966 

967 # Clear out the collection. 

968 butler.removeRuns([self.default_run]) 

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

970 

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

972 

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

974 new_butler_cfg = Butler.makeRepo(tmpdir) 

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

976 

977 # This will fail since dimensions records are missing. 

978 with self.assertRaises(ConflictingDefinitionError): 

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

980 

981 # Dry run should work. 

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

983 

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

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

986 

987 # Check that the refs can be read again. 

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

989 

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

991 self.assertTrue(uri.exists()) 

992 

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

994 # remaining refs to be read. 

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

996 self.assertTrue(uri.exists()) 

997 

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

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

1000 

1001 butler.removeRuns([self.default_run]) 

1002 self.assertFalse(uri.exists()) 

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

1004 

1005 with self.assertRaises(ValueError): 

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

1007 

1008 def testIngest(self) -> None: 

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

1010 

1011 # Create and register a DatasetType 

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

1013 

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

1015 datasetTypeName = "metric" 

1016 

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

1018 

1019 # Add needed Dimensions 

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

1021 butler.registry.insertDimensionData( 

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

1023 ) 

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

1025 for detector in (1, 2): 

1026 butler.registry.insertDimensionData( 

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

1028 ) 

1029 

1030 butler.registry.insertDimensionData( 

1031 "visit", 

1032 { 

1033 "instrument": "DummyCamComp", 

1034 "id": 423, 

1035 "name": "fourtwentythree", 

1036 "physical_filter": "d-r", 

1037 "day_obs": 20250101, 

1038 }, 

1039 { 

1040 "instrument": "DummyCamComp", 

1041 "id": 424, 

1042 "name": "fourtwentyfour", 

1043 "physical_filter": "d-r", 

1044 "day_obs": 20250101, 

1045 }, 

1046 ) 

1047 

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

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

1050 datasets = [] 

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

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

1053 # required. 

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

1055 for detector in (1, 2): 

1056 detector_name = f"detector_{detector}" 

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

1058 dataId = butler.registry.expandDataId( 

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

1060 ) 

1061 # Create a DatasetRef for ingest 

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

1063 

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

1065 

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

1067 

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

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

1070 

1071 metrics1 = butler.get(datasetTypeName, dataId1) 

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

1073 self.assertNotEqual(metrics1, metrics2) 

1074 

1075 # Compare URIs 

1076 uri1 = butler.getURI(datasetTypeName, dataId1) 

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

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

1079 

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

1081 # skip_existing=False. 

1082 with self.assertRaises(ConflictingDefinitionError): 

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

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

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

1086 

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

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

1089 refs = [] 

1090 for detector in (1, 2): 

1091 detector_name = f"detector_{detector}" 

1092 dataId = butler.registry.expandDataId( 

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

1094 ) 

1095 # Create a DatasetRef for ingest 

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

1097 

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

1099 # have disappeared following ingest. 

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

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

1102 

1103 datasets = [] 

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

1105 

1106 # For first ingest use copy. 

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

1108 

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

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

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

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

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

1114 datasets[0].refs = [ 

1115 cast( 

1116 DatasetRef, 

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

1118 ) 

1119 for ref in datasets[0].refs 

1120 ] 

1121 all_refs = [] 

1122 for dataset in datasets: 

1123 refs = [] 

1124 for ref in dataset.refs: 

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

1126 new_data_id = dict(ref.dataId.required) 

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

1128 assert new_ref is not None 

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

1130 refs.append(new_ref) 

1131 dataset.refs = refs 

1132 all_refs.extend(dataset.refs) 

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

1134 

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

1136 # disable recording of file size. 

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

1138 

1139 # Check that every ref now has records. 

1140 for dataset in datasets: 

1141 for ref in dataset.refs: 

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

1143 

1144 # Ensure that the file has disappeared. 

1145 self.assertFalse(tempFile.exists()) 

1146 

1147 # Check that the datastore recorded no file size. 

1148 # Not all datastores can support this. 

1149 try: 

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

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

1152 except AttributeError: 

1153 pass 

1154 

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

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

1157 

1158 multi1 = butler.get(datasetTypeName, dataId1) 

1159 multi2 = butler.get(datasetTypeName, dataId2) 

1160 

1161 self.assertEqual(multi1, metrics1) 

1162 self.assertEqual(multi2, metrics2) 

1163 

1164 # Compare URIs 

1165 uri1 = butler.getURI(datasetTypeName, dataId1) 

1166 uri2 = butler.getURI(datasetTypeName, dataId2) 

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

1168 

1169 # Test that removing one does not break the second 

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

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

1172 # files. 

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

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

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

1176 multi2b = butler.get(datasetTypeName, dataId2) 

1177 self.assertEqual(multi2, multi2b) 

1178 

1179 # Ensure we can ingest 0 datasets 

1180 datasets = [] 

1181 butler.ingest(*datasets) 

1182 

1183 def testPickle(self) -> None: 

1184 """Test pickle support.""" 

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

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

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

1188 self.assertIsInstance(butlerOut, Butler) 

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

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

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

1192 

1193 def testGetDatasetTypes(self) -> None: 

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

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

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

1197 ( 

1198 "instrument", 

1199 [ 

1200 {"instrument": "DummyCam"}, 

1201 {"instrument": "DummyHSC"}, 

1202 {"instrument": "DummyCamComp"}, 

1203 ], 

1204 ), 

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

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

1207 ( 

1208 "visit", 

1209 [ 

1210 { 

1211 "instrument": "DummyCam", 

1212 "id": 42, 

1213 "name": "fortytwo", 

1214 "physical_filter": "d-r", 

1215 "day_obs": 20250101, 

1216 } 

1217 ], 

1218 ), 

1219 ] 

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

1221 # Add needed Dimensions 

1222 for element, data in dimensionEntries: 

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

1224 

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

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

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

1228 components = set() 

1229 for datasetTypeName in datasetTypeNames: 

1230 # Create and register a DatasetType 

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

1232 

1233 for componentName in storageClass.components: 

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

1235 

1236 fromRegistry: set[DatasetType] = set() 

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

1238 fromRegistry.add(parent_dataset_type) 

1239 fromRegistry.update(parent_dataset_type.makeAllComponentDatasetTypes()) 

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

1241 

1242 # Query with wildcard. 

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

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

1245 # but not regex. 

1246 with self.assertRaises(DatasetTypeExpressionError): 

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

1248 

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

1250 butler.validateConfiguration( 

1251 ignore=[ 

1252 "test_metric_comp", 

1253 "metric3", 

1254 "metric5", 

1255 "calexp", 

1256 "DummySC", 

1257 "datasetType.component", 

1258 "random_data", 

1259 "random_data_2", 

1260 ] 

1261 ) 

1262 

1263 # Add a new datasetType that will fail template validation 

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

1265 if self.validationCanFail: 

1266 with self.assertRaises(ValidationError): 

1267 butler.validateConfiguration() 

1268 

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

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

1271 

1272 # Rerun validation but ignore the bad datasetType 

1273 butler.validateConfiguration( 

1274 ignore=[ 

1275 "test_metric_comp", 

1276 "metric3", 

1277 "metric5", 

1278 "calexp", 

1279 "DummySC", 

1280 "datasetType.component", 

1281 "random_data", 

1282 "random_data_2", 

1283 ] 

1284 ) 

1285 

1286 def testTransaction(self) -> None: 

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

1288 datasetTypeName = "test_metric" 

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

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

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

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

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

1294 ( 

1295 "visit", 

1296 { 

1297 "instrument": "DummyCam", 

1298 "id": 42, 

1299 "name": "fortytwo", 

1300 "physical_filter": "d-r", 

1301 "day_obs": 20250101, 

1302 }, 

1303 ), 

1304 ) 

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

1306 metric = makeExampleMetrics() 

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

1308 # Create and register a DatasetType 

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

1310 with self.assertRaises(TransactionTestError): 

1311 with butler.transaction(): 

1312 # Add needed Dimensions 

1313 for args in dimensionEntries: 

1314 butler.registry.insertDimensionData(*args) 

1315 # Store a dataset 

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

1317 self.assertIsInstance(ref, DatasetRef) 

1318 # Test get of a ref. 

1319 metricOut = butler.get(ref) 

1320 self.assertEqual(metric, metricOut) 

1321 # Test get 

1322 metricOut = butler.get(datasetTypeName, dataId) 

1323 self.assertEqual(metric, metricOut) 

1324 # Check we can get components 

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

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

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

1328 butler.registry.expandDataId(dataId) 

1329 # Should raise LookupError for missing data ID value 

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

1331 butler.get(datasetTypeName, dataId) 

1332 # Also check explicitly if Dataset entry is missing 

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

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

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

1336 butler.get(ref) 

1337 

1338 def testMakeRepo(self) -> None: 

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

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

1341 repo root. 

1342 """ 

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

1344 # not support a file system root 

1345 if self.fullConfigKey is None: 

1346 return 

1347 

1348 # create two separate directories 

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

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

1351 

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

1353 limited = Config(self.configFile) 

1354 butler1 = Butler.from_config(butlerConfig) 

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

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

1357 full = Config(self.tmpConfigFile) 

1358 butler2 = Butler.from_config(butlerConfig) 

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

1360 # Butlers should have the same configuration regardless of whether 

1361 # defaults were expanded. 

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

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

1364 self.assertNotEqual(limited, full) 

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

1366 # inheriting from defaults. 

1367 self.assertIn(self.fullConfigKey, full) 

1368 self.assertNotIn(self.fullConfigKey, limited) 

1369 

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

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

1372 self.assertEqual(collections1, set()) 

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

1374 

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

1376 # work properly with relocatable Butler repo 

1377 butlerConfig.configFile = None 

1378 with self.assertRaises(ValueError): 

1379 Butler.from_config(butlerConfig) 

1380 

1381 with self.assertRaises(FileExistsError): 

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

1383 

1384 def testStringification(self) -> None: 

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

1386 butlerStr = str(butler) 

1387 

1388 if self.datastoreStr is not None: 

1389 for testStr in self.datastoreStr: 

1390 self.assertIn(testStr, butlerStr) 

1391 if self.registryStr is not None: 

1392 self.assertIn(self.registryStr, butlerStr) 

1393 

1394 datastoreName = butler._datastore.name 

1395 if self.datastoreName is not None: 

1396 for testStr in self.datastoreName: 

1397 self.assertIn(testStr, datastoreName) 

1398 

1399 def testButlerRewriteDataId(self) -> None: 

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

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

1402 

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

1404 datasetTypeName = "random_data" 

1405 

1406 # Create dimension records. 

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

1408 butler.registry.insertDimensionData( 

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

1410 ) 

1411 butler.registry.insertDimensionData( 

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

1413 ) 

1414 

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

1416 datasetType = DatasetType(datasetTypeName, dimensions, storageClass) 

1417 butler.registry.registerDatasetType(datasetType) 

1418 

1419 n_exposures = 5 

1420 dayobs = 20210530 

1421 

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

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

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

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

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

1427 

1428 for i in range(n_exposures): 

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

1430 butler.registry.insertDimensionData( 

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

1432 ) 

1433 butler.registry.insertDimensionData( 

1434 "exposure", 

1435 { 

1436 "instrument": "DummyCamComp", 

1437 "id": day_obs + i, 

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

1439 "seq_num": i, 

1440 "day_obs": day_obs, 

1441 "physical_filter": "d-r", 

1442 "group": group_name, 

1443 }, 

1444 ) 

1445 

1446 # Write some data. 

1447 for i in range(n_exposures): 

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

1449 

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

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

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

1453 

1454 # Check that the exposure is correct in the dataId 

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

1456 

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

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

1459 self.assertEqual(new_metric, metric) 

1460 

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

1462 # exposure.day_obs. 

1463 datasets_1 = list( 

1464 butler.registry.queryDatasets( 

1465 datasetType, 

1466 collections=self.default_run, 

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

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

1469 ) 

1470 ) 

1471 datasets_2 = list( 

1472 butler.registry.queryDatasets( 

1473 datasetType, 

1474 collections=self.default_run, 

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

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

1477 ) 

1478 ) 

1479 self.assertEqual(datasets_1, datasets_2) 

1480 

1481 def testGetDatasetCollectionCaching(self): 

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

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

1484 # after the collection cache was last updated. 

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

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

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

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

1489 get_ref = reader_butler.get_dataset(put_ref.id) 

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

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

1492 # instance. 

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

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

1495 

1496 def testCollectionChainRedefine(self): 

1497 butler = self._setup_to_test_collection_chain() 

1498 

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

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

1501 

1502 # Duplicates are removed from the list of children 

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

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

1505 

1506 # Empty list clears the chain 

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

1508 self._check_chain(butler, []) 

1509 

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

1511 

1512 def testCollectionChainPrepend(self): 

1513 butler = self._setup_to_test_collection_chain() 

1514 

1515 # Duplicates are removed from the list of children 

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

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

1518 

1519 # Prepend goes on the front of existing chain 

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

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

1522 

1523 # Empty prepend does nothing 

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

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

1526 

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

1528 # their current position. 

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

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

1531 

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

1533 

1534 def testCollectionChainExtend(self): 

1535 butler = self._setup_to_test_collection_chain() 

1536 

1537 # Duplicates are removed from the list of children 

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

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

1540 

1541 # Extend goes on the end of existing chain 

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

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

1544 

1545 # Empty extend does nothing 

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

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

1548 

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

1550 # their current position. 

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

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

1553 

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

1555 

1556 def testCollectionChainRemove(self) -> None: 

1557 butler = self._setup_to_test_collection_chain() 

1558 

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

1560 

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

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

1563 

1564 # Duplicates are allowed in the list of children 

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

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

1567 

1568 # Empty remove does nothing 

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

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

1571 

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

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

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

1575 

1576 self._test_common_chain_functionality( 

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

1578 ) 

1579 

1580 def _setup_to_test_collection_chain(self) -> Butler: 

1581 butler = self.create_empty_butler(writeable=True) 

1582 

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

1584 

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

1586 for run in runs: 

1587 butler.collections.register(run) 

1588 

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

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

1591 

1592 return butler 

1593 

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

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

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

1597 

1598 def _test_common_chain_functionality( 

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

1600 ) -> None: 

1601 # Missing parent collection 

1602 with self.assertRaises(MissingCollectionError): 

1603 func("doesnotexist", []) 

1604 # Missing child collection 

1605 with self.assertRaises(MissingCollectionError): 

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

1607 # Forbid operations on non-chained collections 

1608 with self.assertRaises(CollectionTypeError): 

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

1610 

1611 # Prevent collection cycles 

1612 if not skip_cycle_check: 

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

1614 func("chain2", "chain") 

1615 with self.assertRaises(CollectionCycleError): 

1616 func("chain", "chain2") 

1617 

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

1619 # chains. 

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

1621 

1622 with butler._caching_context(): 

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

1624 func("chain", "a") 

1625 

1626 

1627class FileDatastoreButlerTests(ButlerTests): 

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

1629 by datastores that inherit from FileDatastore. 

1630 """ 

1631 

1632 trustModeSupported = True 

1633 

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

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

1636 

1637 Test testPutTemplates verifies actual physical existance of the files 

1638 in the requested location. 

1639 """ 

1640 uri = ResourcePath(root, forceDirectory=True) 

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

1642 

1643 def testPutTemplates(self) -> None: 

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

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

1646 

1647 # Add needed Dimensions 

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

1649 butler.registry.insertDimensionData( 

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

1651 ) 

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

1653 butler.registry.insertDimensionData( 

1654 "visit", 

1655 { 

1656 "instrument": "DummyCamComp", 

1657 "id": 423, 

1658 "name": "v423", 

1659 "physical_filter": "d-r", 

1660 "day_obs": 20250101, 

1661 }, 

1662 ) 

1663 butler.registry.insertDimensionData( 

1664 "visit", 

1665 { 

1666 "instrument": "DummyCamComp", 

1667 "id": 425, 

1668 "name": "v425", 

1669 "physical_filter": "d-r", 

1670 "day_obs": 20250101, 

1671 }, 

1672 ) 

1673 

1674 # Create and store a dataset 

1675 metric = makeExampleMetrics() 

1676 

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

1678 # template) 

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

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

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

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

1683 

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

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

1686 

1687 # Put with exactly the data ID keys needed 

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

1689 uri = butler.getURI(ref) 

1690 self.assertTrue(uri.exists()) 

1691 self.assertTrue( 

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

1693 ) 

1694 

1695 # Check the template based on dimensions 

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

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

1698 

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

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

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

1702 # must be consistent). 

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

1704 uri = butler.getURI(ref) 

1705 self.assertTrue(uri.exists()) 

1706 self.assertTrue( 

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

1708 ) 

1709 

1710 # Check the template based on dimensions 

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

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

1713 

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

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

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

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

1718 path = template.format(ref) 

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

1720 

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

1722 with self.assertRaises(KeyError): 

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

1724 template.format(ref) 

1725 

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

1727 with self.assertRaises(FileTemplateValidationError): 

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

1729 

1730 def testImportExport(self) -> None: 

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

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

1733 self.runImportExportTest(storageClass) 

1734 

1735 @unittest.expectedFailure 

1736 def testImportExportVirtualComposite(self) -> None: 

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

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

1739 self.runImportExportTest(storageClass) 

1740 

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

1742 """Test exporting and importing. 

1743 

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

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

1746 """ 

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

1748 

1749 # Test that we must have a file extension. 

1750 with self.assertRaises(ValueError): 

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

1752 pass 

1753 

1754 # Test that unknown format is not allowed. 

1755 with self.assertRaises(ValueError): 

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

1757 pass 

1758 

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

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

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

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

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

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

1765 # Export and then import datasets. 

1766 with safeTestTempDir(TESTDIR) as exportDir: 

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

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

1769 export.saveDatasets(datasets) 

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

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

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

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

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

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

1776 # because of internal deduplication. 

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

1778 # Save some dimension records directly. 

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

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

1781 with safeTestTempDir(TESTDIR) as importDir: 

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

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

1784 # Calling script.butlerImport tests the implementation of the 

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

1786 # in the script folder are generally considered protected and 

1787 # should not be used as public api. 

1788 with open(exportFile) as f: 

1789 script.butlerImport( 

1790 importDir, 

1791 export_file=f, 

1792 directory=exportDir, 

1793 transfer="auto", 

1794 skip_dimensions=None, 

1795 ) 

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

1797 for ref in datasets: 

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

1799 # Test for existence by passing in the DatasetType and 

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

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

1802 self.assertEqual( 

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

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

1805 ) 

1806 

1807 def testRemoveRuns(self) -> None: 

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

1809 butler = self.create_empty_butler(writeable=True) 

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

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

1812 # Add some RUN-type collection. 

1813 run1 = "run1" 

1814 butler.collections.register(run1) 

1815 run2 = "run2" 

1816 butler.collections.register(run2) 

1817 # put a dataset in each 

1818 metric = makeExampleMetrics() 

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

1820 datasetType = self.addDatasetType( 

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

1822 ) 

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

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

1825 uri1 = butler.getURI(ref1) 

1826 uri2 = butler.getURI(ref2) 

1827 

1828 # Put one of the runs in a chain. 

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

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

1831 

1832 with self.assertRaises(OrphanedRecordError): 

1833 butler.registry.removeDatasetType(datasetType.name) 

1834 

1835 # Remove a non-run. 

1836 with self.assertRaises(TypeError): 

1837 butler.removeRuns(["Chain"]) 

1838 

1839 # Remove without unlinking from chain should fail. 

1840 with self.assertRaises(IntegrityError): 

1841 butler.removeRuns([run1]) 

1842 

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

1844 # always purges. 

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

1846 

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

1848 # not think either exists. 

1849 with self.assertRaises(MissingCollectionError): 

1850 butler.collections.get_info(run1) 

1851 with self.assertRaises(MissingCollectionError): 

1852 butler.collections.get_info(run1) 

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

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

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

1856 self.assertFalse(uri1.exists()) 

1857 self.assertFalse(uri2.exists()) 

1858 

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

1860 # dataset type 

1861 butler.registry.removeDatasetType(datasetType.name) 

1862 

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

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

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

1866 

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

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

1869 knowledge. 

1870 

1871 Subclasses may override to handle more complicated datastore 

1872 configurations. 

1873 """ 

1874 uri = butler.getURI(ref) 

1875 uri.remove() 

1876 datastore = cast(FileDatastore, butler._datastore) 

1877 datastore.cacheManager.remove_from_cache(ref) 

1878 

1879 def testPruneDatasets(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=_get_test_data_path("base.yaml")) 

1884 # Add some RUN-type collections. 

1885 run1 = "run1" 

1886 butler.collections.register(run1) 

1887 run2 = "run2" 

1888 butler.collections.register(run2) 

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

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

1891 metric = makeExampleMetrics() 

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

1893 datasetType = self.addDatasetType( 

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

1895 ) 

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

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

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

1899 

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

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

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

1903 

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

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

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

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

1908 

1909 # Simple prune. 

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

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

1912 

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

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

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

1916 

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

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

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

1920 

1921 # Put data back. 

1922 ref1_new = butler.put(metric, ref1) 

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

1924 ref2 = butler.put(metric, ref2) 

1925 

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

1927 self.assertTrue(many_stored[ref1]) 

1928 self.assertTrue(many_stored[ref2]) 

1929 self.assertFalse(many_stored[ref3]) 

1930 

1931 ref3 = butler.put(metric, ref3) 

1932 

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

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

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

1936 

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

1938 refs = [ref1, ref2, ref3] 

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

1940 for ref in refs: 

1941 butler.put(metric, ref) 

1942 

1943 # Confirm we can retrieve deferred. 

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

1945 metric1 = dref1.get() 

1946 self.assertEqual(metric1, metric) 

1947 

1948 # Test different forms of file availability. 

1949 # Need to be in a state where: 

1950 # - one ref just has registry record. 

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

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

1953 # - one ref does not exist anywhere. 

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

1955 # above. 

1956 ref0 = DatasetRef( 

1957 datasetType, 

1958 DataCoordinate.standardize( 

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

1960 ), 

1961 run=run1, 

1962 ) 

1963 

1964 # Delete from datastore and retain in Registry. 

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

1966 

1967 # File has been removed. 

1968 self.remove_dataset_out_of_band(butler, ref2) 

1969 

1970 # Datastore has lost track. 

1971 butler._datastore.forget([ref3]) 

1972 

1973 # First test with a standard butler. 

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

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

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

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

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

1979 

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

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

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

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

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

1985 self.assertTrue(exists_many[ref2]) 

1986 

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

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

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

1990 

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

1992 # retrieved. 

1993 with self.assertRaises(LookupError): 

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

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

1996 with self.assertRaises(FileNotFoundError): 

1997 dref2.get() 

1998 

1999 # Test again with a trusting butler. 

2000 if self.trustModeSupported: 

2001 butler._datastore.trustGetRequest = True 

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

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

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

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

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

2007 

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

2009 # known but does exist. 

2010 dref3 = butler.getDeferred(ref3) 

2011 metric3 = dref3.get() 

2012 self.assertEqual(metric3, metric) 

2013 

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

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

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

2017 

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

2019 # but is not the same. 

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

2021 with self.assertRaises(ValueError): 

2022 butler.exists(ref_bad) 

2023 

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

2025 ref_compat = ref2.overrideStorageClass("StructuredDataDict") 

2026 exists = butler.exists(ref_compat) 

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

2028 

2029 # Remove everything and start from scratch. 

2030 butler._datastore.trustGetRequest = False 

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

2032 for ref in refs: 

2033 butler.put(metric, ref) 

2034 

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

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

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

2038 # trash not touching the file. 

2039 uri1 = butler.getURI(ref1) 

2040 butler._datastore.bridge.moveToTrash( 

2041 [ref1], transaction=None 

2042 ) # Update the dataset_location table 

2043 butler._datastore.forget([ref1]) 

2044 butler._datastore.trash(ref1) 

2045 butler._datastore.emptyTrash() 

2046 self.assertTrue(uri1.exists()) 

2047 uri1.remove() # Clean it up. 

2048 

2049 # Simulate execution butler setup by deleting the datastore 

2050 # record but keeping the file around and trusting. 

2051 butler._datastore.trustGetRequest = True 

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

2053 uri2 = uris[ref2].primaryURI 

2054 uri3 = uris[ref3].primaryURI 

2055 self.assertTrue(uri2.exists()) 

2056 self.assertTrue(uri3.exists()) 

2057 

2058 # Remove the datastore record. 

2059 butler._datastore.bridge.moveToTrash( 

2060 [ref2], transaction=None 

2061 ) # Update the dataset_location table 

2062 butler._datastore.forget([ref2]) 

2063 self.assertTrue(uri2.exists()) 

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

2065 # Immediate removal for ref2 file 

2066 self.assertFalse(uri2.exists()) 

2067 # But ref3 has to wait for the empty. 

2068 self.assertTrue(uri3.exists()) 

2069 butler._datastore.emptyTrash() 

2070 self.assertFalse(uri3.exists()) 

2071 

2072 # Clear out the datasets from registry. 

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

2074 

2075 def test_butler_metrics(self): 

2076 """Test that metrics are collected.""" 

2077 run = "test_run" 

2078 metrics = ButlerMetrics() 

2079 butler, datasetType = self.create_butler( 

2080 run, "MetricsExampleModelProvenance", "prov_metric", metrics=metrics 

2081 ) 

2082 data = MetricsExampleModel( 

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

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

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

2086 ) 

2087 

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

2089 butler.get(data_ref) 

2090 butler.get(data_ref) 

2091 self.assertEqual(metrics.n_get, 2) 

2092 self.assertGreater(metrics.time_in_get, 0.0) 

2093 self.assertEqual(metrics.n_put, 1) 

2094 self.assertGreater(metrics.time_in_put, 0.0) 

2095 

2096 deferred = butler.getDeferred(data_ref) 

2097 deferred.get() 

2098 self.assertEqual(metrics.n_get, 3) 

2099 

2100 with butler.record_metrics() as new: 

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

2102 butler.get(data_ref) 

2103 self.assertEqual(new.n_get, 1) 

2104 self.assertEqual(new.n_put, 1) 

2105 

2106 

2107class PosixDatastoreButlerTestCase(FileDatastoreButlerTests, unittest.TestCase): 

2108 """PosixDatastore specialization of a butler""" 

2109 

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

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

2112 validationCanFail = True 

2113 datastoreStr = ["/tmp"] 

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

2115 registryStr = "/gen3.sqlite3" 

2116 

2117 def testPathConstructor(self) -> None: 

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

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

2120 self.assertIsInstance(butler, Butler) 

2121 

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

2123 path = pathlib.Path(self.tmpConfigFile) 

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

2125 self.assertIsInstance(butler, Butler) 

2126 

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

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

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

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

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

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

2133 self.assertIsInstance(butler, Butler) 

2134 

2135 def testExportTransferCopy(self) -> None: 

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

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

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

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

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

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

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

2143 assert isinstance(exportButler._datastore, FileDatastore) 

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

2145 

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

2147 

2148 for path in pathsInStore: 

2149 # Assume local file system 

2150 assert path is not None 

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

2152 

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

2154 with safeTestTempDir(TESTDIR) as exportDir: 

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

2156 export.saveDatasets(datasets) 

2157 for path in pathsInStore: 

2158 assert path is not None 

2159 self.assertTrue( 

2160 self.checkFileExists(exportDir, path), 

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

2162 ) 

2163 

2164 def testPytypeCoercion(self) -> None: 

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

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

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

2168 datasetTypeName = "test_metric" 

2169 butler = self.runPutGetTest(storageClass, datasetTypeName) 

2170 

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

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

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

2174 

2175 datasetType_ori = butler.get_dataset_type(datasetTypeName) 

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

2177 

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

2179 # There is no API for this. 

2180 assert isinstance(butler._registry, SqlRegistry) 

2181 manager = butler._registry._managers.datasets 

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

2183 manager._db.update( 

2184 manager._static.dataset_type, 

2185 {"name": datasetTypeName}, 

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

2187 ) 

2188 

2189 # Force reset of dataset type cache 

2190 butler.registry.refresh() 

2191 

2192 datasetType_new = butler.get_dataset_type(datasetTypeName) 

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

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

2195 

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

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

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

2199 

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

2201 # works as normal. 

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

2203 metric_model_new = butler.get(metric_ref) 

2204 self.assertEqual(metric_model_new, metric_model) 

2205 

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

2207 # get with no conversion class. 

2208 manager._db.update( 

2209 manager._static.dataset_type, 

2210 {"name": datasetTypeName}, 

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

2212 ) 

2213 butler.registry.refresh() 

2214 

2215 with self.assertRaises(ValueError): 

2216 butler.get(datasetTypeName, dataId=dataId) 

2217 

2218 def test_provenance(self): 

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

2220 run = "test_run" 

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

2222 metric = MetricsExampleModel( 

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

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

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

2226 ) 

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

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

2229 # copy on adding provenance to ensure they differ. 

2230 self.assertIsNone(metric.dataset_id) 

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

2232 self.assertIsNone(metric.dataset_id) 

2233 metric_2 = butler.get(metric_ref) 

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

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

2236 self.assertIsNone(metric_2.provenance) 

2237 

2238 # Put with provenance. 

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

2240 prov.add_input(metric_ref) 

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

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

2243 metric_3 = butler.get(metric_ref2) 

2244 self.assertEqual(metric_3.provenance, prov) 

2245 

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

2247 prov_dict = prov.to_flat_dict(metric_ref2) 

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

2249 self.assertEqual(ref_from_prov, metric_ref2) 

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

2251 # includes dimension records. 

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

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

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

2255 

2256 # Force a bad ID into the dict. 

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

2258 with self.assertRaises(ValueError): 

2259 DatasetProvenance.from_flat_dict(prov_dict, butler) 

2260 del prov_dict["id"] 

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

2262 with self.assertRaises(ValueError): 

2263 DatasetProvenance.from_flat_dict(prov_dict, butler) 

2264 

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

2266 # separators. 

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

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

2269 self.assertEqual(ref_from_prov, metric_ref2) 

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

2271 

2272 with self.assertRaises(ValueError): 

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

2274 

2275 def test_specialized_file_datasets_functions(self): 

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

2277 from one repository and write them in-place to the datastore of 

2278 another, without immediately inserting registry entries for the 

2279 datasets. 

2280 """ 

2281 repo = MetricTestRepo.create_from_butler( 

2282 self.create_empty_butler(writeable=True), 

2283 self.tmpConfigFile, 

2284 "StructuredCompositeReadCompNoDisassembly", 

2285 ) 

2286 source_butler = repo.butler 

2287 

2288 # Test writing outputs to a FileDatastore. 

2289 with tempfile.TemporaryDirectory() as tempdir: 

2290 target_repo_config = Butler.makeRepo(tempdir) 

2291 refs = [repo.ref1, repo.ref2] 

2292 datasets = transfer_datasets_to_datastore(source_butler, ButlerConfig(target_repo_config), refs) 

2293 self.assertEqual(len(datasets), 2) 

2294 self.assertEqual({ref.id for ref in refs}, {dataset.refs[0].id for dataset in datasets}) 

2295 for dataset in datasets: 

2296 path = ResourcePath(dataset.path, forceAbsolute=False) 

2297 # Paths should be relative paths to the target datastore. 

2298 self.assertFalse(path.isabs()) 

2299 # Files should have been copied into the target datastore 

2300 self.assertTrue(ResourcePath(tempdir).join(path).exists()) 

2301 

2302 # Make sure the target Butler can ingest the datasets. 

2303 target_butler = Butler(target_repo_config, writeable=True) 

2304 target_butler.transfer_dimension_records_from(source_butler, refs) 

2305 target_butler.ingest(*datasets, transfer=None) 

2306 self.assertIsNotNone(target_butler.get(repo.ref1)) 

2307 self.assertIsNotNone(target_butler.get(repo.ref2)) 

2308 

2309 # Giving an empty list of files is a no-op. 

2310 no_datasets = transfer_datasets_to_datastore(source_butler, ButlerConfig(target_repo_config), []) 

2311 self.assertEqual(len(no_datasets), 0) 

2312 

2313 # Test writing outputs to a ChainedDatastore. 

2314 with tempfile.TemporaryDirectory() as tempdir: 

2315 # Set up a second dataset type, so we can split the files across 

2316 # multiple datastore roots. 

2317 dt1 = repo.datasetType 

2318 dt2 = DatasetType("other", dt1.dimensions, dt1.storageClass) 

2319 source_butler.registry.registerDatasetType(dt2) 

2320 other_ref = repo.addDataset(repo.ref1.dataId, datasetType=dt2) 

2321 config = Config.fromString( 

2322 f""" 

2323 datastore: 

2324 cls: lsst.daf.butler.datastores.chainedDatastore.ChainedDatastore 

2325 datastore_constraints: 

2326 - constraints: 

2327 accept: 

2328 - {dt1.name} 

2329 - constraints: 

2330 accept: 

2331 - {dt2.name} 

2332 datastores: 

2333 - datastore: 

2334 cls: lsst.daf.butler.datastores.fileDatastore.FileDatastore 

2335 root: <butlerRoot>/FileDatastore_0 

2336 - datastore: 

2337 cls: lsst.daf.butler.datastores.fileDatastore.FileDatastore 

2338 root: <butlerRoot>/FileDatastore_1 

2339 """ 

2340 ) 

2341 target_repo_config = Butler.makeRepo(tempdir, config) 

2342 refs = [repo.ref1, repo.ref2, other_ref] 

2343 datasets = transfer_datasets_to_datastore(source_butler, ButlerConfig(target_repo_config), refs) 

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

2345 self.assertEqual({ref.id for ref in refs}, {dataset.refs[0].id for dataset in datasets}) 

2346 for dataset in datasets: 

2347 path = ResourcePath(dataset.path, forceAbsolute=False) 

2348 # Paths should be relative paths to the target datastore. 

2349 self.assertFalse(path.isabs()) 

2350 # Files should have been split up between the two datastores 

2351 # in the chain. 

2352 datastore_root = ResourcePath(tempdir) 

2353 if dataset.refs[0].datasetType.name == dt1.name: 

2354 datastore_root = datastore_root.join("FileDatastore_0") 

2355 else: 

2356 datastore_root = datastore_root.join("FileDatastore_1") 

2357 self.assertTrue(datastore_root.join(path).exists()) 

2358 

2359 # Make sure the target Butler can ingest the datasets. 

2360 target_butler = Butler(target_repo_config, writeable=True) 

2361 target_butler.transfer_dimension_records_from(source_butler, refs) 

2362 target_butler.ingest(*datasets, transfer=None) 

2363 self.assertIsNotNone(target_butler.get(repo.ref1)) 

2364 self.assertIsNotNone(target_butler.get(repo.ref2)) 

2365 self.assertIsNotNone(target_butler.get(other_ref)) 

2366 

2367 

2368class PostgresPosixDatastoreButlerTestCase(FileDatastoreButlerTests, unittest.TestCase): 

2369 """PosixDatastore specialization of a butler using Postgres""" 

2370 

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

2372 fullConfigKey = ".datastore.formatters" 

2373 validationCanFail = True 

2374 datastoreStr = ["/tmp"] 

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

2376 registryStr = "PostgreSQL@test" 

2377 

2378 @classmethod 

2379 def setUpClass(cls) -> None: 

2380 cls.postgresql = cls.enterClassContext(setup_postgres_test_db()) 

2381 super().setUpClass() 

2382 

2383 def setUp(self) -> None: 

2384 # Need to add a registry section to the config. 

2385 self._temp_config = False 

2386 config = Config(self.configFile) 

2387 self.postgresql.patch_butler_config(config) 

2388 with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as fh: 

2389 config.dump(fh) 

2390 self.configFile = fh.name 

2391 self._temp_config = True 

2392 super().setUp() 

2393 

2394 def tearDown(self) -> None: 

2395 if self._temp_config and os.path.exists(self.configFile): 

2396 os.remove(self.configFile) 

2397 super().tearDown() 

2398 

2399 def testMakeRepo(self) -> None: 

2400 # The base class test assumes that it's using sqlite and assumes 

2401 # the config file is acceptable to sqlite. 

2402 raise unittest.SkipTest("Postgres config is not compatible with this test.") 

2403 

2404 

2405class ClonedPostgresPosixDatastoreButlerTestCase(PostgresPosixDatastoreButlerTestCase, unittest.TestCase): 

2406 """Test that Butler with a Postgres registry still works after cloning.""" 

2407 

2408 def create_butler( 

2409 self, 

2410 run: str, 

2411 storageClass: StorageClass | str, 

2412 datasetTypeName: str, 

2413 metrics: ButlerMetrics | None = None, 

2414 ) -> tuple[DirectButler, DatasetType]: 

2415 butler, datasetType = super().create_butler(run, storageClass, datasetTypeName, metrics=metrics) 

2416 return butler.clone(run=run, metrics=metrics), datasetType 

2417 

2418 

2419class InMemoryDatastoreButlerTestCase(ButlerTests, unittest.TestCase): 

2420 """InMemoryDatastore specialization of a butler""" 

2421 

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

2423 fullConfigKey = None 

2424 useTempRoot = False 

2425 validationCanFail = False 

2426 datastoreStr = ["datastore='InMemory"] 

2427 datastoreName = ["InMemoryDatastore@"] 

2428 registryStr = "/gen3.sqlite3" 

2429 

2430 def testIngest(self) -> None: 

2431 pass 

2432 

2433 def test_ingest_zip(self) -> None: 

2434 pass 

2435 

2436 

2437class ClonedSqliteButlerTestCase(InMemoryDatastoreButlerTestCase, unittest.TestCase): 

2438 """Test that a Butler with a Sqlite registry still works after cloning.""" 

2439 

2440 def create_butler( 

2441 self, 

2442 run: str, 

2443 storageClass: StorageClass | str, 

2444 datasetTypeName: str, 

2445 metrics: ButlerMetrics | None = None, 

2446 ) -> tuple[DirectButler, DatasetType]: 

2447 butler, datasetType = super().create_butler(run, storageClass, datasetTypeName, metrics=metrics) 

2448 return butler.clone(run=run), datasetType 

2449 

2450 

2451class ChainedDatastoreButlerTestCase(FileDatastoreButlerTests, unittest.TestCase): 

2452 """PosixDatastore specialization""" 

2453 

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

2455 fullConfigKey = ".datastore.datastores.1.formatters" 

2456 validationCanFail = True 

2457 datastoreStr = ["datastore='InMemory", "/FileDatastore_1/,", "/FileDatastore_2/'"] 

2458 datastoreName = [ 

2459 "InMemoryDatastore@", 

2460 f"FileDatastore@{BUTLER_ROOT_TAG}/FileDatastore_1", 

2461 "SecondDatastore", 

2462 ] 

2463 registryStr = "/gen3.sqlite3" 

2464 

2465 def testPruneDatasets(self) -> None: 

2466 # This test relies on manipulating files out-of-band, which is 

2467 # impossible for this configuration because of the InMemoryDatastore in 

2468 # the ChainedDatastore. 

2469 pass 

2470 

2471 

2472class ButlerExplicitRootTestCase(PosixDatastoreButlerTestCase): 

2473 """Test that a yaml file in one location can refer to a root in another.""" 

2474 

2475 datastoreStr = ["dir1"] 

2476 # Disable the makeRepo test since we are deliberately not using 

2477 # butler.yaml as the config name. 

2478 fullConfigKey = None 

2479 

2480 def setUp(self) -> None: 

2481 self.root = makeTestTempDir(TESTDIR) 

2482 

2483 # Make a new repository in one place 

2484 self.dir1 = os.path.join(self.root, "dir1") 

2485 Butler.makeRepo(self.dir1, config=Config(self.configFile)) 

2486 

2487 # Move the yaml file to a different place and add a "root" 

2488 self.dir2 = os.path.join(self.root, "dir2") 

2489 os.makedirs(self.dir2, exist_ok=True) 

2490 configFile1 = os.path.join(self.dir1, "butler.yaml") 

2491 config = Config(configFile1) 

2492 config["root"] = self.dir1 

2493 configFile2 = os.path.join(self.dir2, "butler2.yaml") 

2494 config.dumpToUri(configFile2) 

2495 os.remove(configFile1) 

2496 self.tmpConfigFile = configFile2 

2497 

2498 def testFileLocations(self) -> None: 

2499 self.assertNotEqual(self.dir1, self.dir2) 

2500 self.assertTrue(os.path.exists(os.path.join(self.dir2, "butler2.yaml"))) 

2501 self.assertFalse(os.path.exists(os.path.join(self.dir1, "butler.yaml"))) 

2502 self.assertTrue(os.path.exists(os.path.join(self.dir1, "gen3.sqlite3"))) 

2503 

2504 

2505class ButlerMakeRepoOutfileTestCase(ButlerPutGetTests, unittest.TestCase): 

2506 """Test that a config file created by makeRepo outside of repo works.""" 

2507 

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

2509 

2510 def setUp(self) -> None: 

2511 self.root = makeTestTempDir(TESTDIR) 

2512 self.root2 = makeTestTempDir(TESTDIR) 

2513 

2514 self.tmpConfigFile = os.path.join(self.root2, "different.yaml") 

2515 Butler.makeRepo(self.root, config=Config(self.configFile), outfile=self.tmpConfigFile) 

2516 

2517 def tearDown(self) -> None: 

2518 if os.path.exists(self.root2): 

2519 shutil.rmtree(self.root2, ignore_errors=True) 

2520 super().tearDown() 

2521 

2522 def testConfigExistence(self) -> None: 

2523 c = Config(self.tmpConfigFile) 

2524 uri_config = ResourcePath(c["root"]) 

2525 uri_expected = ResourcePath(self.root, forceDirectory=True) 

2526 self.assertEqual(uri_config.geturl(), uri_expected.geturl()) 

2527 self.assertNotIn(":", uri_config.path, "Check for URI concatenated with normal path") 

2528 

2529 def testPutGet(self) -> None: 

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

2531 self.runPutGetTest(storageClass, "test_metric") 

2532 

2533 

2534class ButlerMakeRepoOutfileDirTestCase(ButlerMakeRepoOutfileTestCase): 

2535 """Test that a config file created by makeRepo outside of repo works.""" 

2536 

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

2538 

2539 def setUp(self) -> None: 

2540 self.root = makeTestTempDir(TESTDIR) 

2541 self.root2 = makeTestTempDir(TESTDIR) 

2542 

2543 self.tmpConfigFile = self.root2 

2544 Butler.makeRepo(self.root, config=Config(self.configFile), outfile=self.tmpConfigFile) 

2545 

2546 def testConfigExistence(self) -> None: 

2547 # Append the yaml file else Config constructor does not know the file 

2548 # type. 

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

2550 super().testConfigExistence() 

2551 

2552 

2553class ButlerMakeRepoOutfileUriTestCase(ButlerMakeRepoOutfileTestCase): 

2554 """Test that a config file created by makeRepo outside of repo works.""" 

2555 

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

2557 

2558 def setUp(self) -> None: 

2559 self.root = makeTestTempDir(TESTDIR) 

2560 self.root2 = makeTestTempDir(TESTDIR) 

2561 

2562 self.tmpConfigFile = ResourcePath(os.path.join(self.root2, "something.yaml")).geturl() 

2563 Butler.makeRepo(self.root, config=Config(self.configFile), outfile=self.tmpConfigFile) 

2564 

2565 

2566@unittest.skipIf(not boto3, "Warning: boto3 AWS SDK not found!") 

2567class S3DatastoreButlerTestCase(FileDatastoreButlerTests, unittest.TestCase): 

2568 """S3Datastore specialization of a butler; an S3 storage Datastore + 

2569 a local in-memory SqlRegistry. 

2570 """ 

2571 

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

2573 fullConfigKey = None 

2574 validationCanFail = True 

2575 

2576 bucketName = "anybucketname" 

2577 """Name of the Bucket that will be used in the tests. The name is read from 

2578 the config file used with the tests during set-up. 

2579 """ 

2580 

2581 root = "butlerRoot/" 

2582 """Root repository directory expected to be used in case useTempRoot=False. 

2583 Otherwise the root is set to a 20 characters long randomly generated string 

2584 during set-up. 

2585 """ 

2586 

2587 datastoreStr = [f"datastore={root}"] 

2588 """Contains all expected root locations in a format expected to be 

2589 returned by Butler stringification. 

2590 """ 

2591 

2592 datastoreName = ["FileDatastore@s3://{bucketName}/{root}"] 

2593 """The expected format of the S3 Datastore string.""" 

2594 

2595 registryStr = "/gen3.sqlite3" 

2596 """Expected format of the Registry string.""" 

2597 

2598 mock_aws = mock_aws() 

2599 """The mocked s3 interface from moto.""" 

2600 

2601 def genRoot(self) -> str: 

2602 """Return a random string of len 20 to serve as a root 

2603 name for the temporary bucket repo. 

2604 

2605 This is equivalent to tempfile.mkdtemp as this is what self.root 

2606 becomes when useTempRoot is True. 

2607 """ 

2608 rndstr = "".join(random.choice(string.ascii_uppercase + string.digits) for _ in range(20)) 

2609 return rndstr + "/" 

2610 

2611 def setUp(self) -> None: 

2612 config = Config(self.configFile) 

2613 uri = ResourcePath(config[".datastore.datastore.root"]) 

2614 self.bucketName = uri.netloc 

2615 

2616 # Enable S3 mocking of tests. 

2617 self.enterContext(clean_test_environment_for_s3()) 

2618 self.mock_aws.start() 

2619 

2620 if self.useTempRoot: 

2621 self.root = self.genRoot() 

2622 rooturi = f"s3://{self.bucketName}/{self.root}" 

2623 config.update({"datastore": {"datastore": {"root": rooturi}}}) 

2624 

2625 # need local folder to store registry database 

2626 self.reg_dir = makeTestTempDir(TESTDIR) 

2627 config["registry", "db"] = f"sqlite:///{self.reg_dir}/gen3.sqlite3" 

2628 

2629 # MOTO needs to know that we expect Bucket bucketname to exist 

2630 # (this used to be the class attribute bucketName) 

2631 s3 = boto3.resource("s3") 

2632 s3.create_bucket(Bucket=self.bucketName) 

2633 

2634 self.datastoreStr = [f"datastore='{rooturi}'"] 

2635 self.datastoreName = [f"FileDatastore@{rooturi}"] 

2636 Butler.makeRepo(rooturi, config=config, forceConfigRoot=False) 

2637 self.tmpConfigFile = posixpath.join(rooturi, "butler.yaml") 

2638 

2639 def tearDown(self) -> None: 

2640 s3 = boto3.resource("s3") 

2641 bucket = s3.Bucket(self.bucketName) 

2642 try: 

2643 bucket.objects.all().delete() 

2644 except botocore.exceptions.ClientError as e: 

2645 if e.response["Error"]["Code"] == "404": 

2646 # the key was not reachable - pass 

2647 pass 

2648 else: 

2649 raise 

2650 

2651 bucket = s3.Bucket(self.bucketName) 

2652 bucket.delete() 

2653 

2654 # Stop the S3 mock. 

2655 self.mock_aws.stop() 

2656 

2657 if self.reg_dir is not None and os.path.exists(self.reg_dir): 

2658 shutil.rmtree(self.reg_dir, ignore_errors=True) 

2659 

2660 if self.useTempRoot and os.path.exists(self.root): 

2661 shutil.rmtree(self.root, ignore_errors=True) 

2662 

2663 super().tearDown() 

2664 

2665 

2666class DatastoreTransfers(TestCaseMixin): 

2667 """Base test setup for data transfers between butlers. The concrete tests 

2668 for specific configurations are in other classes, below. 

2669 """ 

2670 

2671 storageClassFactory: StorageClassFactory 

2672 

2673 @classmethod 

2674 def setUpClass(cls) -> None: 

2675 cls.storageClassFactory = StorageClassFactory() 

2676 

2677 def setUp(self) -> None: 

2678 self.root = makeTestTempDir(TESTDIR) 

2679 self.config = Config(self.configFile) 

2680 

2681 # Some tests cause convertors to be replaced so ensure 

2682 # the storage class factory is reset each time. 

2683 self.storageClassFactory.reset() 

2684 self.storageClassFactory.addFromConfig(self.configFile) 

2685 

2686 def tearDown(self) -> None: 

2687 removeTestTempDir(self.root) 

2688 

2689 def create_butler(self, manager: str | None, label: str, config_file: str | None = None) -> Butler: 

2690 if manager is None: 

2691 manager = ( 

2692 "lsst.daf.butler.registry.datasets.byDimensions.ByDimensionsDatasetRecordStorageManagerUUID" 

2693 ) 

2694 config = Config(config_file if config_file is not None else self.configFile) 

2695 config["registry", "managers", "datasets"] = manager 

2696 return Butler.from_config( 

2697 Butler.makeRepo(f"{self.root}/butler{label}", config=config), writeable=True 

2698 ) 

2699 

2700 def assertButlerTransfers( 

2701 self, 

2702 purge: bool = False, 

2703 storageClassName: str = "StructuredData", 

2704 storageClassNameTarget: str | None = None, 

2705 ) -> None: 

2706 """Test that a run can be transferred to another butler.""" 

2707 storageClass = self.storageClassFactory.getStorageClass(storageClassName) 

2708 if storageClassNameTarget is not None: 

2709 storageClassTarget = self.storageClassFactory.getStorageClass(storageClassNameTarget) 

2710 else: 

2711 storageClassTarget = storageClass 

2712 

2713 datasetTypeName = "random_data" 

2714 

2715 # Test will create 3 collections and we will want to transfer 

2716 # two of those three. 

2717 runs = ["run1", "run2", "other"] 

2718 

2719 # Also want to use two different dataset types to ensure that 

2720 # grouping works. 

2721 datasetTypeNames = ["random_data", "random_data_2"] 

2722 

2723 # Create the run collections in the source butler. 

2724 for run in runs: 

2725 self.source_butler.collections.register(run) 

2726 

2727 # Create dimensions in source butler. 

2728 n_exposures = 30 

2729 self.source_butler.registry.insertDimensionData("instrument", {"name": "DummyCamComp"}) 

2730 self.source_butler.registry.insertDimensionData( 

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

2732 ) 

2733 self.source_butler.registry.insertDimensionData( 

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

2735 ) 

2736 self.source_butler.registry.insertDimensionData( 

2737 "day_obs", 

2738 { 

2739 "instrument": "DummyCamComp", 

2740 "id": 20250101, 

2741 }, 

2742 ) 

2743 

2744 for i in range(n_exposures): 

2745 self.source_butler.registry.insertDimensionData( 

2746 "group", {"instrument": "DummyCamComp", "name": f"group{i}"} 

2747 ) 

2748 self.source_butler.registry.insertDimensionData( 

2749 "exposure", 

2750 { 

2751 "instrument": "DummyCamComp", 

2752 "id": i, 

2753 "obs_id": f"exp{i}", 

2754 "physical_filter": "d-r", 

2755 "group": f"group{i}", 

2756 "day_obs": 20250101, 

2757 }, 

2758 ) 

2759 

2760 # Create dataset types in the source butler. 

2761 dimensions = self.source_butler.dimensions.conform(["instrument", "exposure"]) 

2762 for datasetTypeName in datasetTypeNames: 

2763 datasetType = DatasetType(datasetTypeName, dimensions, storageClass) 

2764 self.source_butler.registry.registerDatasetType(datasetType) 

2765 

2766 # Write a dataset to an unrelated run -- this will ensure that 

2767 # we are rewriting integer dataset ids in the target if necessary. 

2768 # Will not be relevant for UUID. 

2769 run = "distraction" 

2770 butler = Butler.from_config(butler=self.source_butler, run=run) 

2771 butler.put( 

2772 makeExampleMetrics(), 

2773 datasetTypeName, 

2774 exposure=1, 

2775 instrument="DummyCamComp", 

2776 physical_filter="d-r", 

2777 ) 

2778 

2779 # Write some example metrics to the source 

2780 butler = Butler.from_config(butler=self.source_butler) 

2781 

2782 # Set of DatasetRefs that should be in the list of refs to transfer 

2783 # but which will not be transferred. 

2784 deleted: set[DatasetRef] = set() 

2785 

2786 n_expected = 20 # Number of datasets expected to be transferred 

2787 source_refs = [] 

2788 for i in range(n_exposures): 

2789 # Put a third of datasets into each collection, only retain 

2790 # two thirds. 

2791 index = i % 3 

2792 run = runs[index] 

2793 datasetTypeName = datasetTypeNames[i % 2] 

2794 

2795 metric = MetricsExample( 

2796 summary={"counter": i}, output={"text": "metric"}, data=[2 * x for x in range(i)] 

2797 ) 

2798 dataId = {"exposure": i, "instrument": "DummyCamComp", "physical_filter": "d-r"} 

2799 ref = butler.put(metric, datasetTypeName, dataId=dataId, run=run) 

2800 

2801 # Remove the datastore record using low-level API, but only 

2802 # for a specific index. 

2803 if purge and index == 1: 

2804 # For one of these delete the file as well. 

2805 # This allows the "missing" code to filter the 

2806 # file out. 

2807 # Access the individual datastores. 

2808 datastores = [] 

2809 if hasattr(butler._datastore, "datastores"): 

2810 datastores.extend(butler._datastore.datastores) 

2811 else: 

2812 datastores.append(butler._datastore) 

2813 

2814 if not deleted: 

2815 # For a chained datastore we need to remove 

2816 # files in each chain. 

2817 for datastore in datastores: 

2818 # The file might not be known to the datastore 

2819 # if constraints are used. 

2820 try: 

2821 primary, uris = datastore.getURIs(ref) 

2822 except FileNotFoundError: 

2823 continue 

2824 if primary and primary.scheme != "mem": 

2825 primary.remove() 

2826 for uri in uris.values(): 

2827 if uri.scheme != "mem": 

2828 uri.remove() 

2829 n_expected -= 1 

2830 deleted.add(ref) 

2831 

2832 # Remove the datastore record. 

2833 for datastore in datastores: 

2834 if hasattr(datastore, "removeStoredItemInfo"): 

2835 datastore.removeStoredItemInfo(ref) 

2836 

2837 if index < 2: 

2838 source_refs.append(ref) 

2839 if ref not in deleted: 

2840 new_metric = butler.get(ref) 

2841 self.assertEqual(new_metric, metric) 

2842 

2843 # Create some bad dataset types to ensure we check for inconsistent 

2844 # definitions. 

2845 badStorageClass = self.storageClassFactory.getStorageClass("StructuredDataList") 

2846 for datasetTypeName in datasetTypeNames: 

2847 datasetType = DatasetType(datasetTypeName, dimensions, badStorageClass) 

2848 self.target_butler.registry.registerDatasetType(datasetType) 

2849 with self.assertRaises(ConflictingDefinitionError) as cm: 

2850 self.target_butler.transfer_from(self.source_butler, source_refs) 

2851 self.assertIn("dataset type differs", str(cm.exception)) 

2852 

2853 # And remove the bad definitions. 

2854 for datasetTypeName in datasetTypeNames: 

2855 self.target_butler.registry.removeDatasetType(datasetTypeName) 

2856 

2857 # Transfer without creating dataset types should fail. 

2858 with self.assertRaises(KeyError): 

2859 self.target_butler.transfer_from(self.source_butler, source_refs) 

2860 

2861 # Transfer without creating dimensions should fail. 

2862 with self.assertRaises(ConflictingDefinitionError) as cm: 

2863 self.target_butler.transfer_from(self.source_butler, source_refs, register_dataset_types=True) 

2864 self.assertIn("dimension", str(cm.exception)) 

2865 

2866 # The dry run test requires dataset types to exist. If we have 

2867 # been given distinct storage classes for the target we have 

2868 # to redefine at least one of the dataset types in the target butler. 

2869 if storageClass != storageClassTarget: 

2870 self.target_butler.registry.removeDatasetType(datasetTypeNames[0]) 

2871 datasetType = DatasetType(datasetTypeNames[0], dimensions, storageClassTarget) 

2872 self.target_butler.registry.registerDatasetType(datasetType) 

2873 

2874 # The failed transfer above leaves registry in an inconsistent 

2875 # state because the run is created but then rolled back without 

2876 # the collection cache being cleared. For now force a refresh. 

2877 # Can remove with DM-35498. 

2878 self.target_butler.registry.refresh() 

2879 

2880 # Do a dry run -- this should not have any effect on the target butler. 

2881 self.target_butler.transfer_from(self.source_butler, source_refs, dry_run=True) 

2882 

2883 # Transfer the records for one ref to test the alternative API. 

2884 with self.assertLogs(logger="lsst", level=logging.DEBUG) as log_cm: 

2885 self.target_butler.transfer_dimension_records_from(self.source_butler, [source_refs[0]]) 

2886 self.assertIn("number of records transferred: 1", ";".join(log_cm.output)) 

2887 

2888 # Now transfer them to the second butler, including dimensions. 

2889 with self.assertLogs(logger="lsst", level=logging.DEBUG) as log_cm: 

2890 transferred = self.target_butler.transfer_from( 

2891 self.source_butler, 

2892 source_refs, 

2893 register_dataset_types=True, 

2894 transfer_dimensions=True, 

2895 ) 

2896 self.assertEqual(len(transferred), n_expected) 

2897 log_output = ";".join(log_cm.output) 

2898 

2899 # A ChainedDatastore will use the in-memory datastore for mexists 

2900 # so we can not rely on the mexists log message. 

2901 self.assertIn("Number of datastore records found in source", log_output) 

2902 self.assertIn("Creating output run", log_output) 

2903 

2904 # Do the transfer twice to ensure that it will do nothing extra. 

2905 # Only do this if purge=True because it does not work for int 

2906 # dataset_id. 

2907 if purge: 

2908 # This should not need to register dataset types. 

2909 transferred = self.target_butler.transfer_from(self.source_butler, source_refs) 

2910 self.assertEqual(len(transferred), n_expected) 

2911 

2912 with self.assertRaises((TypeError, AttributeError)): 

2913 self.target_butler._datastore.transfer_from(self.source_butler, source_refs) # type: ignore 

2914 

2915 with self.assertRaises(ValueError): 

2916 self.target_butler._datastore.transfer_from( 

2917 self.source_butler._datastore, source_refs, transfer="split" 

2918 ) 

2919 

2920 # Now try to get the same refs from the new butler. 

2921 for ref in source_refs: 

2922 if ref not in deleted: 

2923 new_metric = self.target_butler.get(ref) 

2924 old_metric = self.source_butler.get(ref) 

2925 self.assertEqual(new_metric, old_metric) 

2926 

2927 # Try again without implicit storage class conversion 

2928 # triggered by using the source ref. This will do conversion 

2929 # since the formatter will be returning the source python type. 

2930 target_ref = self.target_butler.get_dataset(ref.id) 

2931 if target_ref.datasetType.storageClass != ref.datasetType.storageClass: 

2932 new_metric = self.target_butler.get(target_ref) 

2933 self.assertNotEqual(type(new_metric), type(old_metric)) 

2934 

2935 # Remove the dataset from the target and put it again 

2936 # as if it was the right type all along for this butler. 

2937 self.target_butler.pruneDatasets( 

2938 [target_ref], unstore=True, purge=True, disassociate=True 

2939 ) 

2940 self.target_butler.put(new_metric, target_ref) 

2941 new_new_metric = self.target_butler.get(target_ref) 

2942 new_old_metric = self.target_butler.get( 

2943 target_ref, storageClass=ref.datasetType.storageClass 

2944 ) 

2945 self.assertEqual(new_new_metric, new_metric) 

2946 self.assertEqual(new_old_metric, old_metric) 

2947 

2948 # Now prune run2 collection and create instead a CHAINED collection. 

2949 # This should block the transfer. 

2950 self.target_butler.removeRuns(["run2"]) 

2951 self.target_butler.collections.register("run2", CollectionType.CHAINED) 

2952 with self.assertRaises(CollectionTypeError): 

2953 # Re-importing the run1 datasets can be problematic if they 

2954 # use integer IDs so filter those out. 

2955 to_transfer = [ref for ref in source_refs if ref.run == "run2"] 

2956 self.target_butler.transfer_from(self.source_butler, to_transfer) 

2957 

2958 

2959class PosixDatastoreTransfers(DatastoreTransfers, unittest.TestCase): 

2960 """Test data transfers between butlers. 

2961 

2962 Test for different managers. UUID to UUID and integer to integer are 

2963 tested. UUID to integer is not supported since we do not currently 

2964 want to allow that. Integer to UUID is supported with the caveat 

2965 that UUID4 will be generated and this will be incorrect for raw 

2966 dataset types. The test ignores that. 

2967 """ 

2968 

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

2970 

2971 def create_butlers( 

2972 self, manager1: str | None = None, manager2: str | None = None, source_config: str | None = None 

2973 ) -> None: 

2974 self.source_butler = self.create_butler(manager1, "1", config_file=source_config) 

2975 self.target_butler = self.create_butler(manager2, "2") 

2976 

2977 def testTransferUuidToUuid(self) -> None: 

2978 self.create_butlers() 

2979 self.assertButlerTransfers() 

2980 

2981 def testTransferFromChainedUuidToUuid(self) -> None: 

2982 """Force the source butler to be a ChainedDatastore.""" 

2983 self.create_butlers(source_config=os.path.join(TESTDIR, "config/basic/butler-chained.yaml")) 

2984 self.assertButlerTransfers() 

2985 

2986 def testTransferFromIncompatibleUuidToUuid(self) -> None: 

2987 """Force the source butler to be a incompatible datastore.""" 

2988 self.create_butlers(source_config=os.path.join(TESTDIR, "config/basic/butler-inmemory.yaml")) 

2989 with self.assertRaises(NotImplementedError): 

2990 self.assertButlerTransfers() 

2991 

2992 def testTransferFromIncompatibleChainUuidToUuid(self) -> None: 

2993 """Force the source butler to be a incompatible datastore.""" 

2994 self.create_butlers(source_config=os.path.join(TESTDIR, "config/basic/butler-inmemory-chain.yaml")) 

2995 with self.assertRaises(TypeError): 

2996 self.assertButlerTransfers() 

2997 

2998 def testTransferFromFileUuidToUuid(self) -> None: 

2999 """Force the source butler to be a FileDatastore.""" 

3000 self.create_butlers(source_config=os.path.join(TESTDIR, "config/basic/butler.yaml")) 

3001 self.assertButlerTransfers() 

3002 

3003 def testTransferMissing(self) -> None: 

3004 """Test transfers where datastore records are missing. 

3005 

3006 This is how execution butler works. 

3007 """ 

3008 self.create_butlers() 

3009 

3010 # Configure the source butler to allow trust. 

3011 self.source_butler._datastore._set_trust_mode(True) 

3012 

3013 self.assertButlerTransfers(purge=True) 

3014 

3015 def testTransferMissingDisassembly(self) -> None: 

3016 """Test transfers where datastore records are missing. 

3017 

3018 This is how execution butler works. 

3019 """ 

3020 self.create_butlers() 

3021 

3022 # Configure the source butler to allow trust. 

3023 self.source_butler._datastore._set_trust_mode(True) 

3024 

3025 # Test disassembly. 

3026 self.assertButlerTransfers(purge=True, storageClassName="StructuredComposite") 

3027 

3028 def testTransferDifferingStorageClasses(self) -> None: 

3029 """Test transfers when the source butler dataset type has a different 

3030 but compatible storage class. 

3031 """ 

3032 self.create_butlers() 

3033 

3034 self.assertButlerTransfers(storageClassNameTarget="MetricsConversion") 

3035 

3036 def testTransferDifferingStorageClassesDisassembly(self) -> None: 

3037 """Test transfers when the source butler dataset type has a different 

3038 but compatible storage class and where the source butler has 

3039 disassembled. 

3040 """ 

3041 self.create_butlers() 

3042 

3043 self.assertButlerTransfers( 

3044 storageClassName="StructuredComposite", storageClassNameTarget="MetricsConversion" 

3045 ) 

3046 

3047 def testAbsoluteURITransferDirect(self) -> None: 

3048 """Test transfer using an absolute URI.""" 

3049 self._absolute_transfer("auto") 

3050 

3051 def testAbsoluteURITransferCopy(self) -> None: 

3052 """Test transfer using an absolute URI.""" 

3053 self._absolute_transfer("copy") 

3054 

3055 def _absolute_transfer(self, transfer: str) -> None: 

3056 self.create_butlers() 

3057 

3058 storageClassName = "StructuredData" 

3059 storageClass = self.storageClassFactory.getStorageClass(storageClassName) 

3060 datasetTypeName = "random_data" 

3061 run = "run1" 

3062 self.source_butler.collections.register(run) 

3063 

3064 dimensions = self.source_butler.dimensions.conform(()) 

3065 datasetType = DatasetType(datasetTypeName, dimensions, storageClass) 

3066 self.source_butler.registry.registerDatasetType(datasetType) 

3067 

3068 metrics = makeExampleMetrics() 

3069 with ResourcePath.temporary_uri(suffix=".json") as temp: 

3070 dataId = DataCoordinate.make_empty(self.source_butler.dimensions) 

3071 source_refs = [DatasetRef(datasetType, dataId, run=run)] 

3072 temp.write(json.dumps(metrics.exportAsDict()).encode()) 

3073 dataset = FileDataset(path=temp, refs=source_refs) 

3074 self.source_butler.ingest(dataset, transfer="direct") 

3075 

3076 self.target_butler.transfer_from( 

3077 self.source_butler, dataset.refs, register_dataset_types=True, transfer=transfer 

3078 ) 

3079 

3080 uri = self.target_butler.getURI(dataset.refs[0]) 

3081 if transfer == "auto": 

3082 self.assertEqual(uri, temp) 

3083 else: 

3084 self.assertNotEqual(uri, temp) 

3085 

3086 def test_shared_dimension_group(self): 

3087 """Test internal logic that divides dataset types by dimension group 

3088 when doing registry updates. 

3089 """ 

3090 self.create_butlers() 

3091 self.source_butler.import_(filename=_get_test_data_path("base.yaml"), without_datastore=True) 

3092 self.source_butler.import_(filename=_get_test_data_path("datasets.yaml"), without_datastore=True) 

3093 

3094 source_butler = self.source_butler 

3095 target_butler = self.target_butler 

3096 

3097 # Create a dataset type with the same dimensions as the 'bias' dataset 

3098 # type from base.yaml 

3099 dataset_type = DatasetType( 

3100 "test_type", ["instrument", "detector"], "int", universe=source_butler.dimensions 

3101 ) 

3102 source_butler.registry.registerDatasetType(dataset_type) 

3103 # This has the same data ID as one of the bias datasets in 

3104 # datasets.yaml. 

3105 test_ref = source_butler.registry.insertDatasets( 

3106 "test_type", [{"instrument": "Cam1", "detector": 2}], run="imported_g" 

3107 )[0] 

3108 

3109 biases = source_butler.query_datasets("bias", ["imported_g", "imported_r"]) 

3110 flats = source_butler.query_datasets("flat", ["imported_g", "imported_r"]) 

3111 refs = [test_ref, *biases, *flats] 

3112 

3113 # Test setup will be even more convoluted if we want the datastore to 

3114 # actually transfer files. For testing the dimension group behavior, 

3115 # we really only care about the registry. 

3116 with unittest.mock.patch.object(target_butler._datastore, "transfer_from") as mock: 

3117 mock.return_value = (set(refs), set()) 

3118 target_butler.transfer_from( 

3119 source_butler, 

3120 refs, 

3121 transfer=None, 

3122 register_dataset_types=True, 

3123 skip_missing=False, 

3124 transfer_dimensions=True, 

3125 ) 

3126 

3127 transferred_test_ref = target_butler.find_dataset( 

3128 "test_type", {"instrument": "Cam1", "detector": 2}, collections="imported_g" 

3129 ) 

3130 self.assertEqual(transferred_test_ref.id, test_ref.id) 

3131 

3132 transferred_bias = target_butler.find_dataset( 

3133 "bias", {"instrument": "Cam1", "detector": 2}, collections="imported_g" 

3134 ) 

3135 self.assertEqual(transferred_bias.id, uuid.UUID("51352db4-a47a-447c-b12d-a50b206b17cd")) 

3136 

3137 transferred_flat = target_butler.find_dataset( 

3138 "flat", 

3139 {"instrument": "Cam1", "detector": 2, "physical_filter": "Cam1-R1", "band": "r"}, 

3140 collections="imported_r", 

3141 ) 

3142 self.assertEqual(transferred_flat.id, uuid.UUID("c1296796-56c5-4acf-9b49-40d920c6f840")) 

3143 

3144 

3145class ChainedDatastoreTransfers(PosixDatastoreTransfers): 

3146 """Test transfers using a chained datastore.""" 

3147 

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

3149 

3150 

3151@unittest.skipIf(not butler_server_is_available, butler_server_import_error) 

3152class ButlerServerDatastoreTransfers(DatastoreTransfers, unittest.TestCase): 

3153 """Test ``transfer_from`` involving Butler server.""" 

3154 

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

3156 

3157 def test_transfers_from_remote_to_direct(self) -> None: 

3158 from lsst.daf.butler.remote_butler._remote_file_transfer_source import ( 

3159 mock_file_transfer_uris_for_unit_test, 

3160 ) 

3161 

3162 self.target_butler = self.create_butler(None, "2") 

3163 with create_test_server(TESTDIR) as server: 

3164 self.source_butler = server.hybrid_butler 

3165 

3166 def _remap_transfer_url(path: HttpResourcePath) -> HttpResourcePath: 

3167 # The Butler server returns HTTP URIs with a domain name that 

3168 # is not resolvable because there is no actual HTTP server 

3169 # involved in these tests. Strip this first layer of 

3170 # indirection, and return the target of the redirect instead. 

3171 response = server.client.get(str(path), follow_redirects=False, headers=path._extra_headers) 

3172 return ResourcePath(str(response.next_request.url)) 

3173 

3174 with mock_file_transfer_uris_for_unit_test(_remap_transfer_url): 

3175 self.assertButlerTransfers() 

3176 

3177 

3178class NullDatastoreTestCase(unittest.TestCase): 

3179 """Test that we can fall back to a null datastore.""" 

3180 

3181 # Need a good config to create the repo. 

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

3183 storageClassFactory: StorageClassFactory 

3184 

3185 @classmethod 

3186 def setUpClass(cls) -> None: 

3187 cls.storageClassFactory = StorageClassFactory() 

3188 cls.storageClassFactory.addFromConfig(cls.configFile) 

3189 

3190 def setUp(self) -> None: 

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

3192 self.root = makeTestTempDir(TESTDIR) 

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

3194 

3195 def tearDown(self) -> None: 

3196 removeTestTempDir(self.root) 

3197 

3198 def test_fallback(self) -> None: 

3199 # Read the butler config and mess with the datastore section. 

3200 config_path = os.path.join(self.root, "butler.yaml") 

3201 bad_config = Config(config_path) 

3202 bad_config["datastore", "cls"] = "lsst.not.a.datastore.Datastore" 

3203 bad_config.dumpToUri(config_path) 

3204 

3205 with self.assertRaises(RuntimeError): 

3206 Butler(self.root, without_datastore=False) 

3207 

3208 with self.assertRaises(RuntimeError): 

3209 Butler.from_config(self.root, without_datastore=False) 

3210 

3211 butler = Butler.from_config(self.root, writeable=True, without_datastore=True) 

3212 self.assertIsInstance(butler._datastore, NullDatastore) 

3213 

3214 # Check that registry is working. 

3215 butler.collections.register("MYRUN") 

3216 collections = butler.collections.query("*") 

3217 self.assertIn("MYRUN", set(collections)) 

3218 

3219 # Create a ref. 

3220 dimensions = butler.dimensions.conform([]) 

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

3222 datasetTypeName = "metric" 

3223 datasetType = DatasetType(datasetTypeName, dimensions, storageClass) 

3224 butler.registry.registerDatasetType(datasetType) 

3225 ref = DatasetRef(datasetType, {}, run="MYRUN") 

3226 

3227 # Check that datastore will complain. 

3228 with self.assertRaises(FileNotFoundError): 

3229 butler.get(ref) 

3230 with self.assertRaises(FileNotFoundError): 

3231 butler.getURI(ref) 

3232 

3233 

3234@unittest.skipIf(not butler_server_is_available, butler_server_import_error) 

3235class ButlerServerTests(FileDatastoreButlerTests): 

3236 """Test RemoteButler and Butler server.""" 

3237 

3238 configFile = None 

3239 predictionSupported = False 

3240 trustModeSupported = False 

3241 

3242 postgres: TemporaryPostgresInstance | None 

3243 

3244 def setUp(self): 

3245 self.server_instance = self.enterContext(create_test_server(TESTDIR)) 

3246 

3247 def tearDown(self): 

3248 pass 

3249 

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

3251 # S3 pre-signed URLs may end up with differing expiration times in the 

3252 # query parameters, so ignore query parameters when comparing. 

3253 return uri1.scheme == uri2.scheme and uri1.netloc == uri2.netloc and uri1.path == uri2.path 

3254 

3255 def create_empty_butler( 

3256 self, run: str | None = None, writeable: bool | None = None, metrics: ButlerMetrics | None = None 

3257 ) -> Butler: 

3258 return self.server_instance.hybrid_butler.clone(run=run, metrics=metrics) 

3259 

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

3261 # Can't delete a file via S3 signed URLs, so we need to reach in 

3262 # through DirectButler to delete the dataset. 

3263 uri = self.server_instance.direct_butler.getURI(ref) 

3264 uri.remove() 

3265 

3266 def testConstructor(self): 

3267 # RemoteButler constructor is tested in test_server.py and 

3268 # test_remote_butler.py. 

3269 pass 

3270 

3271 def testDafButlerRepositories(self): 

3272 # Loading of RemoteButler via repository index is tested in 

3273 # test_server.py. 

3274 pass 

3275 

3276 def testGetDatasetTypes(self) -> None: 

3277 # This is mostly a test of validateConfiguration, which is for 

3278 # validating Datastore configuration and thus isn't relevant to 

3279 # RemoteButler. 

3280 pass 

3281 

3282 def testMakeRepo(self) -> None: 

3283 # Only applies to DirectButler. 

3284 pass 

3285 

3286 # Pickling not yet implemented for RemoteButler/HybridButler. 

3287 @unittest.expectedFailure 

3288 def testPickle(self) -> None: 

3289 return super().testPickle() 

3290 

3291 def testStringification(self) -> None: 

3292 self.assertEqual( 

3293 str(self.server_instance.remote_butler), 

3294 "RemoteButler(https://test.example/api/butler/repo/testrepo/)", 

3295 ) 

3296 

3297 def testTransaction(self) -> None: 

3298 # Transactions will never be supported for RemoteButler. 

3299 pass 

3300 

3301 def testPutTemplates(self) -> None: 

3302 # The Butler server instance is configured with different file naming 

3303 # templates than this test is expecting. 

3304 pass 

3305 

3306 

3307@unittest.skipIf(not butler_server_is_available, butler_server_import_error) 

3308class ButlerServerSqliteTests(ButlerServerTests, unittest.TestCase): 

3309 """Tests for RemoteButler's registry shim, with a SQLite DB backing the 

3310 server. 

3311 """ 

3312 

3313 postgres = None 

3314 

3315 

3316@unittest.skipIf(not butler_server_is_available, butler_server_import_error) 

3317class ButlerServerPostgresTests(ButlerServerTests, unittest.TestCase): 

3318 """Tests for RemoteButler's registry shim, with a Postgres DB backing the 

3319 server. 

3320 """ 

3321 

3322 @classmethod 

3323 def setUpClass(cls): 

3324 cls.postgres = cls.enterClassContext(setup_postgres_test_db()) 

3325 super().setUpClass() 

3326 

3327 

3328def setup_module(module: types.ModuleType) -> None: 

3329 """Set up the module for pytest.""" 

3330 clean_environment() 

3331 

3332 

3333def _get_test_data_path(filename: str) -> ResourcePath: 

3334 return ResourcePath(f"resource://lsst.daf.butler/tests/registry_data/{filename}") 

3335 

3336 

3337if __name__ == "__main__": 

3338 clean_environment() 

3339 unittest.main()