Coverage for python/lsst/daf/butler/direct_butler/_direct_butler.py: 10%

919 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"""Butler top level classes.""" 

29 

30from __future__ import annotations 

31 

32__all__ = ( 

33 "ButlerValidationError", 

34 "DirectButler", 

35) 

36 

37import collections.abc 

38import contextlib 

39import io 

40import itertools 

41import math 

42import numbers 

43import os 

44import uuid 

45import warnings 

46from collections import Counter, defaultdict 

47from collections.abc import Collection, Iterable, Iterator, MutableMapping, Sequence 

48from types import EllipsisType 

49from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, TextIO, cast 

50 

51from deprecated.sphinx import deprecated 

52from sqlalchemy.exc import IntegrityError 

53 

54from lsst.resources import ResourcePath, ResourcePathExpression 

55from lsst.utils.introspection import find_outside_stacklevel, get_class_of 

56from lsst.utils.iteration import chunk_iterable 

57from lsst.utils.logging import VERBOSE, getLogger 

58from lsst.utils.timer import time_this 

59 

60from .._butler import Butler, _DeprecatedDefault 

61from .._butler_config import ButlerConfig 

62from .._butler_instance_options import ButlerInstanceOptions 

63from .._butler_metrics import ButlerMetrics 

64from .._collection_type import CollectionType 

65from .._dataset_existence import DatasetExistence 

66from .._dataset_ref import DatasetRef 

67from .._dataset_type import DatasetType 

68from .._deferredDatasetHandle import DeferredDatasetHandle 

69from .._exceptions import DatasetNotFoundError, DimensionValueError, EmptyQueryResultError, ValidationError 

70from .._file_dataset import FileDataset 

71from .._limited_butler import LimitedButler 

72from .._query_all_datasets import QueryAllDatasetsParameters, query_all_datasets 

73from .._registry_shim import RegistryShim 

74from .._storage_class import StorageClass, StorageClassFactory 

75from .._timespan import Timespan 

76from ..datastore import Datastore, NullDatastore 

77from ..datastores.file_datastore.retrieve_artifacts import ZipIndex, retrieve_and_zip 

78from ..datastores.file_datastore.transfer import retrieve_file_transfer_records 

79from ..dimensions import DataCoordinate, Dimension, DimensionGroup 

80from ..direct_query_driver import DirectQueryDriver 

81from ..progress import Progress 

82from ..queries import Query 

83from ..registry import ( 

84 ConflictingDefinitionError, 

85 DataIdError, 

86 MissingDatasetTypeError, 

87 RegistryDefaults, 

88 _RegistryFactory, 

89) 

90from ..registry.sql_registry import SqlRegistry 

91from ..transfers import RepoExportContext 

92from ..utils import transactional 

93from ._direct_butler_collections import DirectButlerCollections 

94 

95if TYPE_CHECKING: 

96 from lsst.resources import ResourceHandleProtocol 

97 

98 from .._dataset_provenance import DatasetProvenance 

99 from .._dataset_ref import DatasetId 

100 from ..datastore import DatasetRefURIs 

101 from ..dimensions import DataId, DataIdValue, DimensionElement, DimensionRecord, DimensionUniverse 

102 from ..registry import CollectionArgType, Registry 

103 from ..transfers import RepoImportBackend 

104 

105_LOG = getLogger(__name__) 

106 

107 

108class ButlerValidationError(ValidationError): 

109 """There is a problem with the Butler configuration.""" 

110 

111 pass 

112 

113 

114class DirectButler(Butler): # numpydoc ignore=PR02 

115 """Main entry point for the data access system. 

116 

117 Parameters 

118 ---------- 

119 config : `ButlerConfig` 

120 The configuration for this Butler instance. 

121 registry : `SqlRegistry` 

122 The object that manages dataset metadata and relationships. 

123 datastore : Datastore 

124 The object that manages actual dataset storage. 

125 storageClasses : StorageClassFactory 

126 An object that maps known storage class names to objects that fully 

127 describe them. 

128 

129 Notes 

130 ----- 

131 Most users should call the top-level `Butler`.``from_config`` instead of 

132 using this constructor directly. 

133 """ 

134 

135 # This is __new__ instead of __init__ because we have to support 

136 # instantiation via the legacy constructor Butler.__new__(), which 

137 # reads the configuration and selects which subclass to instantiate. The 

138 # interaction between __new__ and __init__ is kind of wacky in Python. If 

139 # we were using __init__ here, __init__ would be called twice (once when 

140 # the DirectButler instance is constructed inside Butler.from_config(), and 

141 # a second time with the original arguments to Butler() when the instance 

142 # is returned from Butler.__new__() 

143 def __new__( 

144 cls, 

145 *, 

146 config: ButlerConfig, 

147 registry: SqlRegistry, 

148 datastore: Datastore, 

149 storageClasses: StorageClassFactory, 

150 metrics: ButlerMetrics | None = None, 

151 ) -> DirectButler: 

152 self = cast(DirectButler, super().__new__(cls)) 

153 self._config = config 

154 self._registry = registry 

155 self._datastore = datastore 

156 self.storageClasses = storageClasses 

157 self._metrics = metrics if metrics is not None else ButlerMetrics() 

158 

159 # For execution butler the datastore needs a special 

160 # dependency-inversion trick. This is not used by regular butler, 

161 # but we do not have a way to distinguish regular butler from execution 

162 # butler. 

163 self._datastore.set_retrieve_dataset_type_method(self._retrieve_dataset_type) 

164 

165 self._registry_shim = RegistryShim(self) 

166 

167 return self 

168 

169 @classmethod 

170 def create_from_config( 

171 cls, 

172 config: ButlerConfig, 

173 *, 

174 options: ButlerInstanceOptions, 

175 without_datastore: bool = False, 

176 ) -> DirectButler: 

177 """Construct a Butler instance from a configuration file. 

178 

179 Parameters 

180 ---------- 

181 config : `ButlerConfig` 

182 The configuration for this Butler instance. 

183 options : `ButlerInstanceOptions` 

184 Default values and other settings for the Butler instance. 

185 without_datastore : `bool`, optional 

186 If `True` do not attach a datastore to this butler. Any attempts 

187 to use a datastore will fail. 

188 

189 Notes 

190 ----- 

191 Most users should call the top-level `Butler`.``from_config`` 

192 instead of using this function directly. 

193 """ 

194 if "run" in config or "collection" in config: 

195 raise ValueError("Passing a run or collection via configuration is no longer supported.") 

196 

197 defaults = RegistryDefaults.from_butler_instance_options(options) 

198 try: 

199 butlerRoot = config.get("root", config.configDir) 

200 writeable = options.writeable 

201 if writeable is None: 

202 writeable = options.run is not None 

203 registry = _RegistryFactory(config).from_config( 

204 butlerRoot=butlerRoot, writeable=writeable, defaults=defaults 

205 ) 

206 if without_datastore: 

207 datastore: Datastore = NullDatastore(None, None) 

208 else: 

209 datastore = Datastore.fromConfig( 

210 config, registry.getDatastoreBridgeManager(), butlerRoot=butlerRoot 

211 ) 

212 # TODO: Once datastore drops dependency on registry we can 

213 # construct datastore first and pass opaque tables to registry 

214 # constructor. 

215 registry.make_datastore_tables(datastore.get_opaque_table_definitions()) 

216 storageClasses = StorageClassFactory() 

217 storageClasses.addFromConfig(config) 

218 

219 return DirectButler( 

220 config=config, 

221 registry=registry, 

222 datastore=datastore, 

223 storageClasses=storageClasses, 

224 metrics=options.metrics, 

225 ) 

226 except Exception: 

227 # Failures here usually mean that configuration is incomplete, 

228 # just issue an error message which includes config file URI. 

229 _LOG.error(f"Failed to instantiate Butler from config {config.configFile}.") 

230 raise 

231 

232 def clone( 

233 self, 

234 *, 

235 collections: CollectionArgType | None | EllipsisType = ..., 

236 run: str | None | EllipsisType = ..., 

237 inferDefaults: bool | EllipsisType = ..., 

238 dataId: dict[str, str] | EllipsisType = ..., 

239 metrics: ButlerMetrics | None = None, 

240 ) -> DirectButler: 

241 # Docstring inherited 

242 defaults = self._registry.defaults.clone(collections, run, inferDefaults, dataId) 

243 registry = self._registry.copy(defaults) 

244 

245 return DirectButler( 

246 registry=registry, 

247 config=self._config, 

248 datastore=self._datastore.clone(registry.getDatastoreBridgeManager()), 

249 storageClasses=self.storageClasses, 

250 metrics=metrics, 

251 ) 

252 

253 GENERATION: ClassVar[int] = 3 

254 """This is a Generation 3 Butler. 

255 

256 This attribute may be removed in the future, once the Generation 2 Butler 

257 interface has been fully retired; it should only be used in transitional 

258 code. 

259 """ 

260 

261 def _retrieve_dataset_type(self, name: str) -> DatasetType | None: 

262 """Return DatasetType defined in registry given dataset type name.""" 

263 try: 

264 return self.get_dataset_type(name) 

265 except MissingDatasetTypeError: 

266 return None 

267 

268 @classmethod 

269 def _unpickle( 

270 cls, 

271 config: ButlerConfig, 

272 collections: tuple[str, ...] | None, 

273 run: str | None, 

274 defaultDataId: dict[str, str], 

275 writeable: bool, 

276 ) -> DirectButler: 

277 """Callable used to unpickle a Butler. 

278 

279 We prefer not to use ``Butler.__init__`` directly so we can force some 

280 of its many arguments to be keyword-only (note that ``__reduce__`` 

281 can only invoke callables with positional arguments). 

282 

283 Parameters 

284 ---------- 

285 config : `ButlerConfig` 

286 Butler configuration, already coerced into a true `ButlerConfig` 

287 instance (and hence after any search paths for overrides have been 

288 utilized). 

289 collections : `tuple` [ `str` ] 

290 Names of the default collections to read from. 

291 run : `str`, optional 

292 Name of the default `~CollectionType.RUN` collection to write to. 

293 defaultDataId : `dict` [ `str`, `str` ] 

294 Default data ID values. 

295 writeable : `bool` 

296 Whether the Butler should support write operations. 

297 

298 Returns 

299 ------- 

300 butler : `Butler` 

301 A new `Butler` instance. 

302 """ 

303 return cls.create_from_config( 

304 config=config, 

305 options=ButlerInstanceOptions( 

306 collections=collections, run=run, writeable=writeable, kwargs=defaultDataId 

307 ), 

308 ) 

309 

310 def __reduce__(self) -> tuple: 

311 """Support pickling.""" 

312 return ( 

313 DirectButler._unpickle, 

314 ( 

315 self._config, 

316 self.collections.defaults, 

317 self.run, 

318 dict(self._registry.defaults.dataId.required), 

319 self._registry.isWriteable(), 

320 ), 

321 ) 

322 

323 def __str__(self) -> str: 

324 return ( 

325 f"Butler(collections={self.collections}, run={self.run}, " 

326 f"datastore='{self._datastore}', registry='{self._registry}')" 

327 ) 

328 

329 def isWriteable(self) -> bool: 

330 # Docstring inherited. 

331 return self._registry.isWriteable() 

332 

333 def _caching_context(self) -> contextlib.AbstractContextManager[None]: 

334 """Context manager that enables caching.""" 

335 return self._registry.caching_context() 

336 

337 @contextlib.contextmanager 

338 def transaction(self) -> Iterator[None]: 

339 """Context manager supporting `Butler` transactions. 

340 

341 Transactions can be nested. 

342 """ 

343 with self._registry.transaction(), self._datastore.transaction(): 

344 yield 

345 

346 def _standardizeArgs( 

347 self, 

348 datasetRefOrType: DatasetRef | DatasetType | str, 

349 dataId: DataId | None = None, 

350 for_put: bool = True, 

351 **kwargs: Any, 

352 ) -> tuple[DatasetType, DataId | None]: 

353 """Standardize the arguments passed to several Butler APIs. 

354 

355 Parameters 

356 ---------- 

357 datasetRefOrType : `DatasetRef`, `DatasetType`, or `str` 

358 When `DatasetRef` the `dataId` should be `None`. 

359 Otherwise the `DatasetType` or name thereof. 

360 dataId : `dict` or `DataCoordinate` 

361 A `dict` of `Dimension` link name, value pairs that label the 

362 `DatasetRef` within a Collection. When `None`, a `DatasetRef` 

363 should be provided as the second argument. 

364 for_put : `bool`, optional 

365 If `True` this call is invoked as part of a `Butler.put()`. 

366 Otherwise it is assumed to be part of a `Butler.get()`. This 

367 parameter is only relevant if there is dataset type 

368 inconsistency. 

369 **kwargs 

370 Additional keyword arguments used to augment or construct a 

371 `DataCoordinate`. See `DataCoordinate.standardize` 

372 parameters. 

373 

374 Returns 

375 ------- 

376 datasetType : `DatasetType` 

377 A `DatasetType` instance extracted from ``datasetRefOrType``. 

378 dataId : `dict` or `DataId`, optional 

379 Argument that can be used (along with ``kwargs``) to construct a 

380 `DataId`. 

381 

382 Notes 

383 ----- 

384 Butler APIs that conceptually need a DatasetRef also allow passing a 

385 `DatasetType` (or the name of one) and a `DataId` (or a dict and 

386 keyword arguments that can be used to construct one) separately. This 

387 method accepts those arguments and always returns a true `DatasetType` 

388 and a `DataId` or `dict`. 

389 

390 Standardization of `dict` vs `DataId` is best handled by passing the 

391 returned ``dataId`` (and ``kwargs``) to `Registry` APIs, which are 

392 generally similarly flexible. 

393 """ 

