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

974 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-06 01:43 -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, Mapping, MutableMapping, Sequence 

48from functools import partial 

49from types import EllipsisType 

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

51 

52from deprecated.sphinx import deprecated 

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 ( 

70 DatasetNotFoundError, 

71 DimensionValueError, 

72 EmptyQueryResultError, 

73 ValidationError, 

74) 

75from .._file_dataset import FileDataset 

76from .._limited_butler import LimitedButler 

77from .._query_all_datasets import QueryAllDatasetsParameters, query_all_datasets 

78from .._registry_shim import RegistryShim 

79from .._storage_class import StorageClass, StorageClassFactory 

80from .._timespan import Timespan 

81from ..datastore import Datastore, DuplicateIdError, NullDatastore 

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

83from ..datastores.file_datastore.transfer import retrieve_file_transfer_records 

84from ..dimensions import DataCoordinate, Dimension, DimensionGroup 

85from ..direct_query_driver import DirectQueryDriver 

86from ..progress import Progress 

87from ..queries import Query 

88from ..registry import ( 

89 ConflictingDefinitionError, 

90 DataIdError, 

91 MissingDatasetTypeError, 

92 RegistryDefaults, 

93 _RegistryFactory, 

94) 

95from ..registry.sql_registry import SqlRegistry 

96from ..transfers import RepoExportContext 

97from ..utils import transactional 

98from ._direct_butler_collections import DirectButlerCollections 

99 

100if TYPE_CHECKING: 

101 from lsst.resources import ResourceHandleProtocol 

102 

103 from .._dataset_provenance import DatasetProvenance 

104 from .._dataset_ref import DatasetId 

105 from ..datastore import DatasetRefURIs 

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

107 from ..registry import CollectionArgType, Registry 

108 from ..transfers import RepoImportBackend 

109 

110_LOG = getLogger(__name__) 

111 

112 

113class ButlerValidationError(ValidationError): 

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

115 

116 pass 

117 

118 

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

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

121 

122 Parameters 

123 ---------- 

124 config : `ButlerConfig` 

125 The configuration for this Butler instance. 

126 registry : `SqlRegistry` 

127 The object that manages dataset metadata and relationships. 

128 datastore : Datastore 

129 The object that manages actual dataset storage. 

130 storageClasses : StorageClassFactory 

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

132 describe them. 

133 

134 Notes 

135 ----- 

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

137 using this constructor directly. 

138 """ 

139 

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

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

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

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

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

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

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

147 # is returned from Butler.__new__() 

148 def __new__( 

149 cls, 

150 *, 

151 config: ButlerConfig, 

152 registry: SqlRegistry, 

153 datastore: Datastore, 

154 storageClasses: StorageClassFactory, 

155 metrics: ButlerMetrics | None = None, 

156 ) -> DirectButler: 

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

158 self._config = config 

159 self._registry = registry 

160 self._datastore = datastore 

161 self.storageClasses = storageClasses 

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

163 

164 # For execution butler the datastore needs a special 

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

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

167 # butler. 

168 self._datastore.set_retrieve_dataset_type_method(partial(_retrieve_dataset_type, registry)) 

169 

170 self._closed = False 

171 

172 return self 

173 

174 @classmethod 

175 def create_from_config( 

176 cls, 

177 config: ButlerConfig, 

178 *, 

179 options: ButlerInstanceOptions, 

180 without_datastore: bool = False, 

181 ) -> DirectButler: 

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

183 

184 Parameters 

185 ---------- 

186 config : `ButlerConfig` 

187 The configuration for this Butler instance. 

188 options : `ButlerInstanceOptions` 

189 Default values and other settings for the Butler instance. 

190 without_datastore : `bool`, optional 

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

192 to use a datastore will fail. 

193 

194 Notes 

195 ----- 

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

197 instead of using this function directly. 

198 """ 

199 if "run" in config or "collection" in config: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true

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

201 

202 defaults = RegistryDefaults.from_butler_instance_options(options) 

203 try: 

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

205 writeable = options.writeable 

206 if writeable is None: 

207 writeable = options.run is not None 

208 registry = _RegistryFactory(config).from_config( 

209 butlerRoot=butlerRoot, writeable=writeable, defaults=defaults 

210 ) 

211 if without_datastore: 

212 datastore: Datastore = NullDatastore(None, None) 

213 else: 

214 datastore = Datastore.fromConfig( 

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

216 ) 

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

218 # construct datastore first and pass opaque tables to registry 

219 # constructor. 

220 registry.make_datastore_tables(datastore.get_opaque_table_definitions()) 

221 storageClasses = StorageClassFactory() 

222 storageClasses.addFromConfig(config) 

223 

224 return DirectButler( 

225 config=config, 

226 registry=registry, 

227 datastore=datastore, 

228 storageClasses=storageClasses, 

229 metrics=options.metrics, 

230 ) 

231 except Exception: 

232 # Failures here usually mean that configuration is incomplete, 

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

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

235 raise 

236 

237 def clone( 

238 self, 

239 *, 

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

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

242 inferDefaults: bool | EllipsisType = ..., 

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

244 metrics: ButlerMetrics | None = None, 

245 ) -> DirectButler: 

246 # Docstring inherited 

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

248 registry = self._registry.copy(defaults) 

249 

250 return DirectButler( 

251 registry=registry, 

252 config=self._config, 

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

254 storageClasses=self.storageClasses, 

255 metrics=metrics, 

256 ) 

257 

258 def close(self) -> None: 

259 if not self._closed: 

260 self._closed = True 

261 self._registry.close() 

262 # Cause exceptions to be raised if a user attempts to use the 

263 # instance after closing it. Without this, Butler would still 

264 # work after being closed because of implementation details 

265 # of SqlAlchemy, but this may not continue to be the case in the 

266 # future and we don't want users to get in the habit of doing this. 

267 self._registry = _BUTLER_CLOSED_INSTANCE 

268 self._datastore = _BUTLER_CLOSED_INSTANCE 

269 

270 GENERATION: ClassVar[int] = 3 

271 """This is a Generation 3 Butler. 

272 

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

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

275 code. 

276 """ 

277 

278 @classmethod 

279 def _unpickle( 

280 cls, 

281 config: ButlerConfig, 

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

283 run: str | None, 

284 defaultDataId: dict[str, str], 

285 writeable: bool, 

286 ) -> DirectButler: 

287 """Callable used to unpickle a Butler. 

288 

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

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

291 can only invoke callables with positional arguments). 

292 

293 Parameters 

294 ---------- 

295 config : `ButlerConfig` 

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

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

298 utilized). 

299 collections : `tuple` [ `str` ] 

300 Names of the default collections to read from. 

301 run : `str`, optional 

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

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

304 Default data ID values. 

305 writeable : `bool` 

306 Whether the Butler should support write operations. 

307 

308 Returns 

309 ------- 

310 butler : `Butler` 

311 A new `Butler` instance. 

312 """ 

313 return cls.create_from_config( 

314 config=config, 

315 options=ButlerInstanceOptions( 

316 collections=collections, run=run, writeable=writeable, kwargs=defaultDataId 

317 ), 

318 ) 

319 

320 def __reduce__(self) -> tuple: 

321 """Support pickling.""" 

322 return ( 

323 DirectButler._unpickle, 

324 ( 

325 self._config, 

326 self.collections.defaults, 

327 self.run, 

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

329 self._registry.isWriteable(), 

330 ), 

331 ) 

332 

333 def __str__(self) -> str: 

334 return ( 

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

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

337 ) 

338 

339 def isWriteable(self) -> bool: 

340 # Docstring inherited. 

341 return self._registry.isWriteable() 

342 

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

344 """Context manager that enables caching.""" 

345 return self._registry.caching_context() 

346 

347 @contextlib.contextmanager 

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

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

350 

351 Transactions can be nested. 

352 """ 

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

354 yield 

355 

356 def _standardizeArgs( 

357 self, 

358 datasetRefOrType: DatasetRef | DatasetType | str, 

359 dataId: DataId | None = None, 

360 for_put: bool = True, 

361 **kwargs: Any, 

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

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

364 

365 Parameters 

366 ---------- 

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

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

369 Otherwise the `DatasetType` or name thereof. 

370 dataId : `dict` or `DataCoordinate` 

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

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

373 should be provided as the second argument. 

374 for_put : `bool`, optional 

375 If `True` this call is invoked as part of a `Butler.put`. 

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

377 parameter is only relevant if there is dataset type 

378 inconsistency. 

379 **kwargs 

380 Additional keyword arguments used to augment or construct a 

381 `DataCoordinate`. See `DataCoordinate.standardize` 

382 parameters. 

383 

384 Returns 

385 ------- 

386 datasetType : `DatasetType` 

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

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

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

390 `DataId`. 

391 

392 Notes 

393 ----- 

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

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

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

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

398 and a `DataId` or `dict`. 

399 

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

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

402 generally similarly flexible. 

403 """ 

404 externalDatasetType: DatasetType | None = None 

405 internalDatasetType: DatasetType | None = None 

406 if isinstance(datasetRefOrType, DatasetRef): 

407 if dataId is not None or kwargs: 

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

409 externalDatasetType = datasetRefOrType.datasetType 

410 dataId = datasetRefOrType.dataId 

411 else: 

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

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

414 if isinstance(datasetRefOrType, DatasetType): 

415 externalDatasetType = datasetRefOrType 

416 else: 

417 internalDatasetType = self.get_dataset_type(datasetRefOrType) 

418 

419 # Check that they are self-consistent 