394 externalDatasetType: DatasetType | None = None 

395 internalDatasetType: DatasetType | None = None 

396 if isinstance(datasetRefOrType, DatasetRef): 

397 if dataId is not None or kwargs: 

398 raise ValueError("DatasetRef given, cannot use dataId as well") 

399 externalDatasetType = datasetRefOrType.datasetType 

400 dataId = datasetRefOrType.dataId 

401 else: 

402 # Don't check whether DataId is provided, because Registry APIs 

403 # can usually construct a better error message when it wasn't. 

404 if isinstance(datasetRefOrType, DatasetType): 

405 externalDatasetType = datasetRefOrType 

406 else: 

407 internalDatasetType = self.get_dataset_type(datasetRefOrType) 

408 

409 # Check that they are self-consistent 

410 if externalDatasetType is not None: 

411 internalDatasetType = self.get_dataset_type(externalDatasetType.name) 

412 if externalDatasetType != internalDatasetType: 

413 # We can allow differences if they are compatible, depending 

414 # on whether this is a get or a put. A get requires that 

415 # the python type associated with the datastore can be 

416 # converted to the user type. A put requires that the user 

417 # supplied python type can be converted to the internal 

418 # type expected by registry. 

419 relevantDatasetType = internalDatasetType 

420 if for_put: 

421 is_compatible = internalDatasetType.is_compatible_with(externalDatasetType) 

422 else: 

423 is_compatible = externalDatasetType.is_compatible_with(internalDatasetType) 

424 relevantDatasetType = externalDatasetType 

425 if not is_compatible: 

426 raise ValueError( 

427 f"Supplied dataset type ({externalDatasetType}) inconsistent with " 

428 f"registry definition ({internalDatasetType})" 

429 ) 

430 # Override the internal definition. 

431 internalDatasetType = relevantDatasetType 

432 

433 assert internalDatasetType is not None 

434 return internalDatasetType, dataId 

435 

436 def _rewrite_data_id( 

437 self, dataId: DataId | None, datasetType: DatasetType, **kwargs: Any 

438 ) -> tuple[DataId | None, dict[str, Any]]: 

439 """Rewrite a data ID taking into account dimension records. 

440 

441 Take a Data ID and keyword args and rewrite it if necessary to 

442 allow the user to specify dimension records rather than dimension 

443 primary values. 

444 

445 This allows a user to include a dataId dict with keys of 

446 ``exposure.day_obs`` and ``exposure.seq_num`` instead of giving 

447 the integer exposure ID. It also allows a string to be given 

448 for a dimension value rather than the integer ID if that is more 

449 convenient. For example, rather than having to specifying the 

450 detector with ``detector.full_name``, a string given for ``detector`` 

451 will be interpreted as the full name and converted to the integer 

452 value. 

453 

454 Keyword arguments can also use strings for dimensions like detector 

455 and exposure but python does not allow them to include ``.`` and 

456 so the ``exposure.day_obs`` syntax can not be used in a keyword 

457 argument. 

458 

459 Parameters 

460 ---------- 

461 dataId : `dict` or `DataCoordinate` 

462 A `dict` of `Dimension` link name, value pairs that will label the 

463 `DatasetRef` within a Collection. 

464 datasetType : `DatasetType` 

465 The dataset type associated with this dataId. Required to 

466 determine the relevant dimensions. 

467 **kwargs 

468 Additional keyword arguments used to augment or construct a 

469 `DataId`. See `DataId` parameters. 

470 

471 Returns 

472 ------- 

473 dataId : `dict` or `DataCoordinate` 

474 The, possibly rewritten, dataId. If given a `DataCoordinate` and 

475 no keyword arguments, the original dataId will be returned 

476 unchanged. 

477 **kwargs : `dict` 

478 Any unused keyword arguments (would normally be empty dict). 

479 """ 

480 # Process dimension records that are using record information 

481 # rather than ids 

482 newDataId: dict[str, DataIdValue] = {} 

483 byRecord: dict[str, dict[str, Any]] = defaultdict(dict) 

484 

485 if isinstance(dataId, DataCoordinate): 

486 # Do nothing if we have a DataCoordinate and no kwargs. 

487 if not kwargs: 

488 return dataId, kwargs 

489 # If we have a DataCoordinate with kwargs, we know the 

490 # DataCoordinate only has values for real dimensions. 

491 newDataId.update(dataId.mapping) 

492 elif dataId: 

493 # The data is mapping, which means it might have keys like 

494 # "exposure.obs_id" (unlike kwargs, because a "." is not allowed in 

495 # a keyword parameter). 

496 for k, v in dataId.items(): 

497 if isinstance(k, str) and "." in k: 

498 # Someone is using a more human-readable dataId 

499 dimensionName, record = k.split(".", 1) 

500 byRecord[dimensionName][record] = v 

501 else: 

502 newDataId[k] = v 

503 

504 # Go through the updated dataId and check the type in case someone is 

505 # using an alternate key. We have already filtered out the compound 

506 # keys dimensions.record format. 

507 not_dimensions = {} 

508 

509 # Will need to look in the dataId and the keyword arguments 

510 # and will remove them if they need to be fixed or are unrecognized. 

511 for dataIdDict in (newDataId, kwargs): 

512 # Use a list so we can adjust the dict safely in the loop 

513 for dimensionName in list(dataIdDict): 

514 value = dataIdDict[dimensionName] 

515 try: 

516 dimension = self.dimensions.dimensions[dimensionName] 

517 except KeyError: 

518 # This is not a real dimension 

519 not_dimensions[dimensionName] = value 

520 del dataIdDict[dimensionName] 

521 continue 

522 

523 # Convert an integral type to an explicit int to simplify 

524 # comparisons here 

525 if isinstance(value, numbers.Integral): 

526 value = int(value) 

527 

528 if not isinstance(value, dimension.primaryKey.getPythonType()): 

529 for alternate in dimension.alternateKeys: 

530 if isinstance(value, alternate.getPythonType()): 

531 byRecord[dimensionName][alternate.name] = value 

532 del dataIdDict[dimensionName] 

533 _LOG.debug( 

534 "Converting dimension %s to %s.%s=%s", 

535 dimensionName, 

536 dimensionName, 

537 alternate.name, 

538 value, 

539 ) 

540 break 

541 else: 

542 _LOG.warning( 

543 "Type mismatch found for value '%r' provided for dimension %s. " 

544 "Could not find matching alternative (primary key has type %s) " 

545 "so attempting to use as-is.", 

546 value, 

547 dimensionName, 

548 dimension.primaryKey.getPythonType(), 

549 ) 

550 

551 # By this point kwargs and newDataId should only include valid 

552 # dimensions. Merge kwargs in to the new dataId and log if there 

553 # are dimensions in both (rather than calling update). 

554 for k, v in kwargs.items(): 

555 if k in newDataId and newDataId[k] != v: 

556 _LOG.debug( 

557 "Keyword arg %s overriding explicit value in dataId of %s with %s", k, newDataId[k], v 

558 ) 

559 newDataId[k] = v 

560 # No need to retain any values in kwargs now. 

561 kwargs = {} 

562 

563 # If we have some unrecognized dimensions we have to try to connect 

564 # them to records in other dimensions. This is made more complicated 

565 # by some dimensions having records with clashing names. A mitigation 

566 # is that we can tell by this point which dimensions are missing 

567 # for the DatasetType but this does not work for calibrations 

568 # where additional dimensions can be used to constrain the temporal 

569 # axis. 

570 if not_dimensions: 

571 # Search for all dimensions even if we have been given a value 

572 # explicitly. In some cases records are given as well as the 

573 # actually dimension and this should not be an error if they 

574 # match. 

575 mandatoryDimensions = datasetType.dimensions.names # - provided 

576 

577 candidateDimensions: set[str] = set() 

578 candidateDimensions.update(mandatoryDimensions) 

579 

580 # For calibrations we may well be needing temporal dimensions 

581 # so rather than always including all dimensions in the scan 

582 # restrict things a little. It is still possible for there 

583 # to be confusion over day_obs in visit vs exposure for example. 

584 # If we are not searching calibration collections things may 

585 # fail but they are going to fail anyway because of the 

586 # ambiguousness of the dataId... 

587 if datasetType.isCalibration(): 

588 for dim in self.dimensions.dimensions: 

589 if dim.temporal: 

590 candidateDimensions.add(str(dim)) 

591 

592 # Look up table for the first association with a dimension 

593 guessedAssociation: dict[str, dict[str, Any]] = defaultdict(dict) 

594 

595 # Keep track of whether an item is associated with multiple 

596 # dimensions. 

597 counter: Counter[str] = Counter() 

598 assigned: dict[str, set[str]] = defaultdict(set) 

599 

600 # Go through the missing dimensions and associate the 

601 # given names with records within those dimensions 

602 matched_dims = set() 

603 for dimensionName in candidateDimensions: 

604 dimension = self.dimensions.dimensions[dimensionName] 

605 fields = dimension.metadata.names | dimension.uniqueKeys.names 

606 for field in not_dimensions: 

607 if field in fields: 

608 guessedAssociation[dimensionName][field] = not_dimensions[field] 

609 counter[dimensionName] += 1 

610 assigned[field].add(dimensionName) 

611 matched_dims.add(field) 

612 

613 # Calculate the fields that matched nothing. 

614 never_found = set(not_dimensions) - matched_dims 

615 

616 if never_found: 

617 raise DimensionValueError(f"Unrecognized keyword args given: {never_found}") 

618 

619 # There is a chance we have allocated a single dataId item 

620 # to multiple dimensions. Need to decide which should be retained. 

621 # For now assume that the most popular alternative wins. 

622 # This means that day_obs with seq_num will result in 

623 # exposure.day_obs and not visit.day_obs 

624 # Also prefer an explicitly missing dimension over an inferred 

625 # temporal dimension. 

626 for fieldName, assignedDimensions in assigned.items(): 

627 if len(assignedDimensions) > 1: 

628 # Pick the most popular (preferring mandatory dimensions) 

629 requiredButMissing = assignedDimensions.intersection(mandatoryDimensions) 

630 if requiredButMissing: 

631 candidateDimensions = requiredButMissing 

632 else: 

633 candidateDimensions = assignedDimensions 

634 

635 # If this is a choice between visit and exposure and 

636 # neither was a required part of the dataset type, 

637 # (hence in this branch) always prefer exposure over 

638 # visit since exposures are always defined and visits 

639 # are defined from exposures. 

640 if candidateDimensions == {"exposure", "visit"}: 

641 candidateDimensions = {"exposure"} 

642 

643 # Select the relevant items and get a new restricted 

644 # counter. 

645 theseCounts = {k: v for k, v in counter.items() if k in candidateDimensions} 

646 duplicatesCounter: Counter[str] = Counter() 

647 duplicatesCounter.update(theseCounts) 

648 

649 # Choose the most common. If they are equally common 

650 # we will pick the one that was found first. 

651 # Returns a list of tuples 

652 selected = duplicatesCounter.most_common(1)[0][0] 

653 

654 _LOG.debug( 

655 "Ambiguous dataId entry '%s' associated with multiple dimensions: %s." 

656 " Removed ambiguity by choosing dimension %s.", 

657 fieldName, 

658 ", ".join(assignedDimensions), 

659 selected, 

660 ) 

661 

662 for candidateDimension in assignedDimensions: 

663 if candidateDimension != selected: 

664 del guessedAssociation[candidateDimension][fieldName] 

665 

666 # Update the record look up dict with the new associations 

667 for dimensionName, values in guessedAssociation.items(): 

668 if values: # A dict might now be empty 

669 _LOG.debug( 

670 "Assigned non-dimension dataId keys to dimension %s: %s", dimensionName, values 

671 ) 

672 byRecord[dimensionName].update(values) 

673 

674 if byRecord: 

675 # Some record specifiers were found so we need to convert 

676 # them to the Id form 

677 for dimensionName, values in byRecord.items(): 

678 if dimensionName in newDataId: 

679 _LOG.debug( 

680 "DataId specified explicit %s dimension value of %s in addition to" 

681 " general record specifiers for it of %s. Checking for self-consistency.", 

682 dimensionName, 

683 newDataId[dimensionName], 

684 str(values), 

685 ) 

686 # Get the actual record and compare with these values. 

687 # Only query with relevant data ID values. 

688 filtered_data_id = { 

689 k: v for k, v in newDataId.items() if k in self.dimensions[dimensionName].required 

690 } 

691 try: 

692 recs = self.query_dimension_records( 

693 dimensionName, 

694 data_id=filtered_data_id, 

695 ) 

696 except (DataIdError, EmptyQueryResultError): 

697 raise DimensionValueError( 

698 f"Could not find dimension '{dimensionName}'" 

699 f" with dataId {filtered_data_id} as part of comparing with" 

700 f" record values {byRecord[dimensionName]}" 

701 ) from None 

702 if len(recs) == 1: 

703 errmsg: list[str] = [] 

704 for k, v in values.items(): 

705 if (recval := getattr(recs[0], k)) != v: 

706 errmsg.append(f"{k} ({recval} != {v})") 

707 if errmsg: 

708 raise DimensionValueError( 

709 f"Dimension {dimensionName} in dataId has explicit value" 

710 f" {newDataId[dimensionName]} inconsistent with" 

711 f" {dimensionName} dimension record: " + ", ".join(errmsg) 

712 ) 

713 else: 

714 # Multiple matches for an explicit dimension 

715 # should never happen but let downstream complain. 

716 pass 

717 continue 

718 

719 # Do not use data ID keys in query that aren't relevant. 

720 # Otherwise we can have detector queries being constrained 

721 # by an exposure ID that doesn't exist and return no matches 

722 # for a detector even though it's a good detector name. 

723 filtered_data_id = { 

724 k: v 

725 for k, v in newDataId.items() 

726 if k in self.dimensions[dimensionName].minimal_group.names 

727 } 

728 

729 def _get_attr(obj: Any, attr: str) -> Any: 

730 # Used to implement x.exposure.seq_num when given 

731 # x and "exposure.seq_num". 

732 for component in attr.split("."): 

733 obj = getattr(obj, component) 

734 return obj 

735 

736 with self.query() as q: 

737 x = q.expression_factory 

738 # Build up a WHERE expression. 

739 predicates = tuple(_get_attr(x, f"{dimensionName}.{k}") == v for k, v in values.items()) 

740 extra_args: dict[str, Any] = {} # For mypy. 

741 extra_args.update(filtered_data_id) 

742 extra_args.update(kwargs) 

743 q = q.where(x.all(*predicates), **extra_args) 

744 records = set(q.dimension_records(dimensionName)) 

745 

746 if len(records) != 1: 

747 if len(records) > 1: 

748 # visit can have an ambiguous answer without involving 

749 # visit_system. The default visit_system is defined 

750 # by the instrument. 

751 if ( 

752 dimensionName == "visit" 

753 and "visit_system_membership" in self.dimensions 

754 and "visit_system" in self.dimensions["instrument"].metadata 

755 ): 

756 instrument_records = self.query_dimension_records( 

757 "instrument", 

758 data_id=newDataId, 

759 explain=False, 

760 **kwargs, 

761 ) 

762 if len(instrument_records) == 1: 

763 visit_system = instrument_records[0].visit_system 

764 if visit_system is None: 

765 # Set to a value that will never match. 

766 visit_system = -1 

767 

768 # Look up each visit in the 

769 # visit_system_membership records. 

770 for rec in records: 

771 membership = self.query_dimension_records( 

772 # Use bind to allow zero results. 

773 # This is a fully-specified query. 

774 "visit_system_membership", 

775 instrument=instrument_records[0].name, 

776 visit_system=visit_system, 

777 visit=rec.id, 

778 explain=False, 

779 ) 

780 if membership: 

781 # This record is the right answer. 

782 records = {rec} 

783 break 

784 

785 # The ambiguity may have been resolved so check again. 

786 if len(records) > 1: 

787 _LOG.debug( 

788 "Received %d records from constraints of %s", len(records), str(values) 

789 ) 

790 for r in records: 

791 _LOG.debug("- %s", str(r)) 

792 raise DimensionValueError( 

793 f"DataId specification for dimension {dimensionName} is not" 

794 f" uniquely constrained to a single dataset by {values}." 

795 f" Got {len(records)} results." 

796 ) 

797 else: 

798 raise DimensionValueError( 

799 f"DataId specification for dimension {dimensionName} matched no" 

800 f" records when constrained by {values}" 

801 ) 

802 

803 # Get the primary key from the real dimension object 

804 dimension = self.dimensions.dimensions[dimensionName] 

805 if not isinstance(dimension, Dimension): 

806 raise RuntimeError( 

807 f"{dimension.name} is not a true dimension, and cannot be used in data IDs." 

808 ) 

809 newDataId[dimensionName] = getattr(records.pop(), dimension.primaryKey.name) 

810 

811 return newDataId, kwargs 

812 

813 def _findDatasetRef( 

814 self, 

815 datasetRefOrType: DatasetRef | DatasetType | str, 

816 dataId: DataId | None = None, 

817 *, 

818 collections: Any = None, 

819 predict: bool = False, 

820 run: str | None = None, 

821 datastore_records: bool = False, 

822 timespan: Timespan | None = None, 

823 **kwargs: Any, 

824 ) -> DatasetRef: 

825 """Shared logic for methods that start with a search for a dataset in 

826 the registry. 

827 

828 Parameters 

829 ---------- 

830 datasetRefOrType : `DatasetRef`, `DatasetType`, or `str` 

831 When `DatasetRef` the `dataId` should be `None`. 

832 Otherwise the `DatasetType` or name thereof. 

833 dataId : `dict` or `DataCoordinate`, optional 

834 A `dict` of `Dimension` link name, value pairs that label the 

835 `DatasetRef` within a Collection. When `None`, a `DatasetRef` 

836 should be provided as the first argument. 

837 collections : Any, optional 

838 Collections to be searched, overriding ``self.collections``. 

839 Can be any of the types supported by the ``collections`` argument 

840 to butler construction. 

841 predict : `bool`, optional 

842 If `True`, return a newly created `DatasetRef` with a unique 

843 dataset ID if finding a reference in the `Registry` fails. 

844 Defaults to `False`. 

845 run : `str`, optional 

846 Run collection name to use for creating `DatasetRef` for predicted 

847 datasets. Only used if ``predict`` is `True`. 

848 datastore_records : `bool`, optional 

849 If `True` add datastore records to returned `DatasetRef`. 

850 timespan : `Timespan` or `None`, optional 

851 A timespan that the validity range of the dataset must overlap. 

852 If not provided and this is a calibration dataset type, an attempt 

853 will be made to find the timespan from any temporal coordinate 

854 in the data ID. 

855 **kwargs 

856 Additional keyword arguments used to augment or construct a 

857 `DataId`. See `DataId` parameters. 

858 

859 Returns 

860 ------- 

861 ref : `DatasetRef` 

862 A reference to the dataset identified by the given arguments. 

863 This can be the same dataset reference as given if it was 

864 resolved. 

865 

866 Raises 

867 ------ 

868 LookupError 

869 Raised if no matching dataset exists in the `Registry` (and 

870 ``predict`` is `False`). 

871 ValueError 

872 Raised if a resolved `DatasetRef` was passed as an input, but it 

873 differs from the one found in the registry. 

874 TypeError 

875 Raised if no collections were provided. 

876 """ 

877 datasetType, dataId = self._standardizeArgs(datasetRefOrType, dataId, for_put=False, **kwargs) 

878 if isinstance(datasetRefOrType, DatasetRef): 

879 if collections is not None: 

880 warnings.warn("Collections should not be specified with DatasetRef", stacklevel=3) 

881 # May need to retrieve datastore records if requested. 

882 if datastore_records and datasetRefOrType._datastore_records is None: 

883 datasetRefOrType = self._registry.get_datastore_records(datasetRefOrType) 

884 return datasetRefOrType 

885 

886 dataId, kwargs = self._rewrite_data_id(dataId, datasetType, **kwargs) 

887 

888 if datasetType.isCalibration(): 

889 # Because this is a calibration dataset, first try to make a 

890 # standardize the data ID without restricting the dimensions to 

891 # those of the dataset type requested, because there may be extra 

892 # dimensions that provide temporal information for a validity-range 

893 # lookup. 

894 dataId = DataCoordinate.standardize( 

895 dataId, universe=self.dimensions, defaults=self._registry.defaults.dataId, **kwargs 

896 ) 

897 if timespan is None: 

898 if dataId.dimensions.temporal: 

899 dataId = self._registry.expandDataId(dataId) 

900 # Use the timespan from the data ID to constrain the 

901 # calibration lookup, but only if the caller has not 

902 # specified an explicit timespan. 

903 timespan = dataId.timespan 

904 else: 

905 # Try an arbitrary timespan. Downstream will fail if this 

906 # results in more than one matching dataset. 

907 timespan = Timespan(None, None) 

908 else: 

909 # Standardize the data ID to just the dimensions of the dataset 

910 # type instead of letting registry.findDataset do it, so we get the 

911 # result even if no dataset is found. 

912 dataId = DataCoordinate.standardize( 

913 dataId, 

914 dimensions=datasetType.dimensions, 

915 defaults=self._registry.defaults.dataId, 

916 **kwargs, 

917 ) 

918 # Always lookup the DatasetRef, even if one is given, to ensure it is 

919 # present in the current collection. 

920 ref = self.find_dataset( 

921 datasetType, 

922 dataId, 

923 collections=collections, 

924 timespan=timespan, 

925 datastore_records=datastore_records, 

926 ) 

927 if ref is None: 

928 if predict: 

929 if run is None: 

930 run = self.run 

931 if run is None: 

932 raise TypeError("Cannot predict dataset ID/location with run=None.") 

933 return DatasetRef(datasetType, dataId, run=run) 

934 else: 

935 if collections is None: 

936 collections = self._registry.defaults.collections 

937 raise DatasetNotFoundError( 

938 f"Dataset {datasetType.name} with data ID {dataId} " 

939 f"could not be found in collections {collections}." 

940 ) 

941 if datasetType != ref.datasetType: 

942 # If they differ it is because the user explicitly specified 

943 # a compatible dataset type to this call rather than using the 

944 # registry definition. The DatasetRef must therefore be recreated 

945 # using the user definition such that the expected type is 

946 # returned. 

947 ref = DatasetRef( 

948 datasetType, ref.dataId, run=ref.run, id=ref.id, datastore_records=ref._datastore_records 

949 ) 

950 

951 return ref 

952 

953 @transactional 

954 def put( 

955 self, 

956 obj: Any, 

957 datasetRefOrType: DatasetRef | DatasetType | str, 

958 /, 

959 dataId: DataId | None = None, 

960 *, 

961 run: str | None = None, 

962 provenance: DatasetProvenance | None = None, 

963 **kwargs: Any, 

964 ) -> DatasetRef: 

965 """Store and register a dataset. 

966 

967 Parameters 

968 ---------- 

969 obj : `object` 

970 The dataset. 

971 datasetRefOrType : `DatasetRef`, `DatasetType`, or `str` 

972 When `DatasetRef` is provided, ``dataId`` should be `None`. 

973 Otherwise the `DatasetType` or name thereof. If a fully resolved 

974 `DatasetRef` is given the run and ID are used directly. 

975 dataId : `dict` or `DataCoordinate` 

976 A `dict` of `Dimension` link name, value pairs that label the 

977 `DatasetRef` within a Collection. When `None`, a `DatasetRef` 

978 should be provided as the second argument. 

979 run : `str`, optional 

980 The name of the run the dataset should be added to, overriding 

981 ``self.run``. Not used if a resolved `DatasetRef` is provided. 

982 provenance : `DatasetProvenance` or `None`, optional 

983 Any provenance that should be attached to the serialized dataset. 

984 Not supported by all serialization mechanisms. 

985 **kwargs 

986 Additional keyword arguments used to augment or construct a 

987 `DataCoordinate`. See `DataCoordinate.standardize` 

988 parameters. Not used if a resolve `DatasetRef` is provided. 

989 

990 Returns 

991 ------- 

992 ref : `DatasetRef` 

993 A reference to the stored dataset, updated with the correct id if 

994 given. 

995 

996 Raises 

997 ------ 

998 TypeError 

999 Raised if the butler is read-only or if no run has been provided. 

1000 """ 

1001 if isinstance(datasetRefOrType, DatasetRef): 

1002 # This is a direct put of predefined DatasetRef. 

1003 _LOG.debug("Butler put direct: %s", datasetRefOrType) 

1004 if run is not None: 

1005 warnings.warn("Run collection is not used for DatasetRef", stacklevel=3) 

1006 

1007 with self._metrics.instrument_put(_LOG, msg="Dataset put direct"): 

1008 # If registry already has a dataset with the same dataset ID, 

1009 # dataset type and DataId, then _importDatasets will do 

1010 # nothing and just return an original ref. We have to raise in 

1011 # this case, there is a datastore check below for that. 

1012 self._registry._importDatasets([datasetRefOrType], expand=True) 

1013 # Before trying to write to the datastore check that it does 

1014 # not know this dataset. This is prone to races, of course. 

1015 if self._datastore.knows(datasetRefOrType): 

1016 raise ConflictingDefinitionError( 

1017 f"Datastore already contains dataset: {datasetRefOrType}" 

1018 ) 

1019 # Try to write dataset to the datastore, if it fails due to a 

1020 # race with another write, the content of stored data may be 

1021 # unpredictable. 

1022 try: 

1023 self._datastore.put(obj, datasetRefOrType, provenance=provenance) 

1024 except IntegrityError as e: 

1025 raise ConflictingDefinitionError(f"Datastore already contains dataset: {e}") from e 

1026 

1027 return datasetRefOrType 

1028 

1029 _LOG.debug("Butler put: %s, dataId=%s, run=%s", datasetRefOrType, dataId, run) 

1030 if not self.isWriteable(): 

1031 raise TypeError("Butler is read-only.") 

1032 

1033 with self._metrics.instrument_put(_LOG, msg="Dataset put with dataID"): 

1034 datasetType, dataId = self._standardizeArgs(datasetRefOrType, dataId, **kwargs) 

1035 

1036 # Handle dimension records in dataId 

1037 dataId, kwargs = self._rewrite_data_id(dataId, datasetType, **kwargs) 