420 if externalDatasetType is not None: 

421 registryDatasetType = self._get_registry_dataset_type(externalDatasetType) 

422 if registryDatasetType is None: 

423 # The caller has asked for a component that only their own 

424 # storage class defines, so there is no registry definition to 

425 # check against and their definition has to be used as given. 

426 internalDatasetType = externalDatasetType 

427 else: 

428 internalDatasetType = registryDatasetType 

429 if externalDatasetType != internalDatasetType: 

430 # We can allow differences if they are compatible, 

431 # depending on whether this is a get or a put. A get 

432 # requires that the python type associated with the 

433 # datastore can be converted to the user type. A put 

434 # requires that the user supplied python type can be 

435 # converted to the internal type expected by registry. 

436 relevantDatasetType = internalDatasetType 

437 if for_put: 

438 is_compatible = internalDatasetType.is_compatible_with(externalDatasetType) 

439 else: 

440 is_compatible = externalDatasetType.is_compatible_with(internalDatasetType) 

441 relevantDatasetType = externalDatasetType 

442 if not is_compatible: 

443 raise ValueError( 

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

445 f"registry definition ({internalDatasetType})" 

446 ) 

447 # Override the internal definition. 

448 internalDatasetType = relevantDatasetType 

449 

450 assert internalDatasetType is not None 

451 return internalDatasetType, dataId 

452 

453 def _get_registry_dataset_type(self, datasetType: DatasetType) -> DatasetType | None: 

454 """Return the registry definition corresponding to the given dataset 

455 type. 

456 

457 Parameters 

458 ---------- 

459 datasetType : `DatasetType` 

460 Dataset type, possibly a component, supplied by the caller. 

461 

462 Returns 

463 ------- 

464 registry_type : `DatasetType` or `None` 

465 The registry definition of ``datasetType``, or `None` if the 

466 registry has no definition for it. 

467 

468 Raises 

469 ------ 

470 MissingDatasetTypeError 

471 Raised if the dataset type is not registered. For a component 

472 dataset type this refers to the composite. 

473 

474 Notes 

475 ----- 

476 Only composites are registered: a component dataset type is derived 

477 from the storage class of its composite. A component that is defined 

478 only by a read-time storage class override therefore has no registry 

479 definition at all, even though its composite is registered, and `None` 

480 is returned to say so rather than something that only resembles a 

481 registry definition. 

482 """ 

483 parent_name, component = DatasetType.splitDatasetTypeName(datasetType.name) 

484 parent = self.get_dataset_type(parent_name) 

485 if component is None: 

486 return parent 

487 if component in parent.storageClass.allComponents(): 

488 return parent.makeComponentDatasetType(component) 

489 return None 

490 