1038 

1039 # Add Registry Dataset entry. 

1040 dataId = self._registry.expandDataId(dataId, dimensions=datasetType.dimensions, **kwargs) 

1041 (ref,) = self._registry.insertDatasets(datasetType, run=run, dataIds=[dataId]) 

1042 self._datastore.put(obj, ref, provenance=provenance) 

1043 

1044 return ref 

1045 

1046 def getDeferred( 

1047 self, 

1048 datasetRefOrType: DatasetRef | DatasetType | str, 

1049 /, 

1050 dataId: DataId | None = None, 

1051 *, 

1052 parameters: dict | None = None, 

1053 collections: Any = None, 

1054 storageClass: str | StorageClass | None = None, 

1055 timespan: Timespan | None = None, 

1056 **kwargs: Any, 

1057 ) -> DeferredDatasetHandle: 

1058 """Create a `DeferredDatasetHandle` which can later retrieve a dataset, 

1059 after an immediate registry lookup. 

1060 

1061 Parameters 

1062 ---------- 

1063 datasetRefOrType : `DatasetRef`, `DatasetType`, or `str` 

1064 When `DatasetRef` the `dataId` should be `None`. 

1065 Otherwise the `DatasetType` or name thereof. 

1066 dataId : `dict` or `DataCoordinate`, optional 

1067 A `dict` of `Dimension` link name, value pairs that label the 

1068 `DatasetRef` within a Collection. When `None`, a `DatasetRef` 

1069 should be provided as the first argument. 

1070 parameters : `dict` 

1071 Additional StorageClass-defined options to control reading, 

1072 typically used to efficiently read only a subset of the dataset. 

1073 collections : Any, optional 

1074 Collections to be searched, overriding ``self.collections``. 

1075 Can be any of the types supported by the ``collections`` argument 

1076 to butler construction. 

1077 storageClass : `StorageClass` or `str`, optional 

1078 The storage class to be used to override the Python type 

1079 returned by this method. By default the returned type matches 

1080 the dataset type definition for this dataset. Specifying a 

1081 read `StorageClass` can force a different type to be returned. 

1082 This type must be compatible with the original type. 

1083 timespan : `Timespan` or `None`, optional 

1084 A timespan that the validity range of the dataset must overlap. 

1085 If not provided and this is a calibration dataset type, an attempt 

1086 will be made to find the timespan from any temporal coordinate 

1087 in the data ID. 

1088 **kwargs 

1089 Additional keyword arguments used to augment or construct a 

1090 `DataId`. See `DataId` parameters. 

1091 

1092 Returns 

1093 ------- 

1094 obj : `DeferredDatasetHandle` 

1095 A handle which can be used to retrieve a dataset at a later time. 

1096 

1097 Raises 

1098 ------ 

1099 LookupError 

1100 Raised if no matching dataset exists in the `Registry` or 

1101 datastore. 

1102 ValueError 

1103 Raised if a resolved `DatasetRef` was passed as an input, but it 

1104 differs from the one found in the registry. 

1105 TypeError 

1106 Raised if no collections were provided. 

1107 """ 

1108 if isinstance(datasetRefOrType, DatasetRef): 

1109 # Do the quick check first and if that fails, check for artifact 

1110 # existence. This is necessary for datastores that are configured 

1111 # in trust mode where there won't be a record but there will be 

1112 # a file. 

1113 if self._datastore.knows(datasetRefOrType) or self._datastore.exists(datasetRefOrType): 

1114 ref = datasetRefOrType 

1115 else: 

1116 raise LookupError(f"Dataset reference {datasetRefOrType} does not exist.") 

1117 else: 

1118 ref = self._findDatasetRef( 

1119 datasetRefOrType, dataId, collections=collections, timespan=timespan, **kwargs 

1120 ) 

1121 return DeferredDatasetHandle(butler=self, ref=ref, parameters=parameters, storageClass=storageClass) 

1122 

1123 def get( 

1124 self, 

1125 datasetRefOrType: DatasetRef | DatasetType | str, 

1126 /, 

1127 dataId: DataId | None = None, 

1128 *, 

1129 parameters: dict[str, Any] | None = None, 

1130 collections: Any = None, 

1131 storageClass: StorageClass | str | None = None, 

1132 timespan: Timespan | None = None, 

1133 **kwargs: Any, 

1134 ) -> Any: 

1135 """Retrieve a stored dataset. 

1136 

1137 Parameters 

1138 ---------- 

1139 datasetRefOrType : `DatasetRef`, `DatasetType`, or `str` 

1140 When `DatasetRef` the `dataId` should be `None`. 

1141 Otherwise the `DatasetType` or name thereof. 

1142 If a resolved `DatasetRef`, the associated dataset 

1143 is returned directly without additional querying. 

1144 dataId : `dict` or `DataCoordinate` 

1145 A `dict` of `Dimension` link name, value pairs that label the 

1146 `DatasetRef` within a Collection. When `None`, a `DatasetRef` 

1147 should be provided as the first argument. 

1148 parameters : `dict` 

1149 Additional StorageClass-defined options to control reading, 

1150 typically used to efficiently read only a subset of the dataset. 

1151 collections : Any, optional 

1152 Collections to be searched, overriding ``self.collections``. 

1153 Can be any of the types supported by the ``collections`` argument 

1154 to butler construction. 

1155 storageClass : `StorageClass` or `str`, optional 

1156 The storage class to be used to override the Python type 

1157 returned by this method. By default the returned type matches 

1158 the dataset type definition for this dataset. Specifying a 

1159 read `StorageClass` can force a different type to be returned. 

1160 This type must be compatible with the original type. 

1161 timespan : `Timespan` or `None`, optional 

1162 A timespan that the validity range of the dataset must overlap. 

1163 If not provided and this is a calibration dataset type, an attempt 

1164 will be made to find the timespan from any temporal coordinate 

1165 in the data ID. 

1166 **kwargs 

1167 Additional keyword arguments used to augment or construct a 

1168 `DataCoordinate`. See `DataCoordinate.standardize` 

1169 parameters. 

1170 

1171 Returns 

1172 ------- 

1173 obj : `object` 

1174 The dataset. 

1175 

1176 Raises 

1177 ------ 

1178 LookupError 

1179 Raised if no matching dataset exists in the `Registry`. 

1180 TypeError 

1181 Raised if no collections were provided. 

1182 

1183 Notes 

1184 ----- 

1185 When looking up datasets in a `~CollectionType.CALIBRATION` collection, 

1186 this method requires that the given data ID include temporal dimensions 

1187 beyond the dimensions of the dataset type itself, in order to find the 

1188 dataset with the appropriate validity range. For example, a "bias" 

1189 dataset with native dimensions ``{instrument, detector}`` could be 

1190 fetched with a ``{instrument, detector, exposure}`` data ID, because 

1191 ``exposure`` is a temporal dimension. 

1192 """ 

1193 _LOG.debug("Butler get: %s, dataId=%s, parameters=%s", datasetRefOrType, dataId, parameters) 

1194 with self._metrics.instrument_get(_LOG, msg="Retrieved dataset"): 

1195 ref = self._findDatasetRef( 

1196 datasetRefOrType, 

1197 dataId, 

1198 collections=collections, 

1199 datastore_records=True, 

1200 timespan=timespan, 

1201 **kwargs, 

1202 ) 

1203 return self._datastore.get(ref, parameters=parameters, storageClass=storageClass) 

1204 

1205 def getURIs( 

1206 self, 

1207 datasetRefOrType: DatasetRef | DatasetType | str, 

1208 /, 

1209 dataId: DataId | None = None, 

1210 *, 

1211 predict: bool = False, 

1212 collections: Any = None, 

1213 run: str | None = None, 

1214 **kwargs: Any, 

1215 ) -> DatasetRefURIs: 

1216 """Return the URIs associated with the dataset. 

1217 

1218 Parameters 

1219 ---------- 

1220 datasetRefOrType : `DatasetRef`, `DatasetType`, or `str` 

1221 When `DatasetRef` the `dataId` should be `None`. 

1222 Otherwise the `DatasetType` or name thereof. 

1223 dataId : `dict` or `DataCoordinate` 

1224 A `dict` of `Dimension` link name, value pairs that label the 

1225 `DatasetRef` within a Collection. When `None`, a `DatasetRef` 

1226 should be provided as the first argument. 

1227 predict : `bool` 

1228 If `True`, allow URIs to be returned of datasets that have not 

1229 been written. 

1230 collections : Any, optional 

1231 Collections to be searched, overriding ``self.collections``. 

1232 Can be any of the types supported by the ``collections`` argument 

1233 to butler construction. 

1234 run : `str`, optional 

1235 Run to use for predictions, overriding ``self.run``. 

1236 **kwargs 

1237 Additional keyword arguments used to augment or construct a 

1238 `DataCoordinate`. See `DataCoordinate.standardize` 

1239 parameters. 

1240 

1241 Returns 

1242 ------- 

1243 uris : `DatasetRefURIs` 

1244 The URI to the primary artifact associated with this dataset (if 

1245 the dataset was disassembled within the datastore this may be 

1246 `None`), and the URIs to any components associated with the dataset 

1247 artifact. (can be empty if there are no components). 

1248 """ 

1249 ref = self._findDatasetRef( 

1250 datasetRefOrType, dataId, predict=predict, run=run, collections=collections, **kwargs 

1251 ) 

1252 return self._datastore.getURIs(ref, predict) 

1253 

1254 def get_dataset_type(self, name: str) -> DatasetType: 

1255 return self._registry.getDatasetType(name) 

1256 

1257 def get_dataset( 

1258 self, 

1259 id: DatasetId | str, 

1260 *, 

1261 storage_class: str | StorageClass | None = None, 

1262 dimension_records: bool = False, 

1263 datastore_records: bool = False, 

1264 ) -> DatasetRef | None: 

1265 id = _to_uuid(id) 

1266 ref = self._registry.getDataset(id) 

1267 if ref is not None: 

1268 if dimension_records: 

1269 ref = ref.expanded( 

1270 self._registry.expandDataId(ref.dataId, dimensions=ref.datasetType.dimensions) 

1271 ) 

1272 if storage_class: 

1273 ref = ref.overrideStorageClass(storage_class) 

1274 if datastore_records: 

1275 ref = self._registry.get_datastore_records(ref) 

1276 return ref 

1277 

1278 def get_many_datasets(self, ids: Iterable[DatasetId | str]) -> list[DatasetRef]: 

1279 uuids = [_to_uuid(id) for id in ids] 

1280 return self._registry._managers.datasets.get_dataset_refs(uuids) 

1281 

1282 def find_dataset( 

1283 self, 

1284 dataset_type: DatasetType | str, 

1285 data_id: DataId | None = None, 

1286 *, 

1287 collections: str | Sequence[str] | None = None, 

1288 timespan: Timespan | None = None, 

1289 storage_class: str | StorageClass | None = None, 

1290 dimension_records: bool = False, 

1291 datastore_records: bool = False, 

1292 **kwargs: Any, 

1293 ) -> DatasetRef | None: 

1294 # Handle any parts of the dataID that are not using primary dimension 

1295 # keys. 

1296 if isinstance(dataset_type, str): 

1297 actual_type = self.get_dataset_type(dataset_type) 

1298 else: 

1299 actual_type = dataset_type 

1300 

1301 # Store the component for later. 

1302 component_name = actual_type.component() 

1303 if actual_type.isComponent(): 

1304 parent_type = actual_type.makeCompositeDatasetType() 

1305 else: 

1306 parent_type = actual_type 

1307 

1308 data_id, kwargs = self._rewrite_data_id(data_id, parent_type, **kwargs) 

1309 

1310 ref = self.registry.findDataset( 

1311 parent_type, 

1312 data_id, 

1313 collections=collections, 

1314 timespan=timespan, 

1315 datastore_records=datastore_records, 

1316 **kwargs, 

1317 ) 

1318 if ref is not None and dimension_records: 

1319 ref = ref.expanded(self._registry.expandDataId(ref.dataId, dimensions=ref.datasetType.dimensions)) 

1320 if ref is not None and component_name: 

1321 ref = ref.makeComponentRef(component_name) 

1322 if ref is not None and storage_class is not None: 

1323 ref = ref.overrideStorageClass(storage_class) 

1324 

1325 return ref 

1326 

1327 def retrieve_artifacts_zip( 

1328 self, 

1329 refs: Iterable[DatasetRef], 

1330 destination: ResourcePathExpression, 

1331 overwrite: bool = True, 

1332 ) -> ResourcePath: 

1333 return retrieve_and_zip(refs, destination, self._datastore.retrieveArtifacts, overwrite) 

1334 

1335 def retrieveArtifacts( 

1336 self, 

1337 refs: Iterable[DatasetRef], 

1338 destination: ResourcePathExpression, 

1339 transfer: str = "auto", 

1340 preserve_path: bool = True, 

1341 overwrite: bool = False, 

1342 ) -> list[ResourcePath]: 

1343 # Docstring inherited. 

1344 outdir = ResourcePath(destination) 

1345 artifact_map = self._datastore.retrieveArtifacts( 

1346 refs, 

1347 outdir, 

1348 transfer=transfer, 

1349 preserve_path=preserve_path, 

1350 overwrite=overwrite, 

1351 write_index=True, 

1352 ) 

1353 return list(artifact_map) 

1354 

1355 def exists( 

1356 self, 

1357 dataset_ref_or_type: DatasetRef | DatasetType | str, 

1358 /, 

1359 data_id: DataId | None = None, 

1360 *, 

1361 full_check: bool = True, 

1362 collections: Any = None, 

1363 **kwargs: Any, 

1364 ) -> DatasetExistence: 

1365 # Docstring inherited. 

1366 existence = DatasetExistence.UNRECOGNIZED 

1367 

1368 if isinstance(dataset_ref_or_type, DatasetRef): 

1369 if collections is not None: 

1370 warnings.warn("Collections should not be specified with DatasetRef", stacklevel=2) 

1371 if data_id is not None: 

1372 warnings.warn("A DataID should not be specified with DatasetRef", stacklevel=2) 

1373 ref = dataset_ref_or_type 

1374 registry_ref = self._registry.getDataset(dataset_ref_or_type.id) 

1375 if registry_ref is not None: 

1376 existence |= DatasetExistence.RECORDED 

1377 

1378 if dataset_ref_or_type != registry_ref: 

1379 # This could mean that storage classes differ, so we should 

1380 # check for that but use the registry ref for the rest of 

1381 # the method. 

1382 if registry_ref.is_compatible_with(dataset_ref_or_type): 

1383 # Use the registry version from now on. 

1384 ref = registry_ref 

1385 else: 

1386 raise ValueError( 

1387 f"The ref given to exists() ({ref}) has the same dataset ID as one " 

1388 f"in registry but has different incompatible values ({registry_ref})." 

1389 ) 

1390 else: 

1391 try: 

1392 ref = self._findDatasetRef(dataset_ref_or_type, data_id, collections=collections, **kwargs) 

1393 except (LookupError, TypeError): 

1394 return existence 

1395 existence |= DatasetExistence.RECORDED 

1396 

1397 if self._datastore.knows(ref): 

1398 existence |= DatasetExistence.DATASTORE 

1399 

1400 if full_check: 

1401 if self._datastore.exists(ref): 

1402 existence |= DatasetExistence._ARTIFACT 

1403 elif existence.value != DatasetExistence.UNRECOGNIZED.value: 

1404 # Do not add this flag if we have no other idea about a dataset. 

1405 existence |= DatasetExistence(DatasetExistence._ASSUMED) 

1406 

1407 return existence 

1408 

1409 def _exists_many( 

1410 self, 

1411 refs: Iterable[DatasetRef], 

1412 /, 

1413 *, 

1414 full_check: bool = True, 

1415 ) -> dict[DatasetRef, DatasetExistence]: 

1416 # Docstring inherited. 

1417 existence = {ref: DatasetExistence.UNRECOGNIZED for ref in refs} 

1418 

1419 # Check which refs exist in the registry. 

1420 id_map = {ref.id: ref for ref in existence.keys()} 

1421 for registry_ref in self.get_many_datasets(id_map.keys()): 

1422 # Consistency between the given DatasetRef and the information 

1423 # recorded in the registry is not verified. 

1424 existence[id_map[registry_ref.id]] |= DatasetExistence.RECORDED 

1425 

1426 # Ask datastore if it knows about these refs. 

1427 knows = self._datastore.knows_these(refs) 

1428 for ref, known in knows.items(): 

1429 if known: 

1430 existence[ref] |= DatasetExistence.DATASTORE 

1431 

1432 if full_check: 

1433 mexists = self._datastore.mexists(refs) 

1434 for ref, exists in mexists.items(): 

1435 if exists: 

1436 existence[ref] |= DatasetExistence._ARTIFACT 

1437 else: 

1438 # Do not set this flag if nothing is known about the dataset. 

1439 for ref in existence: 

1440 if existence[ref] != DatasetExistence.UNRECOGNIZED: 

1441 existence[ref] |= DatasetExistence._ASSUMED 

1442 

1443 return existence 

1444 

1445 def removeRuns( 

1446 self, 

1447 names: Iterable[str], 

1448 unstore: bool | type[_DeprecatedDefault] = _DeprecatedDefault, 

1449 *, 

1450 unlink_from_chains: bool = False, 

1451 ) -> None: 

1452 # Docstring inherited. 

1453 if not self.isWriteable(): 

1454 raise TypeError("Butler is read-only.") 

1455 

1456 if unstore is not _DeprecatedDefault: 

1457 # The value was passed in by a user. Must report it is now 

1458 # ignored. 

1459 if unstore is True: 

1460 msg = "The unstore parameter is deprecated and is now always treated as True. " 

1461 else: 

1462 msg = "The unstore parameter for removeRuns can no longer be False and is now ignored. " 

1463 warnings.warn( 

1464 msg + " The parameter will be removed after v30.", 

1465 category=FutureWarning, 

1466 stacklevel=find_outside_stacklevel("lsst.daf.butler"), 

1467 ) 

1468 

1469 names = list(names) 

1470 refs: list[DatasetRef] = [] 

1471 # Map of the chained collections to the RUN children. 

1472 parents_to_children: dict[str, set[str]] = defaultdict(set) 

1473 

1474 with self._caching_context(): 

1475 # Get information about these RUNs. 

1476 collections_info = self.collections.query_info(names, include_parents=unlink_from_chains) 

1477 for info in collections_info: 

1478 if info.type is not CollectionType.RUN: 

1479 raise TypeError(f"The collection type of '{info.name}' is {info.type.name}, not RUN.") 

1480 if unlink_from_chains: 

1481 if info.parents is None: # For mypy. 

1482 raise AssertionError("Internal error: Collection parents required but not received") 

1483 for parent in info.parents: 

1484 parents_to_children[parent].add(info.name) 

1485 

1486 # Update the names in case the query unexpectedly had a wildcard. 

1487 names = [info.name for info in collections_info] 

1488 

1489 # Get all the datasets from these runs. 

1490 refs = self.query_all_datasets(names, find_first=False, limit=None) 

1491 

1492 # Call pruneDatasets since we are deliberately removing 

1493 # datasets in chunks from the RUN collections rather than 

1494 # attempting to remove everything at once. 

1495 with time_this( 

1496 _LOG, 

1497 msg="Removing %d dataset%s from %s", 

1498 args=(len(refs), "s" if len(refs) != 1 else "", ", ".join(names)), 

1499 ): 

1500 self.pruneDatasets(refs, unstore=True, purge=True, disassociate=True) 

1501 

1502 # Now can remove the actual RUN collection and unlink from chains. 

1503 with self._registry.transaction(): 

1504 # This will fail if caller is not unlinking from chains but the 

1505 # RUN is in a chain -- but we have already deleted all the datasets 

1506 # by this point. 

1507 if unlink_from_chains: 

1508 # Use deterministic order for deletions to attempt to minimize 

1509 # risk of deadlocks for parallel deletes. 

1510 for parent in sorted(parents_to_children): 

1511 self.collections.remove_from_chain(parent, sorted(parents_to_children[parent])) 

1512 # Sort to avoid potential deadlocks. 

1513 for name in sorted(names): 

1514 # This should be fast since the collection should be empty. 

1515 with time_this(_LOG, msg="Removing RUN collection %s", args=(name,)): 

1516 self._registry.removeCollection(name) 

1517 _LOG.info("Completely removed the following RUN collections: %s", ", ".join(names)) 

1518 

1519 def pruneDatasets( 

1520 self, 

1521 refs: Iterable[DatasetRef], 

1522 *, 

1523 disassociate: bool = True, 

1524 unstore: bool = False, 

1525 tags: Iterable[str] = (), 

1526 purge: bool = False, 

1527 ) -> None: 

1528 # docstring inherited from LimitedButler 

1529 

1530 if not self.isWriteable(): 

1531 raise TypeError("Butler is read-only.") 

1532 if purge: 

1533 if not disassociate: 

1534 raise TypeError("Cannot pass purge=True without disassociate=True.") 

1535 if not unstore: 

1536 raise TypeError("Cannot pass purge=True without unstore=True.") 

1537 elif disassociate: 

1538 tags = tuple(tags) 

1539 if not tags: 

1540 raise TypeError("No tags provided but disassociate=True.") 

1541 for tag in tags: 

1542 collectionType = self._registry.getCollectionType(tag) 

1543 if collectionType is not CollectionType.TAGGED: 

1544 raise TypeError( 

1545 f"Cannot disassociate from collection '{tag}' " 

1546 f"of non-TAGGED type {collectionType.name}." 

1547 ) 

1548 # Transform possibly-single-pass iterable into something we can iterate 

1549 # over multiple times. 

1550 refs = list(refs) 

1551 # Pruning a component of a DatasetRef makes no sense since registry 

1552 # doesn't know about components and datastore might not store 

1553 # components in a separate file 

1554 for ref in refs: 

1555 if ref.datasetType.component(): 

1556 raise ValueError(f"Can not prune a component of a dataset (ref={ref})") 

1557 

1558 # Chunk the deletions using a size that is reasonably efficient whilst 

1559 # also giving reasonable feedback to the user. Chunking also minimizes 

1560 # what needs to rollback if there is a failure and should allow 

1561 # incremental re-running of the pruning (assuming the query is 

1562 # repeated). The only issue will be if the Ctrl-C comes during 

1563 # emptyTrash since an admin command would need to run to finish the 

1564 # emptying of that chunk. 

1565 progress = Progress("lsst.daf.butler.Butler.pruneDatasets", level=_LOG.INFO) 

1566 chunk_size = 50_000 

1567 n_chunks = math.ceil(len(refs) / chunk_size) 

1568 if n_chunks > 1: 

1569 _LOG.verbose("Pruning a total of %d datasets", len(refs)) 

1570 chunk_num = 0 

1571 for chunked_refs in progress.wrap( 

1572 chunk_iterable(refs, chunk_size=chunk_size), desc="Deleting datasets", total=n_chunks 

1573 ): 

1574 chunk_num += 1 

1575 _LOG.verbose( 

1576 "Pruning %d dataset%s in chunk %d/%d", 

1577 len(chunked_refs), 

1578 "s" if len(chunked_refs) != 1 else "", 

1579 chunk_num, 

1580 n_chunks, 

1581 ) 

1582 with time_this( 

1583 _LOG, 

1584 msg="Removing %d datasets for chunk %d/%d", 

1585 args=(len(chunked_refs), chunk_num, n_chunks), 

1586 ): 

1587 self._prune_datasets( 

1588 chunked_refs, tags=tags, unstore=unstore, purge=purge, disassociate=disassociate 

1589 ) 

1590 

1591 def _prune_datasets( 

1592 self, 

1593 refs: Collection[DatasetRef], 

1594 *, 

1595 disassociate: bool = True, 

1596 unstore: bool = False, 

1597 tags: Iterable[str] = (), 

1598 purge: bool = False, 

1599 ) -> None: 

1600 # We don't need an unreliable Datastore transaction for this, because 

1601 # we've been extra careful to ensure that Datastore.trash only involves 

1602 # mutating the Registry (it can _look_ at Datastore-specific things, 

1603 # but shouldn't change them), and hence all operations here are 

1604 # Registry operations. 

1605 with self.transaction(): 

1606 plural = "s" if len(refs) != 1 else "" 

1607 if unstore: 

1608 with time_this( 

1609 _LOG, 

1610 msg="Marking %d dataset%s for removal during pruneDatasets", 

1611 args=(len(refs), plural), 

1612 ): 

1613 self._datastore.trash(refs) 

1614 if purge: 

1615 with time_this( 

1616 _LOG, msg="Removing %d pruned dataset%s from registry", args=(len(refs), plural) 

1617 ): 

1618 self._registry.removeDatasets(refs) 

1619 elif disassociate: 

1620 assert tags, "Guaranteed by earlier logic in this function." 

1621 with time_this( 

1622 _LOG, msg="Disassociating %d dataset%ss from tagged collections", args=(len(refs), plural) 

1623 ): 

1624 for tag in tags: 

1625 self._registry.disassociate(tag, refs) 

1626 # We've exited the Registry transaction, and apparently committed. 

1627 # (if there was an exception, everything rolled back, and it's as if 

1628 # nothing happened - and we never get here). 

1629 # Datastore artifacts are not yet gone, but they're clearly marked 

1630 # as trash, so if we fail to delete now because of (e.g.) filesystem 

1631 # problems we can try again later, and if manual administrative 

1632 # intervention is required, it's pretty clear what that should entail: 

1633 # deleting everything on disk and in private Datastore tables that is 

1634 # in the dataset_location_trash table. 

1635 if unstore: 

1636 # Point of no return for removing artifacts. Restrict the trash 

1637 # emptying to the refs that this call trashed. 

1638 with time_this( 

1639 _LOG, 

1640 msg="Attempting to remove artifacts for %d dataset%s associated with pruning", 

1641 args=(len(refs), plural), 

1642 ): 

1643 self._datastore.emptyTrash(refs=refs) 

1644 

1645 def ingest_zip( 

1646 self, 

1647 zip_file: ResourcePathExpression, 

1648 transfer: str = "auto", 

1649 *, 

1650 transfer_dimensions: bool = False, 

1651 dry_run: bool = False, 

1652 ) -> None: 