491 def _rewrite_data_id( 

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

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

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

495 

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

497 allow the user to specify dimension records rather than dimension 

498 primary values. 

499 

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

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

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

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

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

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

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

507 value. 

508 

509 Keyword arguments can also use strings for dimensions like detector 

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

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

512 argument. 

513 

514 Parameters 

515 ---------- 

516 dataId : `dict` or `DataCoordinate` 

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

518 `DatasetRef` within a Collection. 

519 datasetType : `DatasetType` 

520 The dataset type associated with this dataId. Required to 

521 determine the relevant dimensions. 

522 **kwargs 

523 Additional keyword arguments used to augment or construct a 

524 `DataId`. See `DataId` parameters. 

525 

526 Returns 

527 ------- 

528 dataId : `dict` or `DataCoordinate` 

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

530 no keyword arguments, the original dataId will be returned 

531 unchanged. 

532 **kwargs : `dict` 

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

534 """ 

535 # Process dimension records that are using record information 

536 # rather than ids 

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

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

539 

540 if isinstance(dataId, DataCoordinate): 

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

542 if not kwargs: 542 ↛ 546line 542 didn't jump to line 546 because the condition on line 542 was always true

543 return dataId, kwargs 

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

545 # DataCoordinate only has values for real dimensions. 

546 newDataId.update(dataId.mapping) 

547 elif dataId: 

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

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

550 # a keyword parameter). 

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

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

553 # Someone is using a more human-readable dataId 

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

555 byRecord[dimensionName][record] = v 

556 else: 

557 newDataId[k] = v 

558 

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

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

561 # keys dimensions.record format. 

562 not_dimensions = {} 

563 

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

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

566 for dataIdDict in (newDataId, kwargs): 

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

568 for dimensionName in list(dataIdDict): 

569 value = dataIdDict[dimensionName] 

570 try: 

571 dimension = self.dimensions.dimensions[dimensionName] 

572 except KeyError: 

573 # This is not a real dimension 

574 not_dimensions[dimensionName] = value 

575 del dataIdDict[dimensionName] 

576 continue 

577 

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

579 # comparisons here 

580 if isinstance(value, numbers.Integral): 

581 value = int(value) 

582 

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

584 for alternate in dimension.alternateKeys: 584 ↛ 597line 584 didn't jump to line 597 because the loop on line 584 didn't complete

585 if isinstance(value, alternate.getPythonType()): 585 ↛ 584line 585 didn't jump to line 584 because the condition on line 585 was always true

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

587 del dataIdDict[dimensionName] 

588 _LOG.debug( 

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

590 dimensionName, 

591 dimensionName, 

592 alternate.name, 

593 value, 

594 ) 

595 break 

596 else: 

597 _LOG.warning( 

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

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

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

601 value, 

602 dimensionName, 

603 dimension.primaryKey.getPythonType(), 

604 ) 

605 

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

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

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

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

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

611 _LOG.debug( 

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

613 ) 

614 newDataId[k] = v 

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

616 kwargs = {} 

617 

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

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

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

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

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

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

624 # axis. 

625 if not_dimensions: 

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

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

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

629 # match. 

630 mandatoryDimensions = datasetType.dimensions.names # - provided 

631 

632 candidateDimensions: set[str] = set() 

633 candidateDimensions.update(mandatoryDimensions) 

634 

635 # For calibrations we may well be needing temporal dimensions 

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

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

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

639 # If we are not searching calibration collections things may 

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

641 # ambiguousness of the dataId... 

642 if datasetType.isCalibration(): 

643 for dim in self.dimensions.dimensions: 

644 if dim.temporal: 

645 candidateDimensions.add(str(dim)) 

646 

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

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

649 

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

651 # dimensions. 

652 counter: Counter[str] = Counter() 

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

654 

655 # Go through the missing dimensions and associate the 

656 # given names with records within those dimensions 

657 matched_dims = set() 

658 for dimensionName in candidateDimensions: 

659 dimension = self.dimensions.dimensions[dimensionName] 

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

661 for field in not_dimensions: 

662 if field in fields: 

663 guessedAssociation[dimensionName][field] = not_dimensions[field] 

664 counter[dimensionName] += 1 

665 assigned[field].add(dimensionName) 

666 matched_dims.add(field) 

667 

668 # Calculate the fields that matched nothing. 

669 never_found = set(not_dimensions) - matched_dims 

670 

671 if never_found: 

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

673 

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

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

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

677 # This means that day_obs with seq_num will result in 

678 # exposure.day_obs and not visit.day_obs 

679 # Also prefer an explicitly missing dimension over an inferred 

680 # temporal dimension. 

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

682 if len(assignedDimensions) > 1: 

683 # Pick the most popular (preferring mandatory dimensions) 

684 requiredButMissing = assignedDimensions.intersection(mandatoryDimensions) 

685 if requiredButMissing: 685 ↛ 686line 685 didn't jump to line 686 because the condition on line 685 was never true

686 candidateDimensions = requiredButMissing 

687 else: 

688 candidateDimensions = assignedDimensions 

689 

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

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

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

693 # visit since exposures are always defined and visits 

694 # are defined from exposures. 

695 if candidateDimensions == {"exposure", "visit"}: 695 ↛ 700line 695 didn't jump to line 700 because the condition on line 695 was always true

696 candidateDimensions = {"exposure"} 

697 

698 # Select the relevant items and get a new restricted 

699 # counter. 

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

701 duplicatesCounter: Counter[str] = Counter() 

702 duplicatesCounter.update(theseCounts) 

703 

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

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

706 # Returns a list of tuples 

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

708 

709 _LOG.debug( 

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

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

712 fieldName, 

713 ", ".join(assignedDimensions), 

714 selected, 

715 ) 

716 

717 for candidateDimension in assignedDimensions: 

718 if candidateDimension != selected: 

719 del guessedAssociation[candidateDimension][fieldName] 

720 

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

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

723 if values: # A dict might now be empty 

724 _LOG.debug( 

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

726 ) 

727 byRecord[dimensionName].update(values) 

728 

729 if byRecord: 

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

731 # them to the Id form 

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

733 if dimensionName in newDataId: 

734 _LOG.debug( 

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

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

737 dimensionName, 

738 newDataId[dimensionName], 

739 str(values), 

740 ) 

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

742 # Only query with relevant data ID values. 

743 filtered_data_id = { 

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

745 } 

746 try: 

747 recs = self.query_dimension_records( 

748 dimensionName, 

749 data_id=filtered_data_id, 

750 ) 

751 except (DataIdError, EmptyQueryResultError): 

752 raise DimensionValueError( 

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

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

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

756 ) from None 

757 if len(recs) == 1: 757 ↛ 771line 757 didn't jump to line 771 because the condition on line 757 was always true

758 errmsg: list[str] = [] 

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

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

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

762 if errmsg: 

763 raise DimensionValueError( 

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

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

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

767 ) 

768 else: 

769 # Multiple matches for an explicit dimension 

770 # should never happen but let downstream complain. 

771 pass 

772 continue 

773 

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

775 # Otherwise we can have detector queries being constrained 

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

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

778 filtered_data_id = { 

779 k: v 

780 for k, v in newDataId.items() 

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

782 } 

783 

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

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

786 # x and "exposure.seq_num". 

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

788 obj = getattr(obj, component) 

789 return obj 

790 

791 with self.query() as q: 

792 x = q.expression_factory 

793 # Build up a WHERE expression. 

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

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

796 extra_args.update(filtered_data_id) 

797 extra_args.update(kwargs) 

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

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

800 

801 if len(records) != 1: 

802 if len(records) > 1: 

803 # visit can have an ambiguous answer without involving 

804 # visit_system. The default visit_system is defined 

805 # by the instrument. 

806 if ( 806 ↛ 811line 806 didn't jump to line 811 because the condition on line 806 was never true

807 dimensionName == "visit" 

808 and "visit_system_membership" in self.dimensions 

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

810 ): 

811 instrument_records = self.query_dimension_records( 

812 "instrument", 

813 data_id=newDataId, 

814 explain=False, 

815 **kwargs, 

816 ) 

817 if len(instrument_records) == 1: 

818 visit_system = instrument_records[0].visit_system 

819 if visit_system is None: 

820 # Set to a value that will never match. 

821 visit_system = -1 

822 

823 # Look up each visit in the 

824 # visit_system_membership records. 

825 for rec in records: 

826 membership = self.query_dimension_records( 

827 # Use bind to allow zero results. 

828 # This is a fully-specified query. 

829 "visit_system_membership", 

830 instrument=instrument_records[0].name, 

831 visit_system=visit_system, 

832 visit=rec.id, 

833 explain=False, 

834 ) 

835 if membership: 

836 # This record is the right answer. 

837 records = {rec} 

838 break 

839 

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

841 if len(records) > 1: 841 ↛ 859line 841 didn't jump to line 859 because the condition on line 841 was always true

842 _LOG.debug( 

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

844 ) 

845 for r in records: 

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

847 raise DimensionValueError( 

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

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

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

851 ) 

852 else: 

853 raise DimensionValueError( 

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

855 f" records when constrained by {values}" 

856 ) 

857 

858 # Get the primary key from the real dimension object 

859 dimension = self.dimensions.dimensions[dimensionName] 

860 if not isinstance(dimension, Dimension): 860 ↛ 861line 860 didn't jump to line 861 because the condition on line 860 was never true

861 raise RuntimeError( 

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

863 ) 

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

865 

866 return newDataId, kwargs 

867 

868 def _findDatasetRef( 

869 self, 

870 datasetRefOrType: DatasetRef | DatasetType | str, 

871 dataId: DataId | None = None, 

872 *, 

873 collections: Any = None, 

874 predict: bool = False, 

875 run: str | None = None, 

876 datastore_records: bool = False, 

877 timespan: Timespan | None = None, 

878 **kwargs: Any, 

879 ) -> DatasetRef: 

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

881 the registry. 

882 

883 Parameters 

884 ---------- 

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

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

887 Otherwise the `DatasetType` or name thereof. 

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

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

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

891 should be provided as the first argument. 

892 collections : Any, optional 

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

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

895 to butler construction. 

896 predict : `bool`, optional 

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

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

899 Defaults to `False`. 

900 run : `str`, optional 

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

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

903 datastore_records : `bool`, optional 

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

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

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

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

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

909 in the data ID. 

910 **kwargs 

911 Additional keyword arguments used to augment or construct a 

912 `DataId`. See `DataId` parameters. 

913 

914 Returns 

915 ------- 

916 ref : `DatasetRef` 

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

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

919 resolved. 

920 

921 Raises 

922 ------ 

923 LookupError 

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

925 ``predict`` is `False`). 

926 ValueError 

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

928 differs from the one found in the registry. 

929 TypeError 

930 Raised if no collections were provided. 

931 """ 

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

933 if isinstance(datasetRefOrType, DatasetRef): 

934 if collections is not None: 934 ↛ 935line 934 didn't jump to line 935 because the condition on line 934 was never true

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

936 if predict and not datasetRefOrType.dataId.hasRecords(): 

937 return datasetRefOrType.expanded(self.registry.expandDataId(datasetRefOrType.dataId)) 

938 # May need to retrieve datastore records if requested. 

939 if datastore_records and datasetRefOrType._datastore_records is None: 

940 datasetRefOrType = self._registry.get_datastore_records(datasetRefOrType) 

941 return datasetRefOrType 

942 

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

944 

945 if datasetType.isCalibration(): 

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

947 # standardize the data ID without restricting the dimensions to 

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

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

950 # lookup. 

951 dataId = DataCoordinate.standardize( 

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

953 ) 

954 if timespan is None: 

955 if dataId.dimensions.temporal: 

956 dataId = self._registry.expandDataId(dataId) 

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

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

959 # specified an explicit timespan. 

960 timespan = dataId.timespan 

961 else: 

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

963 # results in more than one matching dataset. 

964 timespan = Timespan(None, None) 

965 else: 

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

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

968 # result even if no dataset is found. 

969 dataId = DataCoordinate.standardize( 

970 dataId, 

971 dimensions=datasetType.dimensions, 

972 defaults=self._registry.defaults.dataId, 

973 **kwargs, 

974 ) 

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

976 # present in the current collection. 

977 ref = self.find_dataset( 

978 datasetType, 

979 dataId, 

980 collections=collections, 

981 timespan=timespan, 

982 datastore_records=datastore_records, 

983 ) 

984 if ref is None: 

985 if predict: 

986 if run is None: 986 ↛ 990line 986 didn't jump to line 990 because the condition on line 986 was always true

987 run = self.run 

988 if run is None: 988 ↛ 989line 988 didn't jump to line 989 because the condition on line 988 was never true

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

990 dataId = self.registry.expandDataId(dataId) 

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

992 else: 

993 if collections is None: 

994 collections = self._registry.defaults.collections 

995 raise DatasetNotFoundError( 

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

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

998 ) 

999 if datasetType != ref.datasetType: 

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

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

1002 # registry definition. The DatasetRef must therefore be recreated 

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

1004 # returned. 

1005 ref = DatasetRef( 

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

1007 ) 

1008 

1009 return ref 

1010 

1011 @transactional 

1012 def put( 

1013 self, 

1014 obj: Any, 

1015 datasetRefOrType: DatasetRef | DatasetType | str, 

1016 /, 

1017 dataId: DataId | None = None, 

1018 *, 

1019 run: str | None = None, 

1020 provenance: DatasetProvenance | None = None, 

1021 **kwargs: Any, 

1022 ) -> DatasetRef: 

1023 """Store and register a dataset. 

1024 

1025 Parameters 

1026 ---------- 

1027 obj : `object` 

1028 The dataset. 

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

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

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

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

1033 dataId : `dict` or `DataCoordinate` 

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

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

1036 should be provided as the second argument. 

1037 run : `str`, optional 

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

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

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

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

1042 Not supported by all serialization mechanisms. 

1043 **kwargs 

1044 Additional keyword arguments used to augment or construct a 

1045 `DataCoordinate`. See `DataCoordinate.standardize` 

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

1047 

1048 Returns 

1049 ------- 

1050 ref : `DatasetRef` 

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

1052 given. 

1053 

1054 Raises 

1055 ------ 

1056 TypeError 

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

1058 """ 

1059 if isinstance(datasetRefOrType, DatasetRef): 

1060 # This is a direct put of predefined DatasetRef. 

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

1062 if run is not None: 1062 ↛ 1063line 1062 didn't jump to line 1063 because the condition on line 1062 was never true

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

1064 

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

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

1067 # dataset type and DataId, then _importDatasets will do 

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

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

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

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

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

1073 # unpredictable. 

1074 try: 

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

1076 except DuplicateIdError as e: 

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

1078 

1079 return datasetRefOrType 

1080 

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

1082 if not self.isWriteable(): 1082 ↛ 1083line 1082 didn't jump to line 1083 because the condition on line 1082 was never true

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

1084 

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

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

1087 

1088 # Handle dimension records in dataId 

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

1090 

1091 # Add Registry Dataset entry. 

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

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

1094 try: 

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

1096 except DuplicateIdError as e: 

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

1098 

1099 return ref 

1100 

1101 def getDeferred( 

1102 self, 

1103 datasetRefOrType: DatasetRef | DatasetType | str, 

1104 /, 

1105 dataId: DataId | None = None, 

1106 *, 

1107 parameters: dict | None = None, 

1108 collections: Any = None, 

1109 storageClass: str | StorageClass | None = None, 

1110 timespan: Timespan | None = None, 

1111 **kwargs: Any, 

1112 ) -> DeferredDatasetHandle: 

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

1114 after an immediate registry lookup. 

1115 

1116 Parameters 

1117 ---------- 

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

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

1120 Otherwise the `DatasetType` or name thereof. 

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

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

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

1124 should be provided as the first argument. 

1125 parameters : `dict` 

1126 Additional StorageClass-defined options to control reading, 

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

1128 collections : Any, optional 

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

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

1131 to butler construction. 

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

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

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

1135 the dataset type definition for this dataset. Specifying a 

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

1137 This type must be compatible with the original type. 

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

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

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

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

1142 in the data ID. 

1143 **kwargs 

1144 Additional keyword arguments used to augment or construct a 

1145 `DataId`. See `DataId` parameters. 

1146 

1147 Returns 

1148 ------- 

1149 obj : `DeferredDatasetHandle` 

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

1151 

1152 Raises 

1153 ------ 

1154 LookupError 

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

1156 datastore. 

1157 ValueError 

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

1159 differs from the one found in the registry. 

1160 TypeError 

1161 Raised if no collections were provided. 

1162 """ 

1163 if isinstance(datasetRefOrType, DatasetRef): 

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

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

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

1167 # a file. 

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

1169 ref = datasetRefOrType 

1170 else: 

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

1172 else: 

1173 ref = self._findDatasetRef( 

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

1175 ) 

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

1177 

1178 def get( 

1179 self, 

1180 datasetRefOrType: DatasetRef | DatasetType | str, 

1181 /, 

1182 dataId: DataId | None = None, 

1183 *, 

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

1185 collections: Any = None, 

1186 storageClass: StorageClass | str | None = None, 

1187 timespan: Timespan | None = None, 

1188 **kwargs: Any, 

1189 ) -> Any: 

1190 """Retrieve a stored dataset. 

1191 

1192 Parameters 

1193 ---------- 

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

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

1196 Otherwise the `DatasetType` or name thereof. 

1197 If a resolved `DatasetRef`, the associated dataset 

1198 is returned directly without additional querying. 

1199 dataId : `dict` or `DataCoordinate` 

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

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

1202 should be provided as the first argument. 

1203 parameters : `dict` 

1204 Additional StorageClass-defined options to control reading, 

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

1206 collections : Any, optional 

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

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

1209 to butler construction. 

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

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

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

1213 the dataset type definition for this dataset. Specifying a 

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

1215 This type must be compatible with the original type. 

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

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

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

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

1220 in the data ID. 

1221 **kwargs 

1222 Additional keyword arguments used to augment or construct a 

1223 `DataCoordinate`. See `DataCoordinate.standardize` 

1224 parameters. 

1225 

1226 Returns 

1227 ------- 

1228 obj : `object` 

1229 The dataset. 

1230 

1231 Raises 

1232 ------ 

1233 LookupError 

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

1235 TypeError 

1236 Raised if no collections were provided. 

1237 

1238 Notes 

1239 ----- 

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

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

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

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

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

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

1246 ``exposure`` is a temporal dimension. 

1247 """ 

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

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

1250 ref = self._findDatasetRef( 

1251 datasetRefOrType, 

1252 dataId, 

1253 collections=collections, 

1254 datastore_records=True, 

1255 timespan=timespan, 

1256 **kwargs, 

1257 ) 

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

1259 

1260 def getURIs( 

1261 self, 

1262 datasetRefOrType: DatasetRef | DatasetType | str, 

1263 /, 

1264 dataId: DataId | None = None, 

1265 *, 

1266 predict: bool = False, 

1267 collections: Any = None, 

1268 run: str | None = None, 

1269 **kwargs: Any, 

1270 ) -> DatasetRefURIs: 

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

1272 

1273 Parameters 

1274 ---------- 

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

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

1277 Otherwise the `DatasetType` or name thereof. 

1278 dataId : `dict` or `DataCoordinate` 

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

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

1281 should be provided as the first argument. 

1282 predict : `bool` 

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

1284 been written. 

1285 collections : Any, optional 

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

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

1288 to butler construction. 

1289 run : `str`, optional 

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

1291 **kwargs 

1292 Additional keyword arguments used to augment or construct a 

1293 `DataCoordinate`. See `DataCoordinate.standardize` 

1294 parameters. 

1295 

1296 Returns 

1297 ------- 

1298 uris : `DatasetRefURIs` 

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

1300 the dataset was disassembled within the datastore this may be 

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

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

1303 """ 

1304 ref = self._findDatasetRef( 

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

1306 ) 

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

1308 

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

1310 return self._registry.getDatasetType(name) 

1311 

1312 def get_dataset( 

1313 self, 

1314 id: DatasetId | str, 

1315 *, 

1316 storage_class: str | StorageClass | None = None, 

1317 dimension_records: bool = False, 

1318 datastore_records: bool = False, 

1319 ) -> DatasetRef | None: 

1320 id = _to_uuid(id) 

1321 ref = self._registry.getDataset(id) 

1322 if ref is not None: 

1323 if dimension_records: 1323 ↛ 1324line 1323 didn't jump to line 1324 because the condition on line 1323 was never true

1324 ref = ref.expanded( 

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

1326 ) 

1327 if storage_class: 1327 ↛ 1328line 1327 didn't jump to line 1328 because the condition on line 1327 was never true

1328 ref = ref.overrideStorageClass(storage_class) 

1329 if datastore_records: 1329 ↛ 1330line 1329 didn't jump to line 1330 because the condition on line 1329 was never true

1330 ref = self._registry.get_datastore_records(ref) 

1331 return ref 

1332 

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

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

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

1336 

1337 def find_dataset( 

1338 self, 

1339 dataset_type: DatasetType | str, 

1340 data_id: DataId | None = None, 

1341 *, 

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

1343 timespan: Timespan | None = None, 

1344 storage_class: str | StorageClass | None = None, 

1345 dimension_records: bool = False, 

1346 datastore_records: bool = False, 

1347 **kwargs: Any, 

1348 ) -> DatasetRef | None: 

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

1350 # keys. 

1351 if isinstance(dataset_type, str): 

1352 actual_type = self.get_dataset_type(dataset_type) 

1353 else: 

1354 actual_type = dataset_type 

1355 

1356 # Store the component for later. 

1357 component_name = actual_type.component() 

1358 if actual_type.isComponent(): 

1359 parent_type = actual_type.makeCompositeDatasetType() 

1360 else: 

1361 parent_type = actual_type 

1362 

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

1364 

1365 ref = self.registry.findDataset( 

1366 parent_type, 

1367 data_id, 

1368 collections=collections, 

1369 timespan=timespan, 

1370 datastore_records=datastore_records, 

1371 **kwargs, 

1372 ) 

1373 if ref is not None and dimension_records: 1373 ↛ 1374line 1373 didn't jump to line 1374 because the condition on line 1373 was never true

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

1375 if ref is not None and component_name: 

1376 ref = ref.makeComponentRef(component_name) 

1377 if ref is not None and storage_class is not None: 1377 ↛ 1378line 1377 didn't jump to line 1378 because the condition on line 1377 was never true

1378 ref = ref.overrideStorageClass(storage_class) 

1379 

1380 return ref 

1381 

1382 def retrieve_artifacts_zip( 

1383 self, 

1384 refs: Iterable[DatasetRef], 

1385 destination: ResourcePathExpression, 

1386 overwrite: bool = True, 

1387 ) -> ResourcePath: 

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

1389 

1390 def retrieveArtifacts( 

1391 self, 

1392 refs: Iterable[DatasetRef], 

1393 destination: ResourcePathExpression, 

1394 transfer: str = "auto", 

1395 preserve_path: bool = True, 

1396 overwrite: bool = False, 

1397 ) -> list[ResourcePath]: 

1398 # Docstring inherited. 

1399 outdir = ResourcePath(destination) 

1400 artifact_map = self._datastore.retrieveArtifacts( 

1401 refs, 

1402 outdir, 

1403 transfer=transfer, 

1404 preserve_path=preserve_path, 

1405 overwrite=overwrite, 

1406 write_index=True, 

1407 ) 

1408 return list(artifact_map) 

1409 

1410 def exists( 

1411 self, 

1412 dataset_ref_or_type: DatasetRef | DatasetType | str, 

1413 /, 

1414 data_id: DataId | None = None, 

1415 *, 

1416 full_check: bool = True, 

1417 collections: Any = None, 

1418 **kwargs: Any, 

1419 ) -> DatasetExistence: 

1420 # Docstring inherited. 

1421 existence = DatasetExistence.UNRECOGNIZED 

1422 

1423 if isinstance(dataset_ref_or_type, DatasetRef): 

1424 if collections is not None: 1424 ↛ 1425line 1424 didn't jump to line 1425 because the condition on line 1424 was never true

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

1426 if data_id is not None: 1426 ↛ 1427line 1426 didn't jump to line 1427 because the condition on line 1426 was never true

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

1428 ref = dataset_ref_or_type 

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

1430 if registry_ref is not None: 

1431 existence |= DatasetExistence.RECORDED 

1432 

1433 if dataset_ref_or_type != registry_ref: 

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

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

1436 # the method. 

1437 if registry_ref.is_compatible_with(dataset_ref_or_type): 

1438 # Use the registry version from now on. 

1439 ref = registry_ref 

1440 else: 

1441 raise ValueError( 

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

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

1444 ) 

1445 else: 

1446 try: 

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

1448 except (LookupError, TypeError): 

1449 return existence 

1450 existence |= DatasetExistence.RECORDED 

1451 

1452 if self._datastore.knows(ref): 

1453 existence |= DatasetExistence.DATASTORE 

1454 

1455 if full_check: 

1456 if self._datastore.exists(ref): 

1457 existence |= DatasetExistence._ARTIFACT 

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

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

1460 existence |= DatasetExistence(DatasetExistence._ASSUMED) 

1461 

1462 return existence 

1463 

1464 def _exists_many( 

1465 self, 

1466 refs: Iterable[DatasetRef], 

1467 /, 

1468 *, 

1469 full_check: bool = True, 

1470 ) -> dict[DatasetRef, DatasetExistence]: 

1471 # Docstring inherited. 

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

1473 

1474 # Check which refs exist in the registry. 

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

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

1477 # Consistency between the given DatasetRef and the information 

1478 # recorded in the registry is not verified. 

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

1480 

1481 # Ask datastore if it knows about these refs. 

1482 knows = self._datastore.knows_these(refs) 

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

1484 if known: 

1485 existence[ref] |= DatasetExistence.DATASTORE 

1486 

1487 if full_check: 

1488 mexists = self._datastore.mexists(refs) 

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

1490 if exists: 

1491 existence[ref] |= DatasetExistence._ARTIFACT 

1492 else: 

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

1494 for ref in existence: 

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

1496 existence[ref] |= DatasetExistence._ASSUMED 

1497 

1498 return existence 

1499 

1500 def removeRuns( 

1501 self, 

1502 names: Iterable[str], 

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

1504 *, 

1505 unlink_from_chains: bool = False, 

1506 ) -> None: 

1507 # Docstring inherited. 

1508 if not self.isWriteable(): 1508 ↛ 1509line 1508 didn't jump to line 1509 because the condition on line 1508 was never true

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

1510 

1511 if unstore is not _DeprecatedDefault: 1511 ↛ 1514line 1511 didn't jump to line 1514 because the condition on line 1511 was never true

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

1513 # ignored. 

1514 if unstore is True: 

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

1516 else: 

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

1518 warnings.warn( 

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

1520 category=FutureWarning, 

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

1522 ) 

1523 

1524 names = list(names) 

1525 refs: list[DatasetRef] = [] 

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

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

1528 

1529 with self._caching_context(): 

1530 # Get information about these RUNs. 

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

1532 for info in collections_info: 

1533 if info.type is not CollectionType.RUN: 

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

1535 if unlink_from_chains: 

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

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

1538 for parent in info.parents: 

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

1540 

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

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

1543 

1544 # Get all the datasets from these runs. 

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

1546 

1547 # Call pruneDatasets since we are deliberately removing 

1548 # datasets in chunks from the RUN collections rather than 

1549 # attempting to remove everything at once. 

1550 with time_this( 

1551 _LOG, 

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

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

1554 ): 

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

1556 

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

1558 with self._registry.transaction(): 

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

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

1561 # by this point. 

1562 if unlink_from_chains: 

1563 # Use deterministic order for deletions to attempt to minimize 

1564 # risk of deadlocks for parallel deletes. 

1565 for parent in sorted(parents_to_children): 

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

1567 # Sort to avoid potential deadlocks. 

1568 for name in sorted(names): 

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

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

1571 self._registry.removeCollection(name) 

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

1573 

1574 def pruneDatasets( 

1575 self, 

1576 refs: Iterable[DatasetRef], 

1577 *, 

1578 disassociate: bool = True, 

1579 unstore: bool = False, 

1580 tags: Iterable[str] = (), 

1581 purge: bool = False, 

1582 ) -> None: 

1583 # docstring inherited from LimitedButler 

1584 

1585 if not self.isWriteable(): 1585 ↛ 1586line 1585 didn't jump to line 1586 because the condition on line 1585 was never true

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

1587 if purge: 

1588 if not disassociate: 1588 ↛ 1589line 1588 didn't jump to line 1589 because the condition on line 1588 was never true

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

1590 if not unstore: 1590 ↛ 1591line 1590 didn't jump to line 1591 because the condition on line 1590 was never true

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

1592 elif disassociate: 

1593 tags = tuple(tags) 

1594 if not tags: 1594 ↛ 1595line 1594 didn't jump to line 1595 because the condition on line 1594 was never true

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

1596 for tag in tags: 

1597 collectionType = self._registry.getCollectionType(tag) 

1598 if collectionType is not CollectionType.TAGGED: 1598 ↛ 1599line 1598 didn't jump to line 1599 because the condition on line 1598 was never true

1599 raise TypeError( 

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

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

1602 ) 

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

1604 # over multiple times. 

1605 refs = list(refs) 

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

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

1608 # components in a separate file 

1609 for ref in refs: 

1610 if ref.datasetType.component(): 1610 ↛ 1611line 1610 didn't jump to line 1611 because the condition on line 1610 was never true

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

1612 

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

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

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

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

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

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

1619 # emptying of that chunk. 

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

1621 chunk_size = 50_000 

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

1623 if n_chunks > 1: 1623 ↛ 1624line 1623 didn't jump to line 1624 because the condition on line 1623 was never true

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

1625 chunk_num = 0 

1626 for chunked_refs in progress.wrap( 

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

1628 ): 

1629 chunk_num += 1 

1630 _LOG.verbose( 

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

1632 len(chunked_refs), 

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

1634 chunk_num, 

1635 n_chunks, 

1636 ) 

1637 with time_this( 

1638 _LOG, 

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

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

1641 ): 

1642 self._prune_datasets( 

1643 chunked_refs, tags=tags, unstore=unstore, purge=purge, disassociate=disassociate 

1644 ) 

1645 

1646 def _prune_datasets( 

1647 self, 

1648 refs: Collection[DatasetRef], 

1649 *, 

1650 disassociate: bool = True, 

1651 unstore: bool = False, 

1652 tags: Iterable[str] = (), 

1653 purge: bool = False, 

1654 ) -> None: 

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

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

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

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

1659 # Registry operations. 

1660 with self.transaction(): 

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

1662 if unstore: 

1663 with time_this( 

1664 _LOG, 

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

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

1667 ): 

1668 self._datastore.trash(refs) 

1669 if purge: 

1670 with time_this( 

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

1672 ): 

1673 self._registry.removeDatasets(refs) 

1674 elif disassociate: 

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

1676 with time_this( 

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

1678 ): 

1679 for tag in tags: 

1680 self._registry.disassociate(tag, refs) 

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

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

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

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

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

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

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

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

1689 # in the dataset_location_trash table. 

1690 if unstore: 

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

1692 # emptying to the refs that this call trashed. 

1693 with time_this( 

1694 _LOG, 

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

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

1697 ): 

1698 self._datastore.emptyTrash(refs=refs) 

1699 

1700 def ingest_zip( 

1701 self, 

1702 zip_file: ResourcePathExpression, 

1703 transfer: str = "auto", 

1704 *, 

1705 transfer_dimensions: bool = False, 

1706 dry_run: bool = False, 

1707 skip_existing: bool = False, 

1708 ) -> None: 

1709 # Docstring inherited. 

1710 if not self.isWriteable(): 1710 ↛ 1711line 1710 didn't jump to line 1711 because the condition on line 1710 was never true

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

1712 

1713 zip_path = ResourcePath(zip_file) 

1714 index = ZipIndex.from_zip_file(zip_path) 

1715 _LOG.verbose( 

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

1717 ) 

1718 

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

1720 # code by reconstructing FileDataset from the index. 

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

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

1723 datasets: list[FileDataset] = [] 

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

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

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

1727 # included. 

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

1729 if not unprocessed: 1729 ↛ 1730line 1729 didn't jump to line 1730 because the condition on line 1729 was never true

1730 continue 

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

1732 datasets.append(dataset) 

1733 processed_ids.update(unprocessed) 

1734 

1735 new_datasets, existing_datasets = self._partition_datasets_by_known(datasets) 

1736 if existing_datasets: 

1737 if skip_existing: 

1738 _LOG.info( 

1739 "Skipping %d datasets from zip file %s which already exist in the repository.", 

1740 len(existing_datasets), 

1741 zip_file, 

1742 ) 

1743 else: 

1744 raise ConflictingDefinitionError( 

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

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

1747 ) 

1748 if new_datasets: 1748 ↛ 1751line 1748 didn't jump to line 1751 because the condition on line 1748 was never true

1749 # Can not yet support partial zip ingests where a zip contains 

1750 # some datasets that are already in another zip. 

1751 raise ValueError( 

1752 f"The given zip file from {zip_file} contains {len(new_datasets)} datasets not known " 

1753 f"to this butler but also contains {len(existing_datasets)} datasets already known to " 

1754 "this butler. Currently butler can not ingest zip files with overlapping content." 

1755 ) 

1756 return 

1757 

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

1759 # here. 

1760 # 

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

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

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

1764 # name. (See DM-47543). 

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

1766 for run in sorted(runs): 

1767 registered = self.collections.register(run) 

1768 if registered: 

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

1770 

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

1772 import_info = self._prepare_ingest_file_datasets( 

1773 datasets, progress, dry_run=dry_run, transfer_dimensions=transfer_dimensions 

1774 ) 

1775 

1776 # Calculate some statistics based on the given list of datasets. 

1777 n_datasets = 0 

1778 for d in datasets: 

1779 n_datasets += len(d.refs) 

1780 srefs = "s" if n_datasets != 1 else "" 

1781 

1782 with ( 

1783 self._metrics.instrument_ingest( 

1784 n_datasets, 

1785 _LOG, 

1786 msg="Ingesting zip file %s with %s dataset%s", 

1787 args=(zip_file, n_datasets, srefs), 

1788 ), 

1789 self.transaction(), 

1790 ): 

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

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

1793 

1794 try: 

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

1796 except DuplicateIdError as e: 

1797 raise ConflictingDefinitionError( 

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

1799 ) from e 

1800 

1801 def _prepare_ingest_file_datasets( 

1802 self, 

1803 datasets: Sequence[FileDataset], 

1804 progress: Progress, 

1805 *, 

1806 transfer_dimensions: bool = False, 

1807 dry_run: bool = False, 

1808 ) -> _ImportDatasetsInfo: 

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

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

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

1812 defaultdict(dict) 

1813 ) 