1653 """Ingest a Zip file into this butler. 

1654 

1655 The Zip file must have been created by `retrieve_artifacts_zip`. 

1656 

1657 Parameters 

1658 ---------- 

1659 zip_file : `lsst.resources.ResourcePathExpression` 

1660 Path to the Zip file. 

1661 transfer : `str`, optional 

1662 Method to use to transfer the Zip into the datastore. 

1663 transfer_dimensions : `bool`, optional 

1664 If `True`, dimension record data associated with the new datasets 

1665 will be transferred from the Zip, if present. 

1666 dry_run : `bool`, optional 

1667 If `True` the ingest will be processed without any modifications 

1668 made to the target butler and as if the target butler did not 

1669 have any of the datasets. 

1670 

1671 Notes 

1672 ----- 

1673 Run collections and dataset types are created as needed. 

1674 """ 

1675 if not self.isWriteable(): 

1676 raise TypeError("Butler is read-only.") 

1677 

1678 zip_path = ResourcePath(zip_file) 

1679 index = ZipIndex.from_zip_file(zip_path) 

1680 _LOG.verbose( 

1681 "Ingesting %s containing %d datasets and %d files.", zip_path, len(index.refs), len(index) 

1682 ) 

1683 

1684 # Need to ingest the refs into registry. Re-use the standard ingest 

1685 # code by reconstructing FileDataset from the index. 

1686 refs = index.refs.to_refs(universe=self.dimensions) 

1687 id_to_ref = {ref.id: ref for ref in refs} 

1688 datasets: list[FileDataset] = [] 

1689 processed_ids: set[uuid.UUID] = set() 

1690 for path_in_zip, index_info in index.artifact_map.items(): 

1691 # Disassembled composites need to check this ref isn't already 

1692 # included. 

1693 unprocessed = {id_ for id_ in index_info.ids if id_ not in processed_ids} 

1694 if not unprocessed: 

1695 continue 

1696 dataset = FileDataset(refs=[id_to_ref[id_] for id_ in unprocessed], path=path_in_zip) 

1697 datasets.append(dataset) 

1698 processed_ids.update(unprocessed) 

1699 

1700 # Ingest doesn't create the RUN collections so we have to do that 

1701 # here. 

1702 # 

1703 # Sort by run collection name to ensure Postgres takes locks in the 

1704 # same order between different processes, to mitigate an issue 

1705 # where Postgres can deadlock due to the unique index on collection 

1706 # name. (See DM-47543). 

1707 runs = {ref.run for ref in refs} 

1708 for run in sorted(runs): 

1709 registered = self.collections.register(run) 

1710 if registered: 

1711 _LOG.verbose("Created RUN collection %s as part of zip ingest", run) 

1712 

1713 progress = Progress("lsst.daf.butler.Butler.ingest", level=VERBOSE) 

1714 import_info = self._prepare_ingest_file_datasets( 

1715 datasets, progress, dry_run=dry_run, transfer_dimensions=transfer_dimensions 

1716 ) 

1717 

1718 with self.transaction(): 

1719 # Do not need expanded dataset refs so can ignore the return value. 

1720 self._ingest_file_datasets(datasets, import_info, progress, dry_run=dry_run) 

1721 

1722 try: 

1723 self._datastore.ingest_zip(zip_path, transfer=transfer, dry_run=dry_run) 

1724 except IntegrityError as e: 

1725 raise ConflictingDefinitionError( 

1726 f"Datastore already contains one or more datasets: {e}" 

1727 ) from e 

1728 

1729 def _prepare_ingest_file_datasets( 

1730 self, 

1731 datasets: Sequence[FileDataset], 

1732 progress: Progress, 

1733 *, 

1734 transfer_dimensions: bool = False, 

1735 dry_run: bool = False, 

1736 ) -> _ImportDatasetsInfo: 

1737 # Track DataIDs that are being ingested so we can spot issues early 

1738 # with duplication. Retain previous FileDataset so we can report it. 

1739 groupedDataIds: MutableMapping[tuple[DatasetType, str], dict[DataCoordinate, FileDataset]] = ( 

1740 defaultdict(dict) 

1741 ) 

1742 

1743 # All the refs we need to import. 

1744 refs: list[DatasetRef] = [] 

1745 

1746 for dataset in progress.wrap(datasets, desc="Validating dataIDs"): 

1747 for ref in dataset.refs: 

1748 group_key = (ref.datasetType, ref.run) 

1749 

1750 if ref.dataId in groupedDataIds[group_key]: 

1751 raise ConflictingDefinitionError( 

1752 f"Ingest conflict. Dataset {dataset.path} has same" 

1753 " DataId as other ingest dataset" 

1754 f" {groupedDataIds[group_key][ref.dataId].path} " 

1755 f" ({ref.dataId})" 

1756 ) 

1757 

1758 groupedDataIds[group_key][ref.dataId] = dataset 

1759 refs.extend(dataset.refs) 

1760 

1761 # Ensure that dataset types are created and all ref information 

1762 # extracted. 

1763 import_info = self._prepare_for_import_refs( 

1764 self, 

1765 refs, 

1766 register_dataset_types=True, 

1767 dry_run=dry_run, 

1768 transfer_dimensions=transfer_dimensions, 

1769 ) 

1770 return import_info 

1771 

1772 def _ingest_file_datasets( 

1773 self, 

1774 datasets: Sequence[FileDataset], 

1775 import_info: _ImportDatasetsInfo, 

1776 progress: Progress, 

1777 *, 

1778 dry_run: bool = False, 

1779 ) -> None: 

1780 self._import_dimension_records(import_info.dimension_records, dry_run=dry_run) 

1781 imported_refs = self._import_grouped_refs( 

1782 import_info.grouped_refs, None, progress, dry_run=dry_run, expand_refs=True 

1783 ) 

1784 

1785 # The expanded refs need to be attached back to the original 

1786 # FileDatasets for datastore to use. 

1787 id_to_ref = {ref.id: ref for ref in imported_refs} 

1788 

1789 for dataset in progress.wrap(datasets, desc="Re-attaching expanded refs"): 

1790 dataset.refs = [id_to_ref[ref.id] for ref in dataset.refs] 

1791 

1792 def ingest( 

1793 self, 

1794 *datasets: FileDataset, 

1795 transfer: str | None = "auto", 

1796 record_validation_info: bool = True, 

1797 skip_existing: bool = False, 

1798 ) -> None: 

1799 # Docstring inherited. 

1800 if not datasets: 

1801 return 

1802 if not self.isWriteable(): 

1803 raise TypeError("Butler is read-only.") 

1804 _LOG.verbose("Ingesting %d file dataset%s.", len(datasets), "" if len(datasets) == 1 else "s") 

1805 progress = Progress("lsst.daf.butler.Butler.ingest", level=VERBOSE) 

1806 

1807 new_datasets, existing_datasets = self._partition_datasets_by_known(datasets) 

1808 if existing_datasets: 

1809 if skip_existing: 

1810 _LOG.info( 

1811 "Skipping %d datasets which already exist in the repository.", len(existing_datasets) 

1812 ) 

1813 else: 

1814 raise ConflictingDefinitionError( 

1815 f"Datastore already contains {len(existing_datasets)} of the given datasets." 

1816 f" Example: {existing_datasets[0]}" 

1817 ) 

1818 

1819 # We use `datasets` rather `new_datasets` for the Registry 

1820 # portion of this, to let it confirm that everything matches the 

1821 # existing datasets. 

1822 import_info = self._prepare_ingest_file_datasets(datasets, progress) 

1823 

1824 with self.transaction(): 

1825 self._ingest_file_datasets(datasets, import_info, progress) 

1826 

1827 # Bulk-insert everything into Datastore. 

1828 # We do not know if any of the registry entries already existed 

1829 # (_importDatasets only complains if they exist but differ). 

1830 # The _partition_datasets_by_known logic above should catch most 

1831 # instances where we attempt to re-ingest files that were already 

1832 # ingested, but a concurrent writer could cause a unique constraint 

1833 # violation here. 

1834 try: 

1835 self._datastore.ingest( 

1836 *new_datasets, transfer=transfer, record_validation_info=record_validation_info 

1837 ) 

1838 except IntegrityError as e: 

1839 raise ConflictingDefinitionError( 

1840 f"Datastore already contains one or more datasets: {e}" 

1841 ) from e 

1842 

1843 def _partition_datasets_by_known( 

1844 self, datasets: Iterable[FileDataset] 

1845 ) -> tuple[list[FileDataset], list[FileDataset]]: 

1846 """Divides the given `FileDataset` objects into two groups: those for 

1847 which the Datastore already has an entry, and those for which it does 

1848 not. 

1849 """ 

1850 new_datasets = [] 

1851 existing_datasets = [] 

1852 

1853 refs = itertools.chain.from_iterable(dataset.refs for dataset in datasets) 

1854 known_refs = self._datastore.knows_these(refs) 

1855 

1856 for dataset in datasets: 

1857 if any(known_refs[ref] for ref in dataset.refs): 

1858 existing_datasets.append(dataset) 

1859 else: 

1860 new_datasets.append(dataset) 

1861 

1862 return new_datasets, existing_datasets 

1863 

1864 @contextlib.contextmanager 

1865 def export( 

1866 self, 

1867 *, 

1868 directory: str | None = None, 

1869 filename: str | None = None, 

1870 format: str | None = None, 

1871 transfer: str | None = None, 

1872 ) -> Iterator[RepoExportContext]: 

1873 # Docstring inherited. 

1874 if directory is None and transfer is not None: 

1875 raise TypeError("Cannot transfer without providing a directory.") 

1876 if transfer == "move": 

1877 raise TypeError("Transfer may not be 'move': export is read-only") 

1878 if format is None: 

1879 if filename is None: 

1880 raise TypeError("At least one of 'filename' or 'format' must be provided.") 

1881 else: 

1882 _, format = os.path.splitext(filename) 

1883 if not format: 

1884 raise ValueError("Please specify a file extension to determine export format.") 

1885 format = format[1:] # Strip leading "."" 

1886 elif filename is None: 

1887 filename = f"export.{format}" 

1888 if directory is not None: 

1889 filename = os.path.join(directory, filename) 

1890 formats = self._config["repo_transfer_formats"] 

1891 if format not in formats: 

1892 raise ValueError(f"Unknown export format {format!r}, allowed: {','.join(formats.keys())}") 

1893 BackendClass = get_class_of(formats[format, "export"]) 

1894 with open(filename, "w") as stream: 

1895 backend = BackendClass(stream, universe=self.dimensions) 

1896 try: 

1897 helper = RepoExportContext(self, backend=backend, directory=directory, transfer=transfer) 

1898 with self._caching_context(): 

1899 yield helper 

1900 except BaseException: 

1901 raise 

1902 else: 

1903 helper._finish() 

1904 

1905 def import_( 

1906 self, 

1907 *, 

1908 directory: ResourcePathExpression | None = None, 

1909 filename: ResourcePathExpression | TextIO | None = None, 

1910 format: str | None = None, 

1911 transfer: str | None = None, 

1912 skip_dimensions: set | None = None, 

1913 record_validation_info: bool = True, 

1914 without_datastore: bool = False, 

1915 ) -> None: 

1916 # Docstring inherited. 

1917 if not self.isWriteable(): 

1918 raise TypeError("Butler is read-only.") 

1919 if filename is None and format is not None: 

1920 filename = ResourcePath(f"export.{format}", forceAbsolute=False) 

1921 if directory is not None: 

1922 directory = ResourcePath(directory, forceDirectory=True) 

1923 # mypy doesn't think this will work but it does in python >= 3.10. 

1924 if isinstance(filename, ResourcePathExpression): # type: ignore 

1925 filename = ResourcePath(filename, forceAbsolute=False) # type: ignore 

1926 if format is None: 

1927 format = filename.getExtension() 

1928 if not filename.isabs() and directory is not None: 

1929 potential = directory.join(filename) 

1930 exists_in_cwd = filename.exists() 

1931 exists_in_dir = potential.exists() 

1932 if exists_in_cwd and exists_in_dir: 

1933 _LOG.warning( 

1934 "A relative path for filename was specified (%s) which exists relative to cwd. " 

1935 "Additionally, the file exists relative to the given search directory (%s). " 

1936 "Using the export file in the given directory.", 

1937 filename, 

1938 potential, 

1939 ) 

1940 # Given they specified an explicit directory and that 

1941 # directory has the export file in it, assume that that 

1942 # is what was meant despite the file in cwd. 

1943 filename = potential 

1944 elif exists_in_dir: 

1945 filename = potential 

1946 elif not exists_in_cwd and not exists_in_dir: 

1947 # Raise early. 

1948 raise FileNotFoundError( 

1949 f"Export file could not be found in {filename.abspath()} or {potential.abspath()}." 

1950 ) 

1951 elif format is None: 

1952 format = ".yaml" 

1953 BackendClass: type[RepoImportBackend] = get_class_of( 

1954 self._config["repo_transfer_formats"][format]["import"] 

1955 ) 

1956 

1957 def doImport(importStream: TextIO | ResourceHandleProtocol) -> None: 

1958 with self._caching_context(): 

1959 backend = BackendClass(importStream, self) # type: ignore[call-arg] 

1960 backend.register() 

1961 with self.transaction(): 

1962 backend.load( 

1963 datastore=self._datastore if not without_datastore else None, 

1964 directory=directory, 

1965 transfer=transfer, 

1966 skip_dimensions=skip_dimensions, 

1967 record_validation_info=record_validation_info, 

1968 ) 

1969 

1970 if isinstance(filename, ResourcePath): 

1971 # We can not use open() here at the moment because of 

1972 # DM-38589 since yaml does stream.read(8192) in a loop. 

1973 stream = io.StringIO(filename.read().decode()) 

1974 doImport(stream) 

1975 else: 

1976 doImport(filename) # type: ignore 

1977 

1978 def transfer_dimension_records_from( 

1979 self, source_butler: LimitedButler | Butler, source_refs: Iterable[DatasetRef] 

1980 ) -> None: 

1981 # Allowed dimensions in the target butler. 

1982 elements = frozenset(element for element in self.dimensions.elements if element.has_own_table) 

1983 

1984 data_ids = {ref.dataId for ref in source_refs} 

1985 

1986 dimension_records = self._extract_all_dimension_records_from_data_ids( 

1987 source_butler, data_ids, elements 

1988 ) 

1989 

1990 # Insert order is important. 

1991 for element in self.dimensions.sorted(dimension_records.keys()): 

1992 records = [r for r in dimension_records[element].values()] 

1993 # Assume that if the record is already present that we can 

1994 # use it without having to check that the record metadata 

1995 # is consistent. 

1996 self._registry.insertDimensionData(element, *records, skip_existing=True) 

1997 _LOG.debug("Dimension '%s' -- number of records transferred: %d", element.name, len(records)) 

1998 

1999 def _extract_all_dimension_records_from_data_ids( 

2000 self, 

2001 source_butler: LimitedButler | Butler, 

2002 data_ids: set[DataCoordinate], 

2003 allowed_elements: frozenset[DimensionElement], 

2004 ) -> dict[DimensionElement, dict[DataCoordinate, DimensionRecord]]: 

2005 primary_records = self._extract_dimension_records_from_data_ids( 

2006 source_butler, data_ids, allowed_elements 

2007 ) 

2008 

2009 can_query = True if isinstance(source_butler, Butler) else False 

2010 

2011 additional_records: dict[DimensionElement, dict[DataCoordinate, DimensionRecord]] = defaultdict(dict) 

2012 for original_element, record_mapping in primary_records.items(): 

2013 # Get dimensions that depend on this dimension. 

2014 populated_by = self.dimensions.get_elements_populated_by( 

2015 self.dimensions[original_element.name] # type: ignore 

2016 ) 

2017 

2018 for data_id in record_mapping.keys(): 

2019 for element in populated_by: 

2020 if element not in allowed_elements: 

2021 continue 

2022 if element.name == original_element.name: 

2023 continue 

2024 

2025 if element.name in primary_records: 

2026 # If this element has already been stored avoid 

2027 # re-finding records since that may lead to additional 

2028 # spurious records. e.g. visit is populated_by 

2029 # visit_detector_region but querying 

2030 # visit_detector_region by visit will return all the 

2031 # detectors for this visit -- the visit dataId does not 

2032 # constrain this. 

2033 # To constrain the query the original dataIds would 

2034 # have to be scanned. 

2035 continue 

2036 

2037 if not can_query: 

2038 raise RuntimeError( 

2039 f"Transferring populated_by records like {element.name} requires a full Butler." 

2040 ) 

2041 

2042 records = source_butler.query_dimension_records( # type: ignore 

2043 element.name, 

2044 explain=False, 

2045 **data_id.mapping, # type: ignore 

2046 ) 

2047 for record in records: 

2048 additional_records[record.definition].setdefault(record.dataId, record) 

2049 

2050 # The next step is to walk back through the additional records to 

2051 # pick up any missing content (such as visit_definition needing to 

2052 # know the exposure). Want to ensure we do not request records we 

2053 # already have. 

2054 missing_data_ids = set() 

2055 for name, record_mapping in additional_records.items(): 

2056 for data_id in record_mapping.keys(): 

2057 if data_id not in primary_records[name]: 

2058 missing_data_ids.add(data_id) 

2059 

2060 # Fill out the new records. Assume that these new records do not 

2061 # also need to carry over additional populated_by records. 

2062 secondary_records = self._extract_dimension_records_from_data_ids( 

2063 source_butler, missing_data_ids, allowed_elements 

2064 ) 

2065 

2066 # Merge the extra sets of records in with the original. 

2067 for name, record_mapping in itertools.chain(additional_records.items(), secondary_records.items()): 

2068 primary_records[name].update(record_mapping) 

2069 

2070 return primary_records 

2071 

2072 def _extract_dimension_records_from_data_ids( 

2073 self, 

2074 source_butler: LimitedButler | Butler, 

2075 data_ids: set[DataCoordinate], 

2076 allowed_elements: frozenset[DimensionElement], 

2077 ) -> dict[DimensionElement, dict[DataCoordinate, DimensionRecord]]: 

2078 dimension_records: dict[DimensionElement, dict[DataCoordinate, DimensionRecord]] = defaultdict(dict) 

2079 

2080 for data_id in data_ids: 

2081 # Need an expanded record, if not expanded that we need a full 

2082 # butler with registry (allow mocks with registry too). 

2083 if not data_id.hasRecords(): 

2084 if registry := getattr(source_butler, "registry", None): 

2085 data_id = registry.expandDataId(data_id) 

2086 else: 

2087 raise TypeError("Input butler needs to be a full butler to expand DataId.") 

2088 # If this butler doesn't know about a dimension in the source 

2089 # butler things will break later. 

2090 for element_name in data_id.dimensions.elements: 

2091 record = data_id.records[element_name] 

2092 if record is not None and record.definition in allowed_elements: 

2093 dimension_records[record.definition].setdefault(record.dataId, record) 

2094 

2095 return dimension_records 

2096 

2097 def _prepare_for_import_refs( 

2098 self, 

2099 source_butler: LimitedButler, 

2100 source_refs: Iterable[DatasetRef], 

2101 *, 

2102 register_dataset_types: bool = False, 

2103 transfer_dimensions: bool = False, 

2104 dry_run: bool = False, 

2105 ) -> _ImportDatasetsInfo: 

2106 # Docstring inherited. 

2107 if not self.isWriteable() and not dry_run: 

2108 raise TypeError("Butler is read-only.") 

2109 

2110 # Will iterate through the refs multiple times so need to convert 

2111 # to a list if this isn't a collection. 

2112 if not isinstance(source_refs, collections.abc.Collection): 

2113 source_refs = list(source_refs) 

2114 

2115 original_count = len(source_refs) 

2116 log_level = _LOG.INFO if original_count > 1 else _LOG.VERBOSE 

2117 _LOG.log( 

2118 log_level, 

2119 "Importing %d dataset%s into %s", 

2120 original_count, 

2121 "s" if original_count != 1 else "", 

2122 str(self), 

2123 ) 

2124 

2125 # Importing requires that we group the refs by dimension group and run 

2126 # before doing the import. 

2127 source_dataset_types = set() 

2128 grouped_refs: defaultdict[_RefGroup, list[DatasetRef]] = defaultdict(list) 

2129 for ref in source_refs: 

2130 grouped_refs[_RefGroup(ref.datasetType.dimensions, ref.run)].append(ref) 

2131 source_dataset_types.add(ref.datasetType) 

2132 

2133 # Check to see if the dataset type in the source butler has 

2134 # the same definition in the target butler and register missing 

2135 # ones if requested. Registration must happen outside a transaction. 

2136 newly_registered_dataset_types = set() 

2137 for datasetType in source_dataset_types: 

2138 if register_dataset_types: 

2139 # Let this raise immediately if inconsistent. Continuing 

2140 # on to find additional inconsistent dataset types 

2141 # might result in additional unwanted dataset types being 

2142 # registered. 

2143 try: 

2144 if not dry_run and self._registry.registerDatasetType(datasetType): 

2145 newly_registered_dataset_types.add(datasetType) 

2146 except ConflictingDefinitionError as e: 

2147 # Be safe and require that conversions be bidirectional 

2148 # when there are storage class mismatches. This is because 

2149 # get() will have to support conversion from source to 

2150 # target python type (the source formatter will be 

2151 # returning source python type) but there also is an 

2152 # expectation that people will want to be able to get() in 

2153 # the target using the source python type, which will not 

2154 # require conversion for transferred datasets but might 

2155 # for target-native types. Additionally, butler.get does 

2156 # not know that the formatter will return the wrong 

2157 # python type and so will always check that the conversion 

2158 # works even though it won't need it. 

2159 target_dataset_type = self.get_dataset_type(datasetType.name) 

2160 target_compatible_with_source = target_dataset_type.is_compatible_with(datasetType) 

2161 source_compatible_with_target = datasetType.is_compatible_with(target_dataset_type) 

2162 if not (target_compatible_with_source and source_compatible_with_target): 

2163 if target_compatible_with_source: 

2164 e.add_note( 

2165 "Target dataset type storage class is compatible with source " 

2166 "but the reverse is not true." 

2167 ) 

2168 elif source_compatible_with_target: 

2169 e.add_note( 

2170 "Source dataset type storage class is compatible with target " 

2171 "but the reverse is not true." 

2172 ) 

2173 else: 

2174 e.add_note("If storage classes differ, please register converters.") 

2175 raise 

2176 else: 

2177 # If the dataset type is missing, let it fail immediately. 

2178 target_dataset_type = self.get_dataset_type(datasetType.name) 

2179 if target_dataset_type != datasetType: 

2180 target_compatible_with_source = target_dataset_type.is_compatible_with(datasetType) 

2181 source_compatible_with_target = datasetType.is_compatible_with(target_dataset_type) 

2182 # Both conversion directions are currently required. 

2183 if not (target_compatible_with_source and source_compatible_with_target): 

2184 msg = "" 

2185 if target_compatible_with_source: 

2186 msg = ( 

2187 "Target storage class is compatible with the source storage class " 

2188 "but the reverse is not true." 

2189 ) 

2190 elif source_compatible_with_target: 

2191 msg = ( 

2192 "Source storage class is compatible with the target storage class" 

2193 " but the reverse is not true." 

2194 ) 

2195 else: 

2196 msg = "If storage classes differ register converters." 

2197 if msg: 

2198 msg = f"({msg})" 

2199 raise ConflictingDefinitionError( 

2200 "Source butler dataset type differs from definition" 

2201 f" in target butler: {datasetType} !=" 

2202 f" {target_dataset_type} {msg}" 

2203 ) 

2204 if newly_registered_dataset_types: 

2205 # We may have registered some even if there were inconsistencies 

2206 # but should let people know (or else remove them again). 

2207 _LOG.verbose( 

2208 "Registered the following dataset types in the target Butler: %s", 

2209 ", ".join(d.name for d in newly_registered_dataset_types), 

2210 ) 

2211 else: 

2212 _LOG.verbose("All required dataset types are known to the target Butler") 

2213 

2214 dimension_records: dict[DimensionElement, dict[DataCoordinate, DimensionRecord]] = defaultdict(dict) 

2215 if transfer_dimensions: 

2216 # Collect all the dimension records for these refs. 

2217 # All dimensions are to be copied but the list of valid dimensions 

2218 # come from this butler's universe. 

2219 elements = frozenset(element for element in self.dimensions.elements if element.has_own_table) 

2220 dataIds = {ref.dataId for ref in source_refs} 

2221 dimension_records = self._extract_all_dimension_records_from_data_ids( 

2222 source_butler, dataIds, elements 

2223 ) 

2224 return _ImportDatasetsInfo(grouped_refs, dimension_records) 

2225 

2226 def _import_dimension_records( 

2227 self, 

2228 dimension_records: dict[DimensionElement, dict[DataCoordinate, DimensionRecord]], 

2229 *, 

2230 dry_run: bool, 

2231 ) -> None: 

2232 """Import dimension records collected during import pre-process.""" 

2233 if dimension_records and not dry_run: 

2234 _LOG.verbose("Ensuring that dimension records exist for transferred datasets.") 

2235 # Order matters. 

2236 for element in self.dimensions.sorted(dimension_records.keys()): 

2237 records = list(dimension_records[element].values()) 

2238 # Assume that if the record is already present that we can 

2239 # use it without having to check that the record metadata 

2240 # is consistent. 

2241 self._registry.insertDimensionData(element, *records, skip_existing=True) 

2242 

2243 def _import_grouped_refs( 

2244 self, 

2245 grouped_refs: defaultdict[_RefGroup, list[DatasetRef]], 

2246 source_butler: LimitedButler | None, 

2247 progress: Progress, 

2248 *, 

2249 dry_run: bool = False, 

2250 expand_refs: bool = False, 

2251 ) -> list[DatasetRef]: 

2252 handled_collections: set[str] = set() 

2253 n_to_import = 0 

2254 all_imported_refs: list[DatasetRef] = [] 

2255 # Sort by run collection name to ensure Postgres takes locks in the 

2256 # same order between different processes, to mitigate an issue 

2257 # where Postgres can deadlock due to the unique index on collection 

2258 # name. (See DM-47543). 

2259 groups = sorted(grouped_refs.items(), key=lambda item: item[0].run) 

2260 for (dimension_group, run), refs_to_import in progress.iter_item_chunks( 

2261 groups, desc="Importing to registry by run and dataset type" 

2262 ): 

2263 if run not in handled_collections: 

2264 # May need to create output collection. If source butler 

2265 # has a registry, ask for documentation string. 

2266 run_doc = None 

2267 if source_butler is not None and (registry := getattr(source_butler, "registry", None)): 

2268 run_doc = registry.getCollectionDocumentation(run) 

2269 if not dry_run: 

2270 registered = self.collections.register(run, doc=run_doc) 

2271 else: 

2272 registered = True 

2273 handled_collections.add(run) 