1814 

1815 # All the refs we need to import. 

1816 refs: list[DatasetRef] = [] 

1817 

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

1819 for ref in dataset.refs: 

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

1821 

1822 if ref.dataId in groupedDataIds[group_key]: 1822 ↛ 1823line 1822 didn't jump to line 1823 because the condition on line 1822 was never true

1823 raise ConflictingDefinitionError( 

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

1825 " DataId as other ingest dataset" 

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

1827 f" ({ref.dataId})" 

1828 ) 

1829 

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

1831 refs.extend(dataset.refs) 

1832 

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

1834 # extracted. 

1835 import_info = self._prepare_for_import_refs( 

1836 self, 

1837 refs, 

1838 register_dataset_types=True, 

1839 dry_run=dry_run, 

1840 transfer_dimensions=transfer_dimensions, 

1841 ) 

1842 return import_info 

1843 

1844 def _ingest_file_datasets( 

1845 self, 

1846 datasets: Sequence[FileDataset], 

1847 import_info: _ImportDatasetsInfo, 

1848 progress: Progress, 

1849 *, 

1850 dry_run: bool = False, 

1851 ) -> None: 

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

1853 imported_refs = self._import_grouped_refs( 

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

1855 ) 

1856 

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

1858 # FileDatasets for datastore to use. 

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