2274 if registered: 

2275 _LOG.verbose("Creating output run %s", run) 

2276 

2277 n_refs = len(refs_to_import) 

2278 n_to_import += n_refs 

2279 _LOG.verbose( 

2280 "Importing %d ref%s with dimensions %s into run %s", 

2281 n_refs, 

2282 "" if n_refs == 1 else "s", 

2283 dimension_group.names, 

2284 run, 

2285 ) 

2286 

2287 # Assume we are using UUIDs and the source refs will match 

2288 # those imported. 

2289 if not dry_run: 

2290 imported_refs = self._registry._importDatasets(refs_to_import, expand=expand_refs) 

2291 else: 

2292 imported_refs = refs_to_import 

2293 

2294 all_imported_refs.extend(imported_refs) 

2295 

2296 assert n_to_import == len(all_imported_refs) 

2297 _LOG.verbose("Imported %d datasets into destination butler", n_to_import) 

2298 return all_imported_refs 

2299 

2300 def transfer_from( 

2301 self, 

2302 source_butler: LimitedButler, 

2303 source_refs: Iterable[DatasetRef], 

2304 transfer: str = "auto", 

2305 skip_missing: bool = True, 

2306 register_dataset_types: bool = False, 

2307 transfer_dimensions: bool = False, 

2308 dry_run: bool = False, 

2309 ) -> collections.abc.Collection[DatasetRef]: 

2310 # Docstring inherited. 

2311 source_refs = list(source_refs) 

2312 if not self.isWriteable() and not dry_run: 

2313 raise TypeError("Butler is read-only.") 

2314 

2315 progress = Progress("lsst.daf.butler.Butler.transfer_from", level=VERBOSE) 

2316 

2317 artifact_existence: dict[ResourcePath, bool] = {} 

2318 file_transfer_source = source_butler._file_transfer_source 

2319 transfer_records = retrieve_file_transfer_records( 

2320 file_transfer_source, source_refs, artifact_existence 

2321 ) 

2322 # In some situations the datastore artifact may be missing and we do 

2323 # not want that registry entry to be imported. For example, this can 

2324 # happen if a file was removed but the dataset was left in the registry 

2325 # for provenance, or if a pipeline task didn't create all of the 

2326 # possible files in a QuantumBackedButler. 

2327 if skip_missing: 

2328 original_ids = {ref.id for ref in source_refs} 

2329 missing_ids = original_ids - transfer_records.keys() 

2330 if missing_ids: 

2331 original_count = len(source_refs) 

2332 source_refs = [ref for ref in source_refs if ref.id not in missing_ids] 

2333 filtered_count = len(source_refs) 

2334 n_missing = original_count - filtered_count 

2335 _LOG.verbose( 

2336 "%d dataset%s removed because the artifact does not exist. Now have %d.", 

2337 n_missing, 

2338 "" if n_missing == 1 else "s", 

2339 filtered_count, 

2340 ) 

2341 

2342 import_info = self._prepare_for_import_refs( 

2343 source_butler, 

2344 source_refs, 

2345 register_dataset_types=register_dataset_types, 

2346 dry_run=dry_run, 

2347 transfer_dimensions=transfer_dimensions, 

2348 ) 

2349 

2350 # Do all the importing in a single transaction. 

2351 with self.transaction(): 

2352 self._import_dimension_records(import_info.dimension_records, dry_run=dry_run) 

2353 imported_refs = self._import_grouped_refs( 

2354 import_info.grouped_refs, source_butler, progress, dry_run=dry_run 

2355 ) 

2356 

2357 # Ask the datastore to transfer. The datastore has to check that 

2358 # the source datastore is compatible with the target datastore. 

2359 _LOG.verbose("Transferring %d datasets from %s", len(transfer_records), file_transfer_source.name) 

2360 accepted, rejected = self._datastore.transfer_from( 

2361 transfer_records, 

2362 imported_refs, 

2363 transfer=transfer, 

2364 artifact_existence=artifact_existence, 

2365 dry_run=dry_run, 

2366 ) 

2367 if rejected: 

2368 # For now, accept the registry entries but not the files. 

2369 _LOG.warning( 

2370 "%d datasets were rejected and %d accepted for transfer.", 

2371 len(rejected), 

2372 len(accepted), 

2373 ) 

2374 

2375 return imported_refs 

2376 

2377 def validateConfiguration( 

2378 self, 

2379 logFailures: bool = False, 

2380 datasetTypeNames: Iterable[str] | None = None, 

2381 ignore: Iterable[str] | None = None, 

2382 ) -> None: 

2383 # Docstring inherited. 

2384 if datasetTypeNames: 

2385 datasetTypes = [self.get_dataset_type(name) for name in datasetTypeNames] 

2386 else: 

2387 datasetTypes = list(self._registry.queryDatasetTypes()) 

2388 

2389 # filter out anything from the ignore list 

2390 if ignore: 

2391 ignore = set(ignore) 

2392 datasetTypes = [ 

2393 e for e in datasetTypes if e.name not in ignore and e.nameAndComponent()[0] not in ignore 

2394 ] 

2395 else: 

2396 ignore = set() 

2397 

2398 # For each datasetType that has an instrument dimension, create 

2399 # a DatasetRef for each defined instrument 

2400 datasetRefs = [] 

2401 

2402 # Find all the registered instruments (if "instrument" is in the 

2403 # universe). 

2404 instruments: set[str] = set() 

2405 if "instrument" in self.dimensions: 

2406 instruments = {rec.name for rec in self.query_dimension_records("instrument", explain=False)} 

2407 

2408 for datasetType in datasetTypes: 

2409 if "instrument" in datasetType.dimensions: 

2410 # In order to create a conforming dataset ref, create 

2411 # fake DataCoordinate values for the non-instrument 

2412 # dimensions. The type of the value does not matter here. 

2413 dataId = {dim: 1 for dim in datasetType.dimensions.names if dim != "instrument"} 

2414 

2415 for instrument in instruments: 

2416 datasetRef = DatasetRef( 

2417 datasetType, 

2418 DataCoordinate.standardize( 

2419 dataId, instrument=instrument, dimensions=datasetType.dimensions 

2420 ), 

2421 run="validate", 

2422 ) 

2423 datasetRefs.append(datasetRef) 

2424 

2425 entities: list[DatasetType | DatasetRef] = [] 

2426 entities.extend(datasetTypes) 

2427 entities.extend(datasetRefs) 

2428 

2429 datastoreErrorStr = None 

2430 try: 

2431 self._datastore.validateConfiguration(entities, logFailures=logFailures) 

2432 except ValidationError as e: 

2433 datastoreErrorStr = str(e) 

2434 

2435 # Also check that the LookupKeys used by the datastores match 

2436 # registry and storage class definitions 

2437 keys = self._datastore.getLookupKeys() 

2438 

2439 failedNames = set() 

2440 failedDataId = set() 

2441 for key in keys: 

2442 if key.name is not None: 

2443 if key.name in ignore: 

2444 continue 

2445 

2446 # skip if specific datasetType names were requested and this 

2447 # name does not match 

2448 if datasetTypeNames and key.name not in datasetTypeNames: 

2449 continue 

2450 

2451 # See if it is a StorageClass or a DatasetType 

2452 if key.name in self.storageClasses: 

2453 pass 

2454 else: 

2455 try: 

2456 self.get_dataset_type(key.name) 

2457 except KeyError: 

2458 if logFailures: 

2459 _LOG.critical( 

2460 "Key '%s' does not correspond to a DatasetType or StorageClass", key 

2461 ) 

2462 failedNames.add(key) 

2463 else: 

2464 # Dimensions are checked for consistency when the Butler 

2465 # is created and rendezvoused with a universe. 

2466 pass 

2467 

2468 # Check that the instrument is a valid instrument 

2469 # Currently only support instrument so check for that 

2470 if key.dataId: 

2471 dataIdKeys = set(key.dataId) 

2472 if {"instrument"} != dataIdKeys: 

2473 if logFailures: 

2474 _LOG.critical("Key '%s' has unsupported DataId override", key) 

2475 failedDataId.add(key) 

2476 elif key.dataId["instrument"] not in instruments: 

2477 if logFailures: 

2478 _LOG.critical("Key '%s' has unknown instrument", key) 

2479 failedDataId.add(key) 

2480 

2481 messages = [] 

2482 

2483 if datastoreErrorStr: 

2484 messages.append(datastoreErrorStr) 

2485 

2486 for failed, msg in ( 

2487 (failedNames, "Keys without corresponding DatasetType or StorageClass entry: "), 

2488 (failedDataId, "Keys with bad DataId entries: "), 

2489 ): 

2490 if failed: 

2491 msg += ", ".join(str(k) for k in failed) 

2492 messages.append(msg) 

2493 

2494 if messages: 

2495 raise ValidationError(";\n".join(messages)) 

2496 

2497 @property 

2498 @deprecated( 

2499 "Please use 'collections' instead. collection_chains will be removed after v28.", 

2500 version="v28", 

2501 category=FutureWarning, 

2502 ) 

2503 def collection_chains(self) -> DirectButlerCollections: 

2504 """Object with methods for modifying collection chains.""" 

2505 return DirectButlerCollections(self._registry) 

2506 

2507 @property 

2508 def collections(self) -> DirectButlerCollections: 

2509 """Object with methods for modifying and inspecting collections.""" 

2510 return DirectButlerCollections(self._registry) 

2511 

2512 @property 

2513 def run(self) -> str | None: 

2514 """Name of the run this butler writes outputs to by default (`str` or 

2515 `None`). 

2516 

2517 This is an alias for ``self.registry.defaults.run``. It cannot be set 

2518 directly in isolation, but all defaults may be changed together by 

2519 assigning a new `RegistryDefaults` instance to 

2520 ``self.registry.defaults``. 

2521 """ 

2522 return self._registry.defaults.run 

2523 

2524 @property 

2525 def registry(self) -> Registry: 

2526 """The object that manages dataset metadata and relationships 

2527 (`Registry`). 

2528 

2529 Many operations that don't involve reading or writing butler datasets 

2530 are accessible only via `Registry` methods. Eventually these methods 

2531 will be replaced by equivalent `Butler` methods. 

2532 """ 

2533 return self._registry_shim 

2534 

2535 @property 

2536 def dimensions(self) -> DimensionUniverse: 

2537 # Docstring inherited. 

2538 return self._registry.dimensions 

2539 

2540 def query(self) -> contextlib.AbstractContextManager[Query]: 

2541 # Docstring inherited. 

2542 return self._registry._query() 

2543 

2544 def _query_driver( 

2545 self, 

2546 default_collections: Iterable[str], 

2547 default_data_id: DataCoordinate, 

2548 ) -> contextlib.AbstractContextManager[DirectQueryDriver]: 

2549 """Set up a QueryDriver instance for use with this Butler. Although 

2550 this is marked as a private method, it is also used by Butler server. 

2551 """ 

2552 return self._registry._query_driver(default_collections, default_data_id) 

2553 

2554 @contextlib.contextmanager 

2555 def _query_all_datasets_by_page( 

2556 self, args: QueryAllDatasetsParameters 

2557 ) -> Iterator[Iterator[list[DatasetRef]]]: 

2558 with self.query() as query: 

2559 pages = query_all_datasets(self, query, args) 

2560 yield iter(page.data for page in pages) 

2561 

2562 def _preload_cache(self, *, load_dimension_record_cache: bool = True) -> None: 

2563 """Immediately load caches that are used for common operations.""" 

2564 self._registry.preload_cache(load_dimension_record_cache=load_dimension_record_cache) 

2565 

2566 _config: ButlerConfig 

2567 """Configuration for this Butler instance.""" 

2568 

2569 _registry: SqlRegistry 

2570 """The object that manages dataset metadata and relationships 

2571 (`SqlRegistry`). 

2572 

2573 Most operations that don't involve reading or writing butler datasets are 

2574 accessible only via `SqlRegistry` methods. 

2575 """ 

2576 

2577 datastore: Datastore 

2578 """The object that manages actual dataset storage (`Datastore`). 

2579 

2580 Direct user access to the datastore should rarely be necessary; the primary 

2581 exception is the case where a `Datastore` implementation provides extra 

2582 functionality beyond what the base class defines. 

2583 """ 

2584 

2585 storageClasses: StorageClassFactory 

2586 """An object that maps known storage class names to objects that fully 

2587 describe them (`StorageClassFactory`). 

2588 """ 

2589 

2590 _registry_shim: RegistryShim 

2591 """Shim object to provide a legacy public interface for querying via the 

2592 the ``registry`` property. 

2593 """ 

2594 

2595 

2596class _RefGroup(NamedTuple): 

2597 """Key identifying a batch of DatasetRefs to be inserted in 

2598 `Butler.transfer_from`. 

2599 """ 

2600 

2601 dimensions: DimensionGroup 

2602 run: str 

2603 

2604 

2605class _ImportDatasetsInfo(NamedTuple): 

2606 """Information extracted from datasets to be imported.""" 

2607 

2608 grouped_refs: defaultdict[_RefGroup, list[DatasetRef]] 

2609 dimension_records: dict[DimensionElement, dict[DataCoordinate, DimensionRecord]] 

2610 

2611 

2612def _to_uuid(id: DatasetId | str) -> uuid.UUID: 

2613 if isinstance(id, uuid.UUID): 

2614 return id 

2615 else: 

2616 return uuid.UUID(id)