1860 

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

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

1863 

1864 def ingest( 

1865 self, 

1866 *datasets: FileDataset, 

1867 transfer: str | None = "auto", 

1868 record_validation_info: bool = True, 

1869 skip_existing: bool = False, 

1870 ) -> None: 

1871 # Docstring inherited. 

1872 if not datasets: 

1873 return 

1874 if not self.isWriteable(): 1874 ↛ 1875line 1874 didn't jump to line 1875 because the condition on line 1874 was never true

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

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

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

1878 

1879 new_datasets, existing_datasets = self._partition_datasets_by_known(datasets) 

1880 if existing_datasets: 

1881 if skip_existing: 

1882 _LOG.info( 

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

1884 ) 

1885 else: 

1886 raise ConflictingDefinitionError( 

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

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

1889 ) 

1890 

1891 # Calculate some statistics based on the given list of datasets. 

1892 n_files = len(datasets) 

1893 n_datasets = 0 

1894 for d in datasets: 

1895 n_datasets += len(d.refs) 

1896 sfiles = "s" if n_files != 1 else "" 

1897 srefs = "s" if n_datasets != 1 else "" 

1898 

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

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

1901 # existing datasets. 

1902 import_info = self._prepare_ingest_file_datasets(datasets, progress) 

1903 

1904 with ( 

1905 self._metrics.instrument_ingest( 

1906 n_datasets, 

1907 _LOG, 

1908 msg="Ingesting %s file%s with %s dataset%s", 

1909 args=(n_files, sfiles, n_datasets, srefs), 

1910 ), 

1911 self.transaction(), 

1912 ): 

1913 self._ingest_file_datasets(datasets, import_info, progress) 

1914 

1915 # Bulk-insert everything into Datastore. 

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

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

1918 # The _partition_datasets_by_known logic above should catch most 

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

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

1921 # violation here. 

1922 try: 

1923 self._datastore.ingest( 

1924 *new_datasets, transfer=transfer, record_validation_info=record_validation_info 

1925 ) 

1926 except DuplicateIdError as e: 

1927 raise ConflictingDefinitionError( 

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

1929 ) from e 

1930 

1931 def _partition_datasets_by_known( 

1932 self, datasets: Iterable[FileDataset] 

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

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

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

1936 not. 

1937 """ 

1938 new_datasets = [] 

1939 existing_datasets = [] 

1940 

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

1942 known_refs = self._datastore.knows_these(refs) 

1943 

1944 for dataset in datasets: 

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

1946 existing_datasets.append(dataset) 

1947 else: 

1948 new_datasets.append(dataset) 

1949 

1950 return new_datasets, existing_datasets 

1951 

1952 @contextlib.contextmanager 

1953 def export( 

1954 self, 

1955 *, 

1956 directory: str | None = None, 

1957 filename: str | None = None, 

1958 format: str | None = None, 

1959 transfer: str | None = None, 

1960 ) -> Iterator[RepoExportContext]: 

1961 # Docstring inherited. 

1962 if directory is None and transfer is not None: 1962 ↛ 1963line 1962 didn't jump to line 1963 because the condition on line 1962 was never true

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

1964 if transfer == "move": 1964 ↛ 1965line 1964 didn't jump to line 1965 because the condition on line 1964 was never true

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

1966 if format is None: 

1967 if filename is None: 1967 ↛ 1968line 1967 didn't jump to line 1968 because the condition on line 1967 was never true

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

1969 else: 

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

1971 if not format: 

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

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

1974 elif filename is None: 1974 ↛ 1976line 1974 didn't jump to line 1976 because the condition on line 1974 was always true

1975 filename = f"export.{format}" 

1976 if directory is not None: 

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

1978 formats = self._config["repo_transfer_formats"] 

1979 if format not in formats: 

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

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

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

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

1984 try: 

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

1986 with self._caching_context(): 

1987 yield helper 

1988 except BaseException: 

1989 raise 

1990 else: 

1991 helper._finish() 

1992 

1993 def import_( 

1994 self, 

1995 *, 

1996 directory: ResourcePathExpression | None = None, 

1997 filename: ResourcePathExpression | TextIO | None = None, 

1998 format: str | None = None, 

1999 transfer: str | None = None, 

2000 skip_dimensions: set | None = None, 

2001 record_validation_info: bool = True, 

2002 without_datastore: bool = False, 

2003 ) -> None: 

2004 # Docstring inherited. 

2005 if not self.isWriteable(): 2005 ↛ 2006line 2005 didn't jump to line 2006 because the condition on line 2005 was never true

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

2007 if filename is None and format is not None: 2007 ↛ 2008line 2007 didn't jump to line 2008 because the condition on line 2007 was never true

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

2009 if directory is not None: 

2010 directory = ResourcePath(directory, forceDirectory=True) 

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

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

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

2014 if format is None: 2014 ↛ 2016line 2014 didn't jump to line 2016 because the condition on line 2014 was always true

2015 format = filename.getExtension() 

2016 if not filename.isabs() and directory is not None: 2016 ↛ 2017line 2016 didn't jump to line 2017 because the condition on line 2016 was never true

2017 potential = directory.join(filename) 

2018 exists_in_cwd = filename.exists() 

2019 exists_in_dir = potential.exists() 

2020 if exists_in_cwd and exists_in_dir: 

2021 _LOG.warning( 

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

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

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

2025 filename, 

2026 potential, 

2027 ) 

2028 # Given they specified an explicit directory and that 

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

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

2031 filename = potential 

2032 elif exists_in_dir: 

2033 filename = potential 

2034 elif not exists_in_cwd and not exists_in_dir: 

2035 # Raise early. 

2036 raise FileNotFoundError( 

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

2038 ) 

2039 elif format is None: 2039 ↛ 2040line 2039 didn't jump to line 2040 because the condition on line 2039 was never true

2040 format = ".yaml" 

2041 BackendClass: type[RepoImportBackend] = get_class_of( 

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

2043 ) 

2044 

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

2046 with self._caching_context(): 

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

2048 backend.register() 

2049 with self.transaction(): 

2050 backend.load( 

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

2052 directory=directory, 

2053 transfer=transfer, 

2054 skip_dimensions=skip_dimensions, 

2055 record_validation_info=record_validation_info, 

2056 ) 

2057 

2058 if isinstance(filename, ResourcePath): 

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

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

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

2062 doImport(stream) 

2063 else: 

2064 doImport(filename) # type: ignore 

2065 

2066 def transfer_dimension_records_from( 

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

2068 ) -> None: 

2069 # Allowed dimensions in the target butler. 

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

2071 

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

2073 

2074 dimension_records = self._extract_all_dimension_records_from_data_ids( 

2075 source_butler, data_ids, elements 

2076 ) 

2077 

2078 # Insert order is important. 

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

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

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

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

2083 # is consistent. 

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

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

2086 

2087 def _extract_all_dimension_records_from_data_ids( 

2088 self, 

2089 source_butler: LimitedButler | Butler, 

2090 data_ids: set[DataCoordinate], 

2091 allowed_elements: frozenset[DimensionElement], 

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

2093 primary_records = self._extract_dimension_records_from_data_ids( 

2094 source_butler, data_ids, allowed_elements 

2095 ) 

2096 

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

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

2099 # Get dimensions that depend on this dimension. 

2100 populated_by = self.dimensions.get_elements_populated_by( 

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

2102 ) 

2103 if populated_by: 

2104 for element in populated_by: 

2105 if element not in allowed_elements: 2105 ↛ 2106line 2105 didn't jump to line 2106 because the condition on line 2105 was never true

2106 continue 

2107 if element.name == original_element.name: 

2108 continue 

2109 

2110 if element.name in primary_records: 

2111 # If this element has already been stored avoid 

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

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

2114 # visit_detector_region but querying 

2115 # visit_detector_region by visit will return all the 

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

2117 # constrain this. 

2118 # To constrain the query the original dataIds would 

2119 # have to be scanned. 

2120 continue 

2121 

2122 if record_mapping: 2122 ↛ 2104line 2122 didn't jump to line 2104 because the condition on line 2122 was always true

2123 if not isinstance(source_butler, Butler): 2123 ↛ 2124line 2123 didn't jump to line 2124 because the condition on line 2123 was never true

2124 raise RuntimeError( 

2125 f"Transferring populated_by records like {element.name}" 

2126 " requires a full Butler." 

2127 ) 

2128 

2129 with source_butler.query() as query: 

2130 records = query.join_data_coordinates(record_mapping.keys()).dimension_records( 

2131 element.name 

2132 ) 

2133 for record in records: 

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

2135 

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

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

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

2139 # already have. 

2140 missing_data_ids = set() 

2141 for record_mapping in additional_records.values(): 

2142 for data_id in record_mapping.keys(): 

2143 for dimension in data_id.dimensions.required: 

2144 element = source_butler.dimensions[dimension] 

2145 dimension_key = data_id.subset(dimension) 

2146 if dimension_key not in primary_records[element]: 

2147 missing_data_ids.add(dimension_key) 

2148 

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

2150 # also need to carry over additional populated_by records. 

2151 secondary_records = self._extract_dimension_records_from_data_ids( 

2152 source_butler, missing_data_ids, allowed_elements 

2153 ) 

2154 

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

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

2157 primary_records[name].update(record_mapping) 

2158 

2159 return primary_records 

2160 

2161 def _extract_dimension_records_from_data_ids( 

2162 self, 

2163 source_butler: LimitedButler | Butler, 

2164 data_ids: Iterable[DataCoordinate], 

2165 allowed_elements: frozenset[DimensionElement], 

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

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

2168 

2169 data_ids = set(data_ids) 

2170 if not all(data_id.hasRecords() for data_id in data_ids): 

2171 if isinstance(source_butler, Butler): 2171 ↛ 2174line 2171 didn't jump to line 2174 because the condition on line 2171 was always true

2172 data_ids = source_butler._expand_data_ids(data_ids) 

2173 else: 

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

2175 

2176 for data_id in data_ids: 

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

2178 # butler things will break later. 

2179 for element_name in data_id.dimensions.elements: 

2180 record = data_id.records[element_name] 

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

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

2183 

2184 return dimension_records 

2185 

2186 def _cast_universe_for_import_refs( 

2187 self, source_refs: Iterable[DatasetRef] 

2188 ) -> Mapping[DatasetType, list[DatasetRef]]: 

2189 """Try to cast imported refs to the target universe if possible. 

2190 

2191 Parameters 

2192 ---------- 

2193 source_refs 

2194 The refs to be imported. 

2195 

2196 Returns 

2197 ------- 

2198 refs 

2199 The refs to be imported, grouped by dataset type, with the dataset 

2200 types cast to the target universe. 

2201 

2202 Raises 

2203 ------ 

2204 InconsistentUniverseError 

2205 Raised if any reference cannot be converted to target universe. 

2206 

2207 Notes 

2208 ----- 

2209 Potentially this method can perform a non-trivial migrations of the 

2210 datasets by modifying dimensions and dataIds. Presently though it can 

2211 only perform a trivial validation of the dataset types compatibility. 

2212 Returned mapping will contain dataset types in the new universe, but 

2213 returned references will still have the original dataset types as 

2214 there is presently no easy way to replace dataset type in a reference. 

2215 """ 

2216 # In theory input refs could come from multiple universes, but in 

2217 # practice this will not happen, so just group everything by dataset 

2218 # type. 

2219 refs_by_source_type: defaultdict[DatasetType, list[DatasetRef]] = defaultdict(list) 

2220 for ref in source_refs: 

2221 refs_by_source_type[ref.datasetType].append(ref) 

2222 

2223 refs_by_type: defaultdict[DatasetType, list[DatasetRef]] = defaultdict(list) 

2224 for source_type, refs in refs_by_source_type.items(): 

2225 refs_by_type[source_type.conform_to(self.dimensions)] = refs 

2226 

2227 return refs_by_type 

2228 

2229 def _prepare_for_import_refs( 

2230 self, 

2231 source_butler: LimitedButler, 

2232 source_refs: Iterable[DatasetRef], 

2233 *, 

2234 register_dataset_types: bool = False, 

2235 transfer_dimensions: bool = False, 

2236 dry_run: bool = False, 

2237 ) -> _ImportDatasetsInfo: 

2238 # Docstring inherited. 

2239 if not self.isWriteable() and not dry_run: 2239 ↛ 2240line 2239 didn't jump to line 2240 because the condition on line 2239 was never true

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

2241 

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

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

2244 if not isinstance(source_refs, collections.abc.Collection): 2244 ↛ 2245line 2244 didn't jump to line 2245 because the condition on line 2244 was never true

2245 source_refs = list(source_refs) 

2246 

2247 original_count = len(source_refs) 

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

2249 _LOG.log( 

2250 log_level, 

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

2252 original_count, 

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

2254 str(self), 

2255 ) 

2256 

2257 refs_by_type = self._cast_universe_for_import_refs(source_refs) 

2258 

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

2260 # before doing the import. 

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

2262 for ref in source_refs: 

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

2264 

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

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

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

2268 newly_registered_dataset_types = set() 

2269 for datasetType in refs_by_type: 

2270 if register_dataset_types: 

2271 # Let this raise immediately if inconsistent. Continuing 

2272 # on to find additional inconsistent dataset types 

2273 # might result in additional unwanted dataset types being 

2274 # registered. 

2275 try: 

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

2277 newly_registered_dataset_types.add(datasetType) 

2278 except ConflictingDefinitionError as e: 

2279 # Be safe and require that conversions be bidirectional 

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

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

2282 # target python type (the source formatter will be 

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

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

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

2286 # require conversion for transferred datasets but might 

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

2288 # not know that the formatter will return the wrong 

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

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

2291 target_dataset_type = self.get_dataset_type(datasetType.name) 

2292 target_compatible_with_source = target_dataset_type.is_compatible_with(datasetType) 

2293 source_compatible_with_target = datasetType.is_compatible_with(target_dataset_type) 

2294 if not (target_compatible_with_source and source_compatible_with_target): 2294 ↛ 2295line 2294 didn't jump to line 2295 because the condition on line 2294 was never true

2295 if target_compatible_with_source: 

2296 e.add_note( 

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

2298 "but the reverse is not true." 

2299 ) 

2300 elif source_compatible_with_target: 

2301 e.add_note( 

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

2303 "but the reverse is not true." 

2304 ) 

2305 else: 

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

2307 raise 

2308 else: 

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

2310 target_dataset_type = self.get_dataset_type(datasetType.name) 

2311 if target_dataset_type != datasetType: 

2312 target_compatible_with_source = target_dataset_type.is_compatible_with(datasetType) 

2313 source_compatible_with_target = datasetType.is_compatible_with(target_dataset_type) 

2314 # Both conversion directions are currently required. 

2315 if not (target_compatible_with_source and source_compatible_with_target): 

2316 msg = "" 

2317 if target_compatible_with_source: 2317 ↛ 2318line 2317 didn't jump to line 2318 because the condition on line 2317 was never true

2318 msg = ( 

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

2320 "but the reverse is not true." 

2321 ) 

2322 elif source_compatible_with_target: 2322 ↛ 2323line 2322 didn't jump to line 2323 because the condition on line 2322 was never true

2323 msg = ( 

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

2325 " but the reverse is not true." 

2326 ) 

2327 else: 

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

2329 if msg: 2329 ↛ 2331line 2329 didn't jump to line 2331 because the condition on line 2329 was always true

2330 msg = f"({msg})" 

2331 raise ConflictingDefinitionError( 

2332 "Source butler dataset type differs from definition" 

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

2334 f" {target_dataset_type} {msg}" 

2335 ) 

2336 if newly_registered_dataset_types: 

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

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

2339 _LOG.verbose( 

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

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

2342 ) 

2343 else: 

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

2345 

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

2347 if transfer_dimensions: 

2348 # Collect all the dimension records for these refs. 

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

2350 # come from this butler's universe. 

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

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

2353 dimension_records = self._extract_all_dimension_records_from_data_ids( 

2354 source_butler, dataIds, elements 

2355 ) 

2356 return _ImportDatasetsInfo(grouped_refs, dimension_records) 

2357 

2358 def _import_dimension_records( 

2359 self, 

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

2361 *, 

2362 dry_run: bool, 

2363 ) -> None: 

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

2365 if dimension_records and not dry_run: 

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

2367 # Order matters. 

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

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

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

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

2372 # is consistent. 

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

2374 

2375 def _import_grouped_refs( 

2376 self, 

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

2378 source_butler: LimitedButler | None, 

2379 progress: Progress, 

2380 *, 

2381 dry_run: bool = False, 

2382 expand_refs: bool = False, 

2383 ) -> list[DatasetRef]: 

2384 handled_collections: set[str] = set() 

2385 n_to_import = 0 

2386 all_imported_refs: list[DatasetRef] = [] 

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

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

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

2390 # name. (See DM-47543). 

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

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

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

2394 ): 

2395 if run not in handled_collections: 

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

2397 # has a registry, ask for documentation string. 

2398 run_doc = None 

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

2400 run_doc = registry.getCollectionDocumentation(run) 

2401 if not dry_run: 

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

2403 else: 

2404 registered = True 

2405 handled_collections.add(run) 

2406 if registered: 

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

2408 

2409 n_refs = len(refs_to_import) 

2410 n_to_import += n_refs 

2411 _LOG.verbose( 

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

2413 n_refs, 

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

2415 dimension_group.names, 

2416 run, 

2417 ) 

2418 

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

2420 # those imported. 

2421 if not dry_run: 

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

2423 else: 

2424 imported_refs = refs_to_import 

2425 

2426 all_imported_refs.extend(imported_refs) 

2427 

2428 assert n_to_import == len(all_imported_refs) 

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

2430 return all_imported_refs 

2431 

2432 def transfer_from( 

2433 self, 

2434 source_butler: LimitedButler, 

2435 source_refs: Iterable[DatasetRef], 

2436 transfer: str = "auto", 

2437 skip_missing: bool = True, 

2438 register_dataset_types: bool = False, 

2439 transfer_dimensions: bool = False, 

2440 dry_run: bool = False, 

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

2442 # Docstring inherited. 

2443 source_refs = list(source_refs) 

2444 if not self.isWriteable() and not dry_run: 2444 ↛ 2445line 2444 didn't jump to line 2445 because the condition on line 2444 was never true

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

2446 

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

2448 

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

2450 file_transfer_source = source_butler._file_transfer_source 

2451 transfer_records = retrieve_file_transfer_records( 

2452 file_transfer_source, source_refs, artifact_existence 

2453 ) 

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

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

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

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

2458 # possible files in a QuantumBackedButler. 

2459 if skip_missing: 

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

2461 missing_ids = original_ids - transfer_records.keys() 

2462 if missing_ids: 

2463 original_count = len(source_refs) 

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

2465 filtered_count = len(source_refs) 

2466 n_missing = original_count - filtered_count 

2467 _LOG.verbose( 

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

2469 n_missing, 

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

2471 filtered_count, 

2472 ) 

2473 

2474 import_info = self._prepare_for_import_refs( 

2475 source_butler, 

2476 source_refs, 

2477 register_dataset_types=register_dataset_types, 

2478 dry_run=dry_run, 

2479 transfer_dimensions=transfer_dimensions, 

2480 ) 

2481 

2482 # Do all the importing in a single transaction. 

2483 with self.transaction(): 

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

2485 imported_refs = self._import_grouped_refs( 

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

2487 ) 

2488 

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

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

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

2492 accepted, rejected = self._datastore.transfer_from( 

2493 transfer_records, 

2494 imported_refs, 

2495 transfer=transfer, 

2496 artifact_existence=artifact_existence, 

2497 dry_run=dry_run, 

2498 ) 

2499 if rejected: 2499 ↛ 2501line 2499 didn't jump to line 2501 because the condition on line 2499 was never true

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

2501 _LOG.warning( 

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

2503 len(rejected), 

2504 len(accepted), 

2505 ) 

2506 

2507 return imported_refs 

2508 

2509 def validateConfiguration( 

2510 self, 

2511 logFailures: bool = False, 

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

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

2514 ) -> None: 

2515 # Docstring inherited. 

2516 if datasetTypeNames: 

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

2518 else: 

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

2520 

2521 # filter out anything from the ignore list 

2522 if ignore: 

2523 ignore = set(ignore) 

2524 datasetTypes = [ 

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

2526 ] 

2527 else: 

2528 ignore = set() 

2529 

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

2531 # a DatasetRef for each defined instrument 

2532 datasetRefs = [] 

2533 

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

2535 # universe). 

2536 instruments: set[str] = set() 

2537 if "instrument" in self.dimensions: 2537 ↛ 2557line 2537 didn't jump to line 2557 because the condition on line 2537 was always true

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

2539 

2540 for datasetType in datasetTypes: 

2541 if "instrument" in datasetType.dimensions: 2541 ↛ 2540line 2541 didn't jump to line 2540 because the condition on line 2541 was always true

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

2543 # fake DataCoordinate values for the non-instrument 

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

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

2546 

2547 for instrument in instruments: 

2548 datasetRef = DatasetRef( 

2549 datasetType, 

2550 DataCoordinate.standardize( 

2551 dataId, instrument=instrument, dimensions=datasetType.dimensions 

2552 ), 

2553 run="validate", 

2554 ) 

2555 datasetRefs.append(datasetRef) 

2556 

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

2558 entities.extend(datasetTypes) 

2559 entities.extend(datasetRefs) 

2560 

2561 datastoreErrorStr = None 

2562 try: 

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

2564 except ValidationError as e: 

2565 datastoreErrorStr = str(e) 

2566 

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

2568 # registry and storage class definitions 

2569 keys = self._datastore.getLookupKeys() 

2570 

2571 failedNames = set() 

2572 failedDataId = set() 

2573 for key in keys: 

2574 if key.name is not None: 

2575 if key.name in ignore: 

2576 continue 

2577 

2578 # skip if specific datasetType names were requested and this 

2579 # name does not match 

2580 if datasetTypeNames and key.name not in datasetTypeNames: 

2581 continue 

2582 

2583 # See if it is a StorageClass or a DatasetType 

2584 if key.name in self.storageClasses: 

2585 pass 

2586 else: 

2587 try: 

2588 self.get_dataset_type(key.name) 

2589 except KeyError: 

2590 if logFailures: 2590 ↛ 2591line 2590 didn't jump to line 2591 because the condition on line 2590 was never true

2591 _LOG.critical( 

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

2593 ) 

2594 failedNames.add(key) 

2595 else: 

2596 # Dimensions are checked for consistency when the Butler 

2597 # is created and rendezvoused with a universe. 

2598 pass 

2599 

2600 # Check that the instrument is a valid instrument 

2601 # Currently only support instrument so check for that 

2602 if key.dataId: 

2603 dataIdKeys = set(key.dataId) 

2604 if {"instrument"} != dataIdKeys: 2604 ↛ 2605line 2604 didn't jump to line 2605 because the condition on line 2604 was never true

2605 if logFailures: 

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

2607 failedDataId.add(key) 

2608 elif key.dataId["instrument"] not in instruments: 2608 ↛ 2609line 2608 didn't jump to line 2609 because the condition on line 2608 was never true

2609 if logFailures: 

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

2611 failedDataId.add(key) 

2612 

2613 messages = [] 

2614 

2615 if datastoreErrorStr: 2615 ↛ 2616line 2615 didn't jump to line 2616 because the condition on line 2615 was never true

2616 messages.append(datastoreErrorStr) 

2617 

2618 for failed, msg in ( 

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

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

2621 ): 

2622 if failed: 

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

2624 messages.append(msg) 

2625 

2626 if messages: 

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

2628 

2629 @property 

2630 @deprecated( 

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

2632 version="v28", 

2633 category=FutureWarning, 

2634 ) 

2635 def collection_chains(self) -> DirectButlerCollections: 

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

2637 return DirectButlerCollections(self._registry) 

2638 

2639 @property 

2640 def collections(self) -> DirectButlerCollections: 

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

2642 return DirectButlerCollections(self._registry) 

2643 

2644 @property 

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

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

2647 `None`). 

2648 

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

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

2651 assigning a new `RegistryDefaults` instance to 

2652 ``self.registry.defaults``. 

2653 """ 

2654 return self._registry.defaults.run 

2655 

2656 @property 

2657 def registry(self) -> Registry: 

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

2659 (`Registry`). 

2660 

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

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

2663 will be replaced by equivalent `Butler` methods. 

2664 """ 

2665 return RegistryShim(self) 

2666 

2667 @property 

2668 def dimensions(self) -> DimensionUniverse: 

2669 # Docstring inherited. 

2670 return self._registry.dimensions 

2671 

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

2673 # Docstring inherited. 

2674 return self._registry._query() 

2675 

2676 def _query_driver( 

2677 self, 

2678 default_collections: Iterable[str], 

2679 default_data_id: DataCoordinate, 

2680 ) -> contextlib.AbstractContextManager[DirectQueryDriver]: 

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

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

2683 """ 

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

2685 

2686 @contextlib.contextmanager 

2687 def _query_all_datasets_by_page( 

2688 self, args: QueryAllDatasetsParameters 

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

2690 with self.query() as query: 

2691 pages = query_all_datasets(self, query, args) 

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

2693 

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

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

2696 self._registry.preload_cache(load_dimension_record_cache=load_dimension_record_cache) 

2697 

2698 def _expand_data_ids(self, data_ids: Iterable[DataCoordinate]) -> list[DataCoordinate]: 

2699 return self._registry.expand_data_ids(data_ids) 

2700 

2701 _config: ButlerConfig 

2702 """Configuration for this Butler instance.""" 

2703 

2704 _registry: SqlRegistry 

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

2706 (`SqlRegistry`). 

2707 

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

2709 accessible only via `SqlRegistry` methods. 

2710 """ 

2711 

2712 storageClasses: StorageClassFactory 

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

2714 describe them (`StorageClassFactory`). 

2715 """ 

2716 

2717 _closed: bool 

2718 """`True` if close() has already been called on this instance; `False` 

2719 otherwise. 

2720 """ 

2721 

2722 

2723class _RefGroup(NamedTuple): 

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

2725 `Butler.transfer_from`. 

2726 """ 

2727 

2728 dimensions: DimensionGroup 

2729 run: str 

2730 

2731 

2732class _ImportDatasetsInfo(NamedTuple): 

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

2734 

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

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

2737 

2738 

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

2740 if isinstance(id, uuid.UUID): 

2741 return id 

2742 else: 

2743 return uuid.UUID(id) 

2744 

2745 

2746class _ButlerClosed: 

2747 def __getattr__(self, name: str) -> Any: 

2748 raise RuntimeError("Attempted to use a Butler instance which has been closed.") 

2749 

2750 

2751_BUTLER_CLOSED_INSTANCE: Any = _ButlerClosed() 

2752 

2753 

2754def _retrieve_dataset_type(registry: SqlRegistry, name: str) -> DatasetType | None: 

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

2756 try: 

2757 return registry.getDatasetType(name) 

2758 except MissingDatasetTypeError: 

2759 return None