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

963 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-23 07:17 +0000

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

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

7# for details of code ownership. 

8# 

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

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

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

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

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

14# 

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

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

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

18# (at your option) any later version. 

19# 

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

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

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

23# GNU General Public License for more details. 

24# 

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

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

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 

53from sqlalchemy.exc import IntegrityError 

54 

55from lsst.resources import ResourcePath, ResourcePathExpression 

56from lsst.utils.introspection import find_outside_stacklevel, get_class_of 

57from lsst.utils.iteration import chunk_iterable 

58from lsst.utils.logging import VERBOSE, getLogger 

59from lsst.utils.timer import time_this 

60 

61from .._butler import Butler, _DeprecatedDefault 

62from .._butler_config import ButlerConfig 

63from .._butler_instance_options import ButlerInstanceOptions 

64from .._butler_metrics import ButlerMetrics 

65from .._collection_type import CollectionType 

66from .._dataset_existence import DatasetExistence 

67from .._dataset_ref import DatasetRef 

68from .._dataset_type import DatasetType 

69from .._deferredDatasetHandle import DeferredDatasetHandle 

70from .._exceptions import ( 

71 DatasetNotFoundError, 

72 DimensionValueError, 

73 EmptyQueryResultError, 

74 ValidationError, 

75) 

76from .._file_dataset import FileDataset 

77from .._limited_butler import LimitedButler 

78from .._query_all_datasets import QueryAllDatasetsParameters, query_all_datasets 

79from .._registry_shim import RegistryShim 

80from .._storage_class import StorageClass, StorageClassFactory 

81from .._timespan import Timespan 

82from ..datastore import Datastore, NullDatastore 

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

84from ..datastores.file_datastore.transfer import retrieve_file_transfer_records 

85from ..dimensions import DataCoordinate, Dimension, DimensionGroup 

86from ..direct_query_driver import DirectQueryDriver 

87from ..progress import Progress 

88from ..queries import Query 

89from ..registry import ( 

90 ConflictingDefinitionError, 

91 DataIdError, 

92 MissingDatasetTypeError, 

93 RegistryDefaults, 

94 _RegistryFactory, 

95) 

96from ..registry.sql_registry import SqlRegistry 

97from ..transfers import RepoExportContext 

98from ..utils import transactional 

99from ._direct_butler_collections import DirectButlerCollections 

100 

101if TYPE_CHECKING: 

102 from lsst.resources import ResourceHandleProtocol 

103 

104 from .._dataset_provenance import DatasetProvenance 

105 from .._dataset_ref import DatasetId 

106 from ..datastore import DatasetRefURIs 

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

108 from ..registry import CollectionArgType, Registry 

109 from ..transfers import RepoImportBackend 

110 

111_LOG = getLogger(__name__) 

112 

113 

114class ButlerValidationError(ValidationError): 

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

116 

117 pass 

118 

119 

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

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

122 

123 Parameters 

124 ---------- 

125 config : `ButlerConfig` 

126 The configuration for this Butler instance. 

127 registry : `SqlRegistry` 

128 The object that manages dataset metadata and relationships. 

129 datastore : Datastore 

130 The object that manages actual dataset storage. 

131 storageClasses : StorageClassFactory 

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

133 describe them. 

134 

135 Notes 

136 ----- 

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

138 using this constructor directly. 

139 """ 

140 

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

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

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

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

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

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

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

148 # is returned from Butler.__new__() 

149 def __new__( 

150 cls, 

151 *, 

152 config: ButlerConfig, 

153 registry: SqlRegistry, 

154 datastore: Datastore, 

155 storageClasses: StorageClassFactory, 

156 metrics: ButlerMetrics | None = None, 

157 ) -> DirectButler: 

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

159 self._config = config 

160 self._registry = registry 

161 self._datastore = datastore 

162 self.storageClasses = storageClasses 

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

164 

165 # For execution butler the datastore needs a special 

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

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

168 # butler. 

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

170 

171 self._closed = False 

172 

173 return self 

174 

175 @classmethod 

176 def create_from_config( 

177 cls, 

178 config: ButlerConfig, 

179 *, 

180 options: ButlerInstanceOptions, 

181 without_datastore: bool = False, 

182 ) -> DirectButler: 

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

184 

185 Parameters 

186 ---------- 

187 config : `ButlerConfig` 

188 The configuration for this Butler instance. 

189 options : `ButlerInstanceOptions` 

190 Default values and other settings for the Butler instance. 

191 without_datastore : `bool`, optional 

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

193 to use a datastore will fail. 

194 

195 Notes 

196 ----- 

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

198 instead of using this function directly. 

199 """ 

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

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

202 

203 defaults = RegistryDefaults.from_butler_instance_options(options) 

204 try: 

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

206 writeable = options.writeable 

207 if writeable is None: 

208 writeable = options.run is not None 

209 registry = _RegistryFactory(config).from_config( 

210 butlerRoot=butlerRoot, writeable=writeable, defaults=defaults 

211 ) 

212 if without_datastore: 

213 datastore: Datastore = NullDatastore(None, None) 

214 else: 

215 datastore = Datastore.fromConfig( 

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

217 ) 

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

219 # construct datastore first and pass opaque tables to registry 

220 # constructor. 

221 registry.make_datastore_tables(datastore.get_opaque_table_definitions()) 

222 storageClasses = StorageClassFactory() 

223 storageClasses.addFromConfig(config) 

224 

225 return DirectButler( 

226 config=config, 

227 registry=registry, 

228 datastore=datastore, 

229 storageClasses=storageClasses, 

230 metrics=options.metrics, 

231 ) 

232 except Exception: 

233 # Failures here usually mean that configuration is incomplete, 

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

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

236 raise 

237 

238 def clone( 

239 self, 

240 *, 

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

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

243 inferDefaults: bool | EllipsisType = ..., 

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

245 metrics: ButlerMetrics | None = None, 

246 ) -> DirectButler: 

247 # Docstring inherited 

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

249 registry = self._registry.copy(defaults) 

250 

251 return DirectButler( 

252 registry=registry, 

253 config=self._config, 

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

255 storageClasses=self.storageClasses, 

256 metrics=metrics, 

257 ) 

258 

259 def close(self) -> None: 

260 if not self._closed: 

261 self._closed = True 

262 self._registry.close() 

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

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

265 # work after being closed because of implementation details 

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

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

268 self._registry = _BUTLER_CLOSED_INSTANCE 

269 self._datastore = _BUTLER_CLOSED_INSTANCE 

270 

271 GENERATION: ClassVar[int] = 3 

272 """This is a Generation 3 Butler. 

273 

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

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

276 code. 

277 """ 

278 

279 @classmethod 

280 def _unpickle( 

281 cls, 

282 config: ButlerConfig, 

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

284 run: str | None, 

285 defaultDataId: dict[str, str], 

286 writeable: bool, 

287 ) -> DirectButler: 

288 """Callable used to unpickle a Butler. 

289 

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

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

292 can only invoke callables with positional arguments). 

293 

294 Parameters 

295 ---------- 

296 config : `ButlerConfig` 

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

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

299 utilized). 

300 collections : `tuple` [ `str` ] 

301 Names of the default collections to read from. 

302 run : `str`, optional 

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

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

305 Default data ID values. 

306 writeable : `bool` 

307 Whether the Butler should support write operations. 

308 

309 Returns 

310 ------- 

311 butler : `Butler` 

312 A new `Butler` instance. 

313 """ 

314 return cls.create_from_config( 

315 config=config, 

316 options=ButlerInstanceOptions( 

317 collections=collections, run=run, writeable=writeable, kwargs=defaultDataId 

318 ), 

319 ) 

320 

321 def __reduce__(self) -> tuple: 

322 """Support pickling.""" 

323 return ( 

324 DirectButler._unpickle, 

325 ( 

326 self._config, 

327 self.collections.defaults, 

328 self.run, 

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

330 self._registry.isWriteable(), 

331 ), 

332 ) 

333 

334 def __str__(self) -> str: 

335 return ( 

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

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

338 ) 

339 

340 def isWriteable(self) -> bool: 

341 # Docstring inherited. 

342 return self._registry.isWriteable() 

343 

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

345 """Context manager that enables caching.""" 

346 return self._registry.caching_context() 

347 

348 @contextlib.contextmanager 

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

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

351 

352 Transactions can be nested. 

353 """ 

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

355 yield 

356 

357 def _standardizeArgs( 

358 self, 

359 datasetRefOrType: DatasetRef | DatasetType | str, 

360 dataId: DataId | None = None, 

361 for_put: bool = True, 

362 **kwargs: Any, 

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

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

365 

366 Parameters 

367 ---------- 

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

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

370 Otherwise the `DatasetType` or name thereof. 

371 dataId : `dict` or `DataCoordinate` 

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

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

374 should be provided as the second argument. 

375 for_put : `bool`, optional 

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

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

378 parameter is only relevant if there is dataset type 

379 inconsistency. 

380 **kwargs 

381 Additional keyword arguments used to augment or construct a 

382 `DataCoordinate`. See `DataCoordinate.standardize` 

383 parameters. 

384 

385 Returns 

386 ------- 

387 datasetType : `DatasetType` 

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

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

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

391 `DataId`. 

392 

393 Notes 

394 ----- 

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

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

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

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

399 and a `DataId` or `dict`. 

400 

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

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

403 generally similarly flexible. 

404 """ 

405 externalDatasetType: DatasetType | None = None 

406 internalDatasetType: DatasetType | None = None 

407 if isinstance(datasetRefOrType, DatasetRef): 

408 if dataId is not None or kwargs: 

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

410 externalDatasetType = datasetRefOrType.datasetType 

411 dataId = datasetRefOrType.dataId 

412 else: 

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

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

415 if isinstance(datasetRefOrType, DatasetType): 

416 externalDatasetType = datasetRefOrType 

417 else: 

418 internalDatasetType = self.get_dataset_type(datasetRefOrType) 

419 

420 # Check that they are self-consistent 

421 if externalDatasetType is not None: 

422 internalDatasetType = self.get_dataset_type(externalDatasetType.name) 

423 if externalDatasetType != internalDatasetType: 

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

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

426 # the python type associated with the datastore can be 

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

428 # supplied python type can be converted to the internal 

429 # type expected by registry. 

430 relevantDatasetType = internalDatasetType 

431 if for_put: 

432 is_compatible = internalDatasetType.is_compatible_with(externalDatasetType) 

433 else: 

434 is_compatible = externalDatasetType.is_compatible_with(internalDatasetType) 

435 relevantDatasetType = externalDatasetType 

436 if not is_compatible: 

437 raise ValueError( 

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

439 f"registry definition ({internalDatasetType})" 

440 ) 

441 # Override the internal definition. 

442 internalDatasetType = relevantDatasetType 

443 

444 assert internalDatasetType is not None 

445 return internalDatasetType, dataId 

446 

447 def _rewrite_data_id( 

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

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

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

451 

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

453 allow the user to specify dimension records rather than dimension 

454 primary values. 

455 

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

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

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

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

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

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

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

463 value. 

464 

465 Keyword arguments can also use strings for dimensions like detector 

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

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

468 argument. 

469 

470 Parameters 

471 ---------- 

472 dataId : `dict` or `DataCoordinate` 

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

474 `DatasetRef` within a Collection. 

475 datasetType : `DatasetType` 

476 The dataset type associated with this dataId. Required to 

477 determine the relevant dimensions. 

478 **kwargs 

479 Additional keyword arguments used to augment or construct a 

480 `DataId`. See `DataId` parameters. 

481 

482 Returns 

483 ------- 

484 dataId : `dict` or `DataCoordinate` 

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

486 no keyword arguments, the original dataId will be returned 

487 unchanged. 

488 **kwargs : `dict` 

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

490 """ 

491 # Process dimension records that are using record information 

492 # rather than ids 

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

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

495 

496 if isinstance(dataId, DataCoordinate): 

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

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

499 return dataId, kwargs 

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

501 # DataCoordinate only has values for real dimensions. 

502 newDataId.update(dataId.mapping) 

503 elif dataId: 

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

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

506 # a keyword parameter). 

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

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

509 # Someone is using a more human-readable dataId 

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

511 byRecord[dimensionName][record] = v 

512 else: 

513 newDataId[k] = v 

514 

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

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

517 # keys dimensions.record format. 

518 not_dimensions = {} 

519 

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

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

522 for dataIdDict in (newDataId, kwargs): 

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

524 for dimensionName in list(dataIdDict): 

525 value = dataIdDict[dimensionName] 

526 try: 

527 dimension = self.dimensions.dimensions[dimensionName] 

528 except KeyError: 

529 # This is not a real dimension 

530 not_dimensions[dimensionName] = value 

531 del dataIdDict[dimensionName] 

532 continue 

533 

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

535 # comparisons here 

536 if isinstance(value, numbers.Integral): 

537 value = int(value) 

538 

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

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

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

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

543 del dataIdDict[dimensionName] 

544 _LOG.debug( 

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

546 dimensionName, 

547 dimensionName, 

548 alternate.name, 

549 value, 

550 ) 

551 break 

552 else: 

553 _LOG.warning( 

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

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

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

557 value, 

558 dimensionName, 

559 dimension.primaryKey.getPythonType(), 

560 ) 

561 

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

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

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

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

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

567 _LOG.debug( 

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

569 ) 

570 newDataId[k] = v 

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

572 kwargs = {} 

573 

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

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

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

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

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

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

580 # axis. 

581 if not_dimensions: 

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

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

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

585 # match. 

586 mandatoryDimensions = datasetType.dimensions.names # - provided 

587 

588 candidateDimensions: set[str] = set() 

589 candidateDimensions.update(mandatoryDimensions) 

590 

591 # For calibrations we may well be needing temporal dimensions 

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

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

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

595 # If we are not searching calibration collections things may 

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

597 # ambiguousness of the dataId... 

598 if datasetType.isCalibration(): 

599 for dim in self.dimensions.dimensions: 

600 if dim.temporal: 

601 candidateDimensions.add(str(dim)) 

602 

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

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

605 

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

607 # dimensions. 

608 counter: Counter[str] = Counter() 

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

610 

611 # Go through the missing dimensions and associate the 

612 # given names with records within those dimensions 

613 matched_dims = set() 

614 for dimensionName in candidateDimensions: 

615 dimension = self.dimensions.dimensions[dimensionName] 

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

617 for field in not_dimensions: 

618 if field in fields: 

619 guessedAssociation[dimensionName][field] = not_dimensions[field] 

620 counter[dimensionName] += 1 

621 assigned[field].add(dimensionName) 

622 matched_dims.add(field) 

623 

624 # Calculate the fields that matched nothing. 

625 never_found = set(not_dimensions) - matched_dims 

626 

627 if never_found: 

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

629 

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

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

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

633 # This means that day_obs with seq_num will result in 

634 # exposure.day_obs and not visit.day_obs 

635 # Also prefer an explicitly missing dimension over an inferred 

636 # temporal dimension. 

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

638 if len(assignedDimensions) > 1: 

639 # Pick the most popular (preferring mandatory dimensions) 

640 requiredButMissing = assignedDimensions.intersection(mandatoryDimensions) 

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

642 candidateDimensions = requiredButMissing 

643 else: 

644 candidateDimensions = assignedDimensions 

645 

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

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

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

649 # visit since exposures are always defined and visits 

650 # are defined from exposures. 

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

652 candidateDimensions = {"exposure"} 

653 

654 # Select the relevant items and get a new restricted 

655 # counter. 

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

657 duplicatesCounter: Counter[str] = Counter() 

658 duplicatesCounter.update(theseCounts) 

659 

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

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

662 # Returns a list of tuples 

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

664 

665 _LOG.debug( 

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

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

668 fieldName, 

669 ", ".join(assignedDimensions), 

670 selected, 

671 ) 

672 

673 for candidateDimension in assignedDimensions: 

674 if candidateDimension != selected: 

675 del guessedAssociation[candidateDimension][fieldName] 

676 

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

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

679 if values: # A dict might now be empty 

680 _LOG.debug( 

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

682 ) 

683 byRecord[dimensionName].update(values) 

684 

685 if byRecord: 

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

687 # them to the Id form 

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

689 if dimensionName in newDataId: 

690 _LOG.debug( 

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

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

693 dimensionName, 

694 newDataId[dimensionName], 

695 str(values), 

696 ) 

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

698 # Only query with relevant data ID values. 

699 filtered_data_id = { 

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

701 } 

702 try: 

703 recs = self.query_dimension_records( 

704 dimensionName, 

705 data_id=filtered_data_id, 

706 ) 

707 except (DataIdError, EmptyQueryResultError): 

708 raise DimensionValueError( 

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

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

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

712 ) from None 

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

714 errmsg: list[str] = [] 

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

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

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

718 if errmsg: 

719 raise DimensionValueError( 

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

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

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

723 ) 

724 else: 

725 # Multiple matches for an explicit dimension 

726 # should never happen but let downstream complain. 

727 pass 

728 continue 

729 

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

731 # Otherwise we can have detector queries being constrained 

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

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

734 filtered_data_id = { 

735 k: v 

736 for k, v in newDataId.items() 

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

738 } 

739 

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

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

742 # x and "exposure.seq_num". 

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

744 obj = getattr(obj, component) 

745 return obj 

746 

747 with self.query() as q: 

748 x = q.expression_factory 

749 # Build up a WHERE expression. 

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

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

752 extra_args.update(filtered_data_id) 

753 extra_args.update(kwargs) 

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

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

756 

757 if len(records) != 1: 

758 if len(records) > 1: 

759 # visit can have an ambiguous answer without involving 

760 # visit_system. The default visit_system is defined 

761 # by the instrument. 

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

763 dimensionName == "visit" 

764 and "visit_system_membership" in self.dimensions 

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

766 ): 

767 instrument_records = self.query_dimension_records( 

768 "instrument", 

769 data_id=newDataId, 

770 explain=False, 

771 **kwargs, 

772 ) 

773 if len(instrument_records) == 1: 

774 visit_system = instrument_records[0].visit_system 

775 if visit_system is None: 

776 # Set to a value that will never match. 

777 visit_system = -1 

778 

779 # Look up each visit in the 

780 # visit_system_membership records. 

781 for rec in records: 

782 membership = self.query_dimension_records( 

783 # Use bind to allow zero results. 

784 # This is a fully-specified query. 

785 "visit_system_membership", 

786 instrument=instrument_records[0].name, 

787 visit_system=visit_system, 

788 visit=rec.id, 

789 explain=False, 

790 ) 

791 if membership: 

792 # This record is the right answer. 

793 records = {rec} 

794 break 

795 

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

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

798 _LOG.debug( 

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

800 ) 

801 for r in records: 

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

803 raise DimensionValueError( 

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

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

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

807 ) 

808 else: 

809 raise DimensionValueError( 

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

811 f" records when constrained by {values}" 

812 ) 

813 

814 # Get the primary key from the real dimension object 

815 dimension = self.dimensions.dimensions[dimensionName] 

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

817 raise RuntimeError( 

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

819 ) 

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

821 

822 return newDataId, kwargs 

823 

824 def _findDatasetRef( 

825 self, 

826 datasetRefOrType: DatasetRef | DatasetType | str, 

827 dataId: DataId | None = None, 

828 *, 

829 collections: Any = None, 

830 predict: bool = False, 

831 run: str | None = None, 

832 datastore_records: bool = False, 

833 timespan: Timespan | None = None, 

834 **kwargs: Any, 

835 ) -> DatasetRef: 

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

837 the registry. 

838 

839 Parameters 

840 ---------- 

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

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

843 Otherwise the `DatasetType` or name thereof. 

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

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

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

847 should be provided as the first argument. 

848 collections : Any, optional 

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

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

851 to butler construction. 

852 predict : `bool`, optional 

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

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

855 Defaults to `False`. 

856 run : `str`, optional 

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

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

859 datastore_records : `bool`, optional 

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

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

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

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

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

865 in the data ID. 

866 **kwargs 

867 Additional keyword arguments used to augment or construct a 

868 `DataId`. See `DataId` parameters. 

869 

870 Returns 

871 ------- 

872 ref : `DatasetRef` 

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

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

875 resolved. 

876 

877 Raises 

878 ------ 

879 LookupError 

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

881 ``predict`` is `False`). 

882 ValueError 

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

884 differs from the one found in the registry. 

885 TypeError 

886 Raised if no collections were provided. 

887 """ 

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

889 if isinstance(datasetRefOrType, DatasetRef): 

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

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

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

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

894 # May need to retrieve datastore records if requested. 

895 if datastore_records and datasetRefOrType._datastore_records is None: 

896 datasetRefOrType = self._registry.get_datastore_records(datasetRefOrType) 

897 return datasetRefOrType 

898 

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

900 

901 if datasetType.isCalibration(): 

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

903 # standardize the data ID without restricting the dimensions to 

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

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

906 # lookup. 

907 dataId = DataCoordinate.standardize( 

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

909 ) 

910 if timespan is None: 

911 if dataId.dimensions.temporal: 

912 dataId = self._registry.expandDataId(dataId) 

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

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

915 # specified an explicit timespan. 

916 timespan = dataId.timespan 

917 else: 

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

919 # results in more than one matching dataset. 

920 timespan = Timespan(None, None) 

921 else: 

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

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

924 # result even if no dataset is found. 

925 dataId = DataCoordinate.standardize( 

926 dataId, 

927 dimensions=datasetType.dimensions, 

928 defaults=self._registry.defaults.dataId, 

929 **kwargs, 

930 ) 

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

932 # present in the current collection. 

933 ref = self.find_dataset( 

934 datasetType, 

935 dataId, 

936 collections=collections, 

937 timespan=timespan, 

938 datastore_records=datastore_records, 

939 ) 

940 if ref is None: 

941 if predict: 

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

943 run = self.run 

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

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

946 dataId = self.registry.expandDataId(dataId) 

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

948 else: 

949 if collections is None: 

950 collections = self._registry.defaults.collections 

951 raise DatasetNotFoundError( 

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

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

954 ) 

955 if datasetType != ref.datasetType: 

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

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

958 # registry definition. The DatasetRef must therefore be recreated 

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

960 # returned. 

961 ref = DatasetRef( 

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

963 ) 

964 

965 return ref 

966 

967 @transactional 

968 def put( 

969 self, 

970 obj: Any, 

971 datasetRefOrType: DatasetRef | DatasetType | str, 

972 /, 

973 dataId: DataId | None = None, 

974 *, 

975 run: str | None = None, 

976 provenance: DatasetProvenance | None = None, 

977 **kwargs: Any, 

978 ) -> DatasetRef: 

979 """Store and register a dataset. 

980 

981 Parameters 

982 ---------- 

983 obj : `object` 

984 The dataset. 

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

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

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

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

989 dataId : `dict` or `DataCoordinate` 

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

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

992 should be provided as the second argument. 

993 run : `str`, optional 

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

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

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

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

998 Not supported by all serialization mechanisms. 

999 **kwargs 

1000 Additional keyword arguments used to augment or construct a 

1001 `DataCoordinate`. See `DataCoordinate.standardize` 

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

1003 

1004 Returns 

1005 ------- 

1006 ref : `DatasetRef` 

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

1008 given. 

1009 

1010 Raises 

1011 ------ 

1012 TypeError 

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

1014 """ 

1015 if isinstance(datasetRefOrType, DatasetRef): 

1016 # This is a direct put of predefined DatasetRef. 

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

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

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

1020 

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

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

1023 # dataset type and DataId, then _importDatasets will do 

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

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

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

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

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

1029 if self._datastore.knows(datasetRefOrType): 

1030 raise ConflictingDefinitionError( 

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

1032 ) 

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

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

1035 # unpredictable. 

1036 try: 

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

1038 except IntegrityError as e: 

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

1040 

1041 return datasetRefOrType 

1042 

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

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

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

1046 

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

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

1049 

1050 # Handle dimension records in dataId 

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

1052 

1053 # Add Registry Dataset entry. 

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

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

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

1057 

1058 return ref 

1059 

1060 def getDeferred( 

1061 self, 

1062 datasetRefOrType: DatasetRef | DatasetType | str, 

1063 /, 

1064 dataId: DataId | None = None, 

1065 *, 

1066 parameters: dict | None = None, 

1067 collections: Any = None, 

1068 storageClass: str | StorageClass | None = None, 

1069 timespan: Timespan | None = None, 

1070 **kwargs: Any, 

1071 ) -> DeferredDatasetHandle: 

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

1073 after an immediate registry lookup. 

1074 

1075 Parameters 

1076 ---------- 

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

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

1079 Otherwise the `DatasetType` or name thereof. 

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

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

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

1083 should be provided as the first argument. 

1084 parameters : `dict` 

1085 Additional StorageClass-defined options to control reading, 

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

1087 collections : Any, optional 

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

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

1090 to butler construction. 

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

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

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

1094 the dataset type definition for this dataset. Specifying a 

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

1096 This type must be compatible with the original type. 

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

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

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

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

1101 in the data ID. 

1102 **kwargs 

1103 Additional keyword arguments used to augment or construct a 

1104 `DataId`. See `DataId` parameters. 

1105 

1106 Returns 

1107 ------- 

1108 obj : `DeferredDatasetHandle` 

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

1110 

1111 Raises 

1112 ------ 

1113 LookupError 

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

1115 datastore. 

1116 ValueError 

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

1118 differs from the one found in the registry. 

1119 TypeError 

1120 Raised if no collections were provided. 

1121 """ 

1122 if isinstance(datasetRefOrType, DatasetRef): 

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

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

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

1126 # a file. 

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

1128 ref = datasetRefOrType 

1129 else: 

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

1131 else: 

1132 ref = self._findDatasetRef( 

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

1134 ) 

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

1136 

1137 def get( 

1138 self, 

1139 datasetRefOrType: DatasetRef | DatasetType | str, 

1140 /, 

1141 dataId: DataId | None = None, 

1142 *, 

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

1144 collections: Any = None, 

1145 storageClass: StorageClass | str | None = None, 

1146 timespan: Timespan | None = None, 

1147 **kwargs: Any, 

1148 ) -> Any: 

1149 """Retrieve a stored dataset. 

1150 

1151 Parameters 

1152 ---------- 

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

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

1155 Otherwise the `DatasetType` or name thereof. 

1156 If a resolved `DatasetRef`, the associated dataset 

1157 is returned directly without additional querying. 

1158 dataId : `dict` or `DataCoordinate` 

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

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

1161 should be provided as the first argument. 

1162 parameters : `dict` 

1163 Additional StorageClass-defined options to control reading, 

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

1165 collections : Any, optional 

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

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

1168 to butler construction. 

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

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

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

1172 the dataset type definition for this dataset. Specifying a 

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

1174 This type must be compatible with the original type. 

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

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

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

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

1179 in the data ID. 

1180 **kwargs 

1181 Additional keyword arguments used to augment or construct a 

1182 `DataCoordinate`. See `DataCoordinate.standardize` 

1183 parameters. 

1184 

1185 Returns 

1186 ------- 

1187 obj : `object` 

1188 The dataset. 

1189 

1190 Raises 

1191 ------ 

1192 LookupError 

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

1194 TypeError 

1195 Raised if no collections were provided. 

1196 

1197 Notes 

1198 ----- 

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

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

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

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

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

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

1205 ``exposure`` is a temporal dimension. 

1206 """ 

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

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

1209 ref = self._findDatasetRef( 

1210 datasetRefOrType, 

1211 dataId, 

1212 collections=collections, 

1213 datastore_records=True, 

1214 timespan=timespan, 

1215 **kwargs, 

1216 ) 

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

1218 

1219 def getURIs( 

1220 self, 

1221 datasetRefOrType: DatasetRef | DatasetType | str, 

1222 /, 

1223 dataId: DataId | None = None, 

1224 *, 

1225 predict: bool = False, 

1226 collections: Any = None, 

1227 run: str | None = None, 

1228 **kwargs: Any, 

1229 ) -> DatasetRefURIs: 

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

1231 

1232 Parameters 

1233 ---------- 

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

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

1236 Otherwise the `DatasetType` or name thereof. 

1237 dataId : `dict` or `DataCoordinate` 

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

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

1240 should be provided as the first argument. 

1241 predict : `bool` 

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

1243 been written. 

1244 collections : Any, optional 

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

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

1247 to butler construction. 

1248 run : `str`, optional 

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

1250 **kwargs 

1251 Additional keyword arguments used to augment or construct a 

1252 `DataCoordinate`. See `DataCoordinate.standardize` 

1253 parameters. 

1254 

1255 Returns 

1256 ------- 

1257 uris : `DatasetRefURIs` 

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

1259 the dataset was disassembled within the datastore this may be 

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

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

1262 """ 

1263 ref = self._findDatasetRef( 

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

1265 ) 

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

1267 

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

1269 return self._registry.getDatasetType(name) 

1270 

1271 def get_dataset( 

1272 self, 

1273 id: DatasetId | str, 

1274 *, 

1275 storage_class: str | StorageClass | None = None, 

1276 dimension_records: bool = False, 

1277 datastore_records: bool = False, 

1278 ) -> DatasetRef | None: 

1279 id = _to_uuid(id) 

1280 ref = self._registry.getDataset(id) 

1281 if ref is not None: 

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

1283 ref = ref.expanded( 

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

1285 ) 

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

1287 ref = ref.overrideStorageClass(storage_class) 

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

1289 ref = self._registry.get_datastore_records(ref) 

1290 return ref 

1291 

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

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

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

1295 

1296 def find_dataset( 

1297 self, 

1298 dataset_type: DatasetType | str, 

1299 data_id: DataId | None = None, 

1300 *, 

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

1302 timespan: Timespan | None = None, 

1303 storage_class: str | StorageClass | None = None, 

1304 dimension_records: bool = False, 

1305 datastore_records: bool = False, 

1306 **kwargs: Any, 

1307 ) -> DatasetRef | None: 

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

1309 # keys. 

1310 if isinstance(dataset_type, str): 

1311 actual_type = self.get_dataset_type(dataset_type) 

1312 else: 

1313 actual_type = dataset_type 

1314 

1315 # Store the component for later. 

1316 component_name = actual_type.component() 

1317 if actual_type.isComponent(): 

1318 parent_type = actual_type.makeCompositeDatasetType() 

1319 else: 

1320 parent_type = actual_type 

1321 

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

1323 

1324 ref = self.registry.findDataset( 

1325 parent_type, 

1326 data_id, 

1327 collections=collections, 

1328 timespan=timespan, 

1329 datastore_records=datastore_records, 

1330 **kwargs, 

1331 ) 

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

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

1334 if ref is not None and component_name: 

1335 ref = ref.makeComponentRef(component_name) 

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

1337 ref = ref.overrideStorageClass(storage_class) 

1338 

1339 return ref 

1340 

1341 def retrieve_artifacts_zip( 

1342 self, 

1343 refs: Iterable[DatasetRef], 

1344 destination: ResourcePathExpression, 

1345 overwrite: bool = True, 

1346 ) -> ResourcePath: 

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

1348 

1349 def retrieveArtifacts( 

1350 self, 

1351 refs: Iterable[DatasetRef], 

1352 destination: ResourcePathExpression, 

1353 transfer: str = "auto", 

1354 preserve_path: bool = True, 

1355 overwrite: bool = False, 

1356 ) -> list[ResourcePath]: 

1357 # Docstring inherited. 

1358 outdir = ResourcePath(destination) 

1359 artifact_map = self._datastore.retrieveArtifacts( 

1360 refs, 

1361 outdir, 

1362 transfer=transfer, 

1363 preserve_path=preserve_path, 

1364 overwrite=overwrite, 

1365 write_index=True, 

1366 ) 

1367 return list(artifact_map) 

1368 

1369 def exists( 

1370 self, 

1371 dataset_ref_or_type: DatasetRef | DatasetType | str, 

1372 /, 

1373 data_id: DataId | None = None, 

1374 *, 

1375 full_check: bool = True, 

1376 collections: Any = None, 

1377 **kwargs: Any, 

1378 ) -> DatasetExistence: 

1379 # Docstring inherited. 

1380 existence = DatasetExistence.UNRECOGNIZED 

1381 

1382 if isinstance(dataset_ref_or_type, DatasetRef): 

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

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

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

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

1387 ref = dataset_ref_or_type 

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

1389 if registry_ref is not None: 

1390 existence |= DatasetExistence.RECORDED 

1391 

1392 if dataset_ref_or_type != registry_ref: 

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

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

1395 # the method. 

1396 if registry_ref.is_compatible_with(dataset_ref_or_type): 

1397 # Use the registry version from now on. 

1398 ref = registry_ref 

1399 else: 

1400 raise ValueError( 

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

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

1403 ) 

1404 else: 

1405 try: 

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

1407 except (LookupError, TypeError): 

1408 return existence 

1409 existence |= DatasetExistence.RECORDED 

1410 

1411 if self._datastore.knows(ref): 

1412 existence |= DatasetExistence.DATASTORE 

1413 

1414 if full_check: 

1415 if self._datastore.exists(ref): 

1416 existence |= DatasetExistence._ARTIFACT 

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

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

1419 existence |= DatasetExistence(DatasetExistence._ASSUMED) 

1420 

1421 return existence 

1422 

1423 def _exists_many( 

1424 self, 

1425 refs: Iterable[DatasetRef], 

1426 /, 

1427 *, 

1428 full_check: bool = True, 

1429 ) -> dict[DatasetRef, DatasetExistence]: 

1430 # Docstring inherited. 

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

1432 

1433 # Check which refs exist in the registry. 

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

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

1436 # Consistency between the given DatasetRef and the information 

1437 # recorded in the registry is not verified. 

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

1439 

1440 # Ask datastore if it knows about these refs. 

1441 knows = self._datastore.knows_these(refs) 

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

1443 if known: 

1444 existence[ref] |= DatasetExistence.DATASTORE 

1445 

1446 if full_check: 

1447 mexists = self._datastore.mexists(refs) 

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

1449 if exists: 

1450 existence[ref] |= DatasetExistence._ARTIFACT 

1451 else: 

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

1453 for ref in existence: 

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

1455 existence[ref] |= DatasetExistence._ASSUMED 

1456 

1457 return existence 

1458 

1459 def removeRuns( 

1460 self, 

1461 names: Iterable[str], 

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

1463 *, 

1464 unlink_from_chains: bool = False, 

1465 ) -> None: 

1466 # Docstring inherited. 

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

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

1469 

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

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

1472 # ignored. 

1473 if unstore is True: 

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

1475 else: 

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

1477 warnings.warn( 

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

1479 category=FutureWarning, 

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

1481 ) 

1482 

1483 names = list(names) 

1484 refs: list[DatasetRef] = [] 

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

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

1487 

1488 with self._caching_context(): 

1489 # Get information about these RUNs. 

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

1491 for info in collections_info: 

1492 if info.type is not CollectionType.RUN: 

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

1494 if unlink_from_chains: 

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

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

1497 for parent in info.parents: 

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

1499 

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

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

1502 

1503 # Get all the datasets from these runs. 

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

1505 

1506 # Call pruneDatasets since we are deliberately removing 

1507 # datasets in chunks from the RUN collections rather than 

1508 # attempting to remove everything at once. 

1509 with time_this( 

1510 _LOG, 

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

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

1513 ): 

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

1515 

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

1517 with self._registry.transaction(): 

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

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

1520 # by this point. 

1521 if unlink_from_chains: 

1522 # Use deterministic order for deletions to attempt to minimize 

1523 # risk of deadlocks for parallel deletes. 

1524 for parent in sorted(parents_to_children): 

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

1526 # Sort to avoid potential deadlocks. 

1527 for name in sorted(names): 

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

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

1530 self._registry.removeCollection(name) 

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

1532 

1533 def pruneDatasets( 

1534 self, 

1535 refs: Iterable[DatasetRef], 

1536 *, 

1537 disassociate: bool = True, 

1538 unstore: bool = False, 

1539 tags: Iterable[str] = (), 

1540 purge: bool = False, 

1541 ) -> None: 

1542 # docstring inherited from LimitedButler 

1543 

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

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

1546 if purge: 

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

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

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

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

1551 elif disassociate: 

1552 tags = tuple(tags) 

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

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

1555 for tag in tags: 

1556 collectionType = self._registry.getCollectionType(tag) 

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

1558 raise TypeError( 

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

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

1561 ) 

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

1563 # over multiple times. 

1564 refs = list(refs) 

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

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

1567 # components in a separate file 

1568 for ref in refs: 

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

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

1571 

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

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

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

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

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

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

1578 # emptying of that chunk. 

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

1580 chunk_size = 50_000 

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

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

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

1584 chunk_num = 0 

1585 for chunked_refs in progress.wrap( 

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

1587 ): 

1588 chunk_num += 1 

1589 _LOG.verbose( 

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

1591 len(chunked_refs), 

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

1593 chunk_num, 

1594 n_chunks, 

1595 ) 

1596 with time_this( 

1597 _LOG, 

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

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

1600 ): 

1601 self._prune_datasets( 

1602 chunked_refs, tags=tags, unstore=unstore, purge=purge, disassociate=disassociate 

1603 ) 

1604 

1605 def _prune_datasets( 

1606 self, 

1607 refs: Collection[DatasetRef], 

1608 *, 

1609 disassociate: bool = True, 

1610 unstore: bool = False, 

1611 tags: Iterable[str] = (), 

1612 purge: bool = False, 

1613 ) -> None: 

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

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

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

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

1618 # Registry operations. 

1619 with self.transaction(): 

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

1621 if unstore: 

1622 with time_this( 

1623 _LOG, 

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

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

1626 ): 

1627 self._datastore.trash(refs) 

1628 if purge: 

1629 with time_this( 

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

1631 ): 

1632 self._registry.removeDatasets(refs) 

1633 elif disassociate: 

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

1635 with time_this( 

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

1637 ): 

1638 for tag in tags: 

1639 self._registry.disassociate(tag, refs) 

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

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

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

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

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

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

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

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

1648 # in the dataset_location_trash table. 

1649 if unstore: 

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

1651 # emptying to the refs that this call trashed. 

1652 with time_this( 

1653 _LOG, 

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

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

1656 ): 

1657 self._datastore.emptyTrash(refs=refs) 

1658 

1659 def ingest_zip( 

1660 self, 

1661 zip_file: ResourcePathExpression, 

1662 transfer: str = "auto", 

1663 *, 

1664 transfer_dimensions: bool = False, 

1665 dry_run: bool = False, 

1666 skip_existing: bool = False, 

1667 ) -> None: 

1668 # Docstring inherited. 

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

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

1671 

1672 zip_path = ResourcePath(zip_file) 

1673 index = ZipIndex.from_zip_file(zip_path) 

1674 _LOG.verbose( 

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

1676 ) 

1677 

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

1679 # code by reconstructing FileDataset from the index. 

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

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

1682 datasets: list[FileDataset] = [] 

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

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

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

1686 # included. 

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

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

1689 continue 

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

1691 datasets.append(dataset) 

1692 processed_ids.update(unprocessed) 

1693 

1694 new_datasets, existing_datasets = self._partition_datasets_by_known(datasets) 

1695 if existing_datasets: 

1696 if skip_existing: 

1697 _LOG.info( 

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

1699 len(existing_datasets), 

1700 zip_file, 

1701 ) 

1702 else: 

1703 raise ConflictingDefinitionError( 

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

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

1706 ) 

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

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

1709 # some datasets that are already in another zip. 

1710 raise ValueError( 

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

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

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

1714 ) 

1715 return 

1716 

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

1718 # here. 

1719 # 

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

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

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

1723 # name. (See DM-47543). 

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

1725 for run in sorted(runs): 

1726 registered = self.collections.register(run) 

1727 if registered: 

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

1729 

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

1731 import_info = self._prepare_ingest_file_datasets( 

1732 datasets, progress, dry_run=dry_run, transfer_dimensions=transfer_dimensions 

1733 ) 

1734 

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

1736 n_datasets = 0 

1737 for d in datasets: 

1738 n_datasets += len(d.refs) 

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

1740 

1741 with ( 

1742 self._metrics.instrument_ingest( 

1743 n_datasets, 

1744 _LOG, 

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

1746 args=(zip_file, n_datasets, srefs), 

1747 ), 

1748 self.transaction(), 

1749 ): 

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

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

1752 

1753 try: 

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

1755 except IntegrityError as e: 

1756 raise ConflictingDefinitionError( 

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

1758 ) from e 

1759 

1760 def _prepare_ingest_file_datasets( 

1761 self, 

1762 datasets: Sequence[FileDataset], 

1763 progress: Progress, 

1764 *, 

1765 transfer_dimensions: bool = False, 

1766 dry_run: bool = False, 

1767 ) -> _ImportDatasetsInfo: 

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

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

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

1771 defaultdict(dict) 

1772 ) 

1773 

1774 # All the refs we need to import. 

1775 refs: list[DatasetRef] = [] 

1776 

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

1778 for ref in dataset.refs: 

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

1780 

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

1782 raise ConflictingDefinitionError( 

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

1784 " DataId as other ingest dataset" 

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

1786 f" ({ref.dataId})" 

1787 ) 

1788 

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

1790 refs.extend(dataset.refs) 

1791 

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

1793 # extracted. 

1794 import_info = self._prepare_for_import_refs( 

1795 self, 

1796 refs, 

1797 register_dataset_types=True, 

1798 dry_run=dry_run, 

1799 transfer_dimensions=transfer_dimensions, 

1800 ) 

1801 return import_info 

1802 

1803 def _ingest_file_datasets( 

1804 self, 

1805 datasets: Sequence[FileDataset], 

1806 import_info: _ImportDatasetsInfo, 

1807 progress: Progress, 

1808 *, 

1809 dry_run: bool = False, 

1810 ) -> None: 

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

1812 imported_refs = self._import_grouped_refs( 

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

1814 ) 

1815 

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

1817 # FileDatasets for datastore to use. 

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

1819 

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

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

1822 

1823 def ingest( 

1824 self, 

1825 *datasets: FileDataset, 

1826 transfer: str | None = "auto", 

1827 record_validation_info: bool = True, 

1828 skip_existing: bool = False, 

1829 ) -> None: 

1830 # Docstring inherited. 

1831 if not datasets: 

1832 return 

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

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

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

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

1837 

1838 new_datasets, existing_datasets = self._partition_datasets_by_known(datasets) 

1839 if existing_datasets: 

1840 if skip_existing: 

1841 _LOG.info( 

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

1843 ) 

1844 else: 

1845 raise ConflictingDefinitionError( 

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

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

1848 ) 

1849 

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

1851 n_files = len(datasets) 

1852 n_datasets = 0 

1853 for d in datasets: 

1854 n_datasets += len(d.refs) 

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

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

1857 

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

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

1860 # existing datasets. 

1861 import_info = self._prepare_ingest_file_datasets(datasets, progress) 

1862 

1863 with ( 

1864 self._metrics.instrument_ingest( 

1865 n_datasets, 

1866 _LOG, 

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

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

1869 ), 

1870 self.transaction(), 

1871 ): 

1872 self._ingest_file_datasets(datasets, import_info, progress) 

1873 

1874 # Bulk-insert everything into Datastore. 

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

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

1877 # The _partition_datasets_by_known logic above should catch most 

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

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

1880 # violation here. 

1881 try: 

1882 self._datastore.ingest( 

1883 *new_datasets, transfer=transfer, record_validation_info=record_validation_info 

1884 ) 

1885 except IntegrityError as e: 

1886 raise ConflictingDefinitionError( 

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

1888 ) from e 

1889 

1890 def _partition_datasets_by_known( 

1891 self, datasets: Iterable[FileDataset] 

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

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

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

1895 not. 

1896 """ 

1897 new_datasets = [] 

1898 existing_datasets = [] 

1899 

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

1901 known_refs = self._datastore.knows_these(refs) 

1902 

1903 for dataset in datasets: 

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

1905 existing_datasets.append(dataset) 

1906 else: 

1907 new_datasets.append(dataset) 

1908 

1909 return new_datasets, existing_datasets 

1910 

1911 @contextlib.contextmanager 

1912 def export( 

1913 self, 

1914 *, 

1915 directory: str | None = None, 

1916 filename: str | None = None, 

1917 format: str | None = None, 

1918 transfer: str | None = None, 

1919 ) -> Iterator[RepoExportContext]: 

1920 # Docstring inherited. 

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

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

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

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

1925 if format is None: 

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

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

1928 else: 

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

1930 if not format: 

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

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

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

1934 filename = f"export.{format}" 

1935 if directory is not None: 

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

1937 formats = self._config["repo_transfer_formats"] 

1938 if format not in formats: 

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

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

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

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

1943 try: 

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

1945 with self._caching_context(): 

1946 yield helper 

1947 except BaseException: 

1948 raise 

1949 else: 

1950 helper._finish() 

1951 

1952 def import_( 

1953 self, 

1954 *, 

1955 directory: ResourcePathExpression | None = None, 

1956 filename: ResourcePathExpression | TextIO | None = None, 

1957 format: str | None = None, 

1958 transfer: str | None = None, 

1959 skip_dimensions: set | None = None, 

1960 record_validation_info: bool = True, 

1961 without_datastore: bool = False, 

1962 ) -> None: 

1963 # Docstring inherited. 

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

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

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

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

1968 if directory is not None: 

1969 directory = ResourcePath(directory, forceDirectory=True) 

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

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

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

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

1974 format = filename.getExtension() 

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

1976 potential = directory.join(filename) 

1977 exists_in_cwd = filename.exists() 

1978 exists_in_dir = potential.exists() 

1979 if exists_in_cwd and exists_in_dir: 

1980 _LOG.warning( 

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

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

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

1984 filename, 

1985 potential, 

1986 ) 

1987 # Given they specified an explicit directory and that 

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

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

1990 filename = potential 

1991 elif exists_in_dir: 

1992 filename = potential 

1993 elif not exists_in_cwd and not exists_in_dir: 

1994 # Raise early. 

1995 raise FileNotFoundError( 

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

1997 ) 

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

1999 format = ".yaml" 

2000 BackendClass: type[RepoImportBackend] = get_class_of( 

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

2002 ) 

2003 

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

2005 with self._caching_context(): 

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

2007 backend.register() 

2008 with self.transaction(): 

2009 backend.load( 

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

2011 directory=directory, 

2012 transfer=transfer, 

2013 skip_dimensions=skip_dimensions, 

2014 record_validation_info=record_validation_info, 

2015 ) 

2016 

2017 if isinstance(filename, ResourcePath): 

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

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

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

2021 doImport(stream) 

2022 else: 

2023 doImport(filename) # type: ignore 

2024 

2025 def transfer_dimension_records_from( 

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

2027 ) -> None: 

2028 # Allowed dimensions in the target butler. 

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

2030 

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

2032 

2033 dimension_records = self._extract_all_dimension_records_from_data_ids( 

2034 source_butler, data_ids, elements 

2035 ) 

2036 

2037 # Insert order is important. 

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

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

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

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

2042 # is consistent. 

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

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

2045 

2046 def _extract_all_dimension_records_from_data_ids( 

2047 self, 

2048 source_butler: LimitedButler | Butler, 

2049 data_ids: set[DataCoordinate], 

2050 allowed_elements: frozenset[DimensionElement], 

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

2052 primary_records = self._extract_dimension_records_from_data_ids( 

2053 source_butler, data_ids, allowed_elements 

2054 ) 

2055 

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

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

2058 # Get dimensions that depend on this dimension. 

2059 populated_by = self.dimensions.get_elements_populated_by( 

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

2061 ) 

2062 if populated_by: 

2063 for element in populated_by: 

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

2065 continue 

2066 if element.name == original_element.name: 

2067 continue 

2068 

2069 if element.name in primary_records: 

2070 # If this element has already been stored avoid 

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

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

2073 # visit_detector_region but querying 

2074 # visit_detector_region by visit will return all the 

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

2076 # constrain this. 

2077 # To constrain the query the original dataIds would 

2078 # have to be scanned. 

2079 continue 

2080 

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

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

2083 raise RuntimeError( 

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

2085 " requires a full Butler." 

2086 ) 

2087 

2088 with source_butler.query() as query: 

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

2090 element.name 

2091 ) 

2092 for record in records: 

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

2094 

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

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

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

2098 # already have. 

2099 missing_data_ids = set() 

2100 for record_mapping in additional_records.values(): 

2101 for data_id in record_mapping.keys(): 

2102 for dimension in data_id.dimensions.required: 

2103 element = source_butler.dimensions[dimension] 

2104 dimension_key = data_id.subset(dimension) 

2105 if dimension_key not in primary_records[element]: 

2106 missing_data_ids.add(dimension_key) 

2107 

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

2109 # also need to carry over additional populated_by records. 

2110 secondary_records = self._extract_dimension_records_from_data_ids( 

2111 source_butler, missing_data_ids, allowed_elements 

2112 ) 

2113 

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

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

2116 primary_records[name].update(record_mapping) 

2117 

2118 return primary_records 

2119 

2120 def _extract_dimension_records_from_data_ids( 

2121 self, 

2122 source_butler: LimitedButler | Butler, 

2123 data_ids: Iterable[DataCoordinate], 

2124 allowed_elements: frozenset[DimensionElement], 

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

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

2127 

2128 data_ids = set(data_ids) 

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

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

2131 data_ids = source_butler._expand_data_ids(data_ids) 

2132 else: 

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

2134 

2135 for data_id in data_ids: 

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

2137 # butler things will break later. 

2138 for element_name in data_id.dimensions.elements: 

2139 record = data_id.records[element_name] 

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

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

2142 

2143 return dimension_records 

2144 

2145 def _cast_universe_for_import_refs( 

2146 self, source_refs: Iterable[DatasetRef] 

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

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

2149 

2150 Parameters 

2151 ---------- 

2152 source_refs 

2153 The refs to be imported. 

2154 

2155 Returns 

2156 ------- 

2157 refs 

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

2159 types cast to the target universe. 

2160 

2161 Raises 

2162 ------ 

2163 InconsistentUniverseError 

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

2165 

2166 Notes 

2167 ----- 

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

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

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

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

2172 returned references will still have the original dataset types as 

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

2174 """ 

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

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

2177 # type. 

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

2179 for ref in source_refs: 

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

2181 

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

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

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

2185 

2186 return refs_by_type 

2187 

2188 def _prepare_for_import_refs( 

2189 self, 

2190 source_butler: LimitedButler, 

2191 source_refs: Iterable[DatasetRef], 

2192 *, 

2193 register_dataset_types: bool = False, 

2194 transfer_dimensions: bool = False, 

2195 dry_run: bool = False, 

2196 ) -> _ImportDatasetsInfo: 

2197 # Docstring inherited. 

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

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

2200 

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

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

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

2204 source_refs = list(source_refs) 

2205 

2206 original_count = len(source_refs) 

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

2208 _LOG.log( 

2209 log_level, 

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

2211 original_count, 

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

2213 str(self), 

2214 ) 

2215 

2216 refs_by_type = self._cast_universe_for_import_refs(source_refs) 

2217 

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

2219 # before doing the import. 

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

2221 for ref in source_refs: 

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

2223 

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

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

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

2227 newly_registered_dataset_types = set() 

2228 for datasetType in refs_by_type: 

2229 if register_dataset_types: 

2230 # Let this raise immediately if inconsistent. Continuing 

2231 # on to find additional inconsistent dataset types 

2232 # might result in additional unwanted dataset types being 

2233 # registered. 

2234 try: 

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

2236 newly_registered_dataset_types.add(datasetType) 

2237 except ConflictingDefinitionError as e: 

2238 # Be safe and require that conversions be bidirectional 

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

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

2241 # target python type (the source formatter will be 

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

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

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

2245 # require conversion for transferred datasets but might 

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

2247 # not know that the formatter will return the wrong 

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

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

2250 target_dataset_type = self.get_dataset_type(datasetType.name) 

2251 target_compatible_with_source = target_dataset_type.is_compatible_with(datasetType) 

2252 source_compatible_with_target = datasetType.is_compatible_with(target_dataset_type) 

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

2254 if target_compatible_with_source: 

2255 e.add_note( 

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

2257 "but the reverse is not true." 

2258 ) 

2259 elif source_compatible_with_target: 

2260 e.add_note( 

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

2262 "but the reverse is not true." 

2263 ) 

2264 else: 

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

2266 raise 

2267 else: 

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

2269 target_dataset_type = self.get_dataset_type(datasetType.name) 

2270 if target_dataset_type != datasetType: 

2271 target_compatible_with_source = target_dataset_type.is_compatible_with(datasetType) 

2272 source_compatible_with_target = datasetType.is_compatible_with(target_dataset_type) 

2273 # Both conversion directions are currently required. 

2274 if not (target_compatible_with_source and source_compatible_with_target): 

2275 msg = "" 

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

2277 msg = ( 

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

2279 "but the reverse is not true." 

2280 ) 

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

2282 msg = ( 

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

2284 " but the reverse is not true." 

2285 ) 

2286 else: 

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

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

2289 msg = f"({msg})" 

2290 raise ConflictingDefinitionError( 

2291 "Source butler dataset type differs from definition" 

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

2293 f" {target_dataset_type} {msg}" 

2294 ) 

2295 if newly_registered_dataset_types: 

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

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

2298 _LOG.verbose( 

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

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

2301 ) 

2302 else: 

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

2304 

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

2306 if transfer_dimensions: 

2307 # Collect all the dimension records for these refs. 

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

2309 # come from this butler's universe. 

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

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

2312 dimension_records = self._extract_all_dimension_records_from_data_ids( 

2313 source_butler, dataIds, elements 

2314 ) 

2315 return _ImportDatasetsInfo(grouped_refs, dimension_records) 

2316 

2317 def _import_dimension_records( 

2318 self, 

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

2320 *, 

2321 dry_run: bool, 

2322 ) -> None: 

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

2324 if dimension_records and not dry_run: 

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

2326 # Order matters. 

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

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

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

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

2331 # is consistent. 

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

2333 

2334 def _import_grouped_refs( 

2335 self, 

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

2337 source_butler: LimitedButler | None, 

2338 progress: Progress, 

2339 *, 

2340 dry_run: bool = False, 

2341 expand_refs: bool = False, 

2342 ) -> list[DatasetRef]: 

2343 handled_collections: set[str] = set() 

2344 n_to_import = 0 

2345 all_imported_refs: list[DatasetRef] = [] 

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

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

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

2349 # name. (See DM-47543). 

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

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

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

2353 ): 

2354 if run not in handled_collections: 

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

2356 # has a registry, ask for documentation string. 

2357 run_doc = None 

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

2359 run_doc = registry.getCollectionDocumentation(run) 

2360 if not dry_run: 

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

2362 else: 

2363 registered = True 

2364 handled_collections.add(run) 

2365 if registered: 

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

2367 

2368 n_refs = len(refs_to_import) 

2369 n_to_import += n_refs 

2370 _LOG.verbose( 

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

2372 n_refs, 

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

2374 dimension_group.names, 

2375 run, 

2376 ) 

2377 

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

2379 # those imported. 

2380 if not dry_run: 

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

2382 else: 

2383 imported_refs = refs_to_import 

2384 

2385 all_imported_refs.extend(imported_refs) 

2386 

2387 assert n_to_import == len(all_imported_refs) 

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

2389 return all_imported_refs 

2390 

2391 def transfer_from( 

2392 self, 

2393 source_butler: LimitedButler, 

2394 source_refs: Iterable[DatasetRef], 

2395 transfer: str = "auto", 

2396 skip_missing: bool = True, 

2397 register_dataset_types: bool = False, 

2398 transfer_dimensions: bool = False, 

2399 dry_run: bool = False, 

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

2401 # Docstring inherited. 

2402 source_refs = list(source_refs) 

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

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

2405 

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

2407 

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

2409 file_transfer_source = source_butler._file_transfer_source 

2410 transfer_records = retrieve_file_transfer_records( 

2411 file_transfer_source, source_refs, artifact_existence 

2412 ) 

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

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

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

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

2417 # possible files in a QuantumBackedButler. 

2418 if skip_missing: 

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

2420 missing_ids = original_ids - transfer_records.keys() 

2421 if missing_ids: 

2422 original_count = len(source_refs) 

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

2424 filtered_count = len(source_refs) 

2425 n_missing = original_count - filtered_count 

2426 _LOG.verbose( 

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

2428 n_missing, 

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

2430 filtered_count, 

2431 ) 

2432 

2433 import_info = self._prepare_for_import_refs( 

2434 source_butler, 

2435 source_refs, 

2436 register_dataset_types=register_dataset_types, 

2437 dry_run=dry_run, 

2438 transfer_dimensions=transfer_dimensions, 

2439 ) 

2440 

2441 # Do all the importing in a single transaction. 

2442 with self.transaction(): 

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

2444 imported_refs = self._import_grouped_refs( 

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

2446 ) 

2447 

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

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

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

2451 accepted, rejected = self._datastore.transfer_from( 

2452 transfer_records, 

2453 imported_refs, 

2454 transfer=transfer, 

2455 artifact_existence=artifact_existence, 

2456 dry_run=dry_run, 

2457 ) 

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

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

2460 _LOG.warning( 

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

2462 len(rejected), 

2463 len(accepted), 

2464 ) 

2465 

2466 return imported_refs 

2467 

2468 def validateConfiguration( 

2469 self, 

2470 logFailures: bool = False, 

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

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

2473 ) -> None: 

2474 # Docstring inherited. 

2475 if datasetTypeNames: 

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

2477 else: 

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

2479 

2480 # filter out anything from the ignore list 

2481 if ignore: 

2482 ignore = set(ignore) 

2483 datasetTypes = [ 

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

2485 ] 

2486 else: 

2487 ignore = set() 

2488 

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

2490 # a DatasetRef for each defined instrument 

2491 datasetRefs = [] 

2492 

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

2494 # universe). 

2495 instruments: set[str] = set() 

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

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

2498 

2499 for datasetType in datasetTypes: 

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

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

2502 # fake DataCoordinate values for the non-instrument 

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

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

2505 

2506 for instrument in instruments: 

2507 datasetRef = DatasetRef( 

2508 datasetType, 

2509 DataCoordinate.standardize( 

2510 dataId, instrument=instrument, dimensions=datasetType.dimensions 

2511 ), 

2512 run="validate", 

2513 ) 

2514 datasetRefs.append(datasetRef) 

2515 

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

2517 entities.extend(datasetTypes) 

2518 entities.extend(datasetRefs) 

2519 

2520 datastoreErrorStr = None 

2521 try: 

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

2523 except ValidationError as e: 

2524 datastoreErrorStr = str(e) 

2525 

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

2527 # registry and storage class definitions 

2528 keys = self._datastore.getLookupKeys() 

2529 

2530 failedNames = set() 

2531 failedDataId = set() 

2532 for key in keys: 

2533 if key.name is not None: 

2534 if key.name in ignore: 

2535 continue 

2536 

2537 # skip if specific datasetType names were requested and this 

2538 # name does not match 

2539 if datasetTypeNames and key.name not in datasetTypeNames: 

2540 continue 

2541 

2542 # See if it is a StorageClass or a DatasetType 

2543 if key.name in self.storageClasses: 

2544 pass 

2545 else: 

2546 try: 

2547 self.get_dataset_type(key.name) 

2548 except KeyError: 

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

2550 _LOG.critical( 

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

2552 ) 

2553 failedNames.add(key) 

2554 else: 

2555 # Dimensions are checked for consistency when the Butler 

2556 # is created and rendezvoused with a universe. 

2557 pass 

2558 

2559 # Check that the instrument is a valid instrument 

2560 # Currently only support instrument so check for that 

2561 if key.dataId: 

2562 dataIdKeys = set(key.dataId) 

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

2564 if logFailures: 

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

2566 failedDataId.add(key) 

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

2568 if logFailures: 

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

2570 failedDataId.add(key) 

2571 

2572 messages = [] 

2573 

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

2575 messages.append(datastoreErrorStr) 

2576 

2577 for failed, msg in ( 

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

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

2580 ): 

2581 if failed: 

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

2583 messages.append(msg) 

2584 

2585 if messages: 

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

2587 

2588 @property 

2589 @deprecated( 

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

2591 version="v28", 

2592 category=FutureWarning, 

2593 ) 

2594 def collection_chains(self) -> DirectButlerCollections: 

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

2596 return DirectButlerCollections(self._registry) 

2597 

2598 @property 

2599 def collections(self) -> DirectButlerCollections: 

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

2601 return DirectButlerCollections(self._registry) 

2602 

2603 @property 

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

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

2606 `None`). 

2607 

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

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

2610 assigning a new `RegistryDefaults` instance to 

2611 ``self.registry.defaults``. 

2612 """ 

2613 return self._registry.defaults.run 

2614 

2615 @property 

2616 def registry(self) -> Registry: 

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

2618 (`Registry`). 

2619 

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

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

2622 will be replaced by equivalent `Butler` methods. 

2623 """ 

2624 return RegistryShim(self) 

2625 

2626 @property 

2627 def dimensions(self) -> DimensionUniverse: 

2628 # Docstring inherited. 

2629 return self._registry.dimensions 

2630 

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

2632 # Docstring inherited. 

2633 return self._registry._query() 

2634 

2635 def _query_driver( 

2636 self, 

2637 default_collections: Iterable[str], 

2638 default_data_id: DataCoordinate, 

2639 ) -> contextlib.AbstractContextManager[DirectQueryDriver]: 

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

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

2642 """ 

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

2644 

2645 @contextlib.contextmanager 

2646 def _query_all_datasets_by_page( 

2647 self, args: QueryAllDatasetsParameters 

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

2649 with self.query() as query: 

2650 pages = query_all_datasets(self, query, args) 

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

2652 

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

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

2655 self._registry.preload_cache(load_dimension_record_cache=load_dimension_record_cache) 

2656 

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

2658 return self._registry.expand_data_ids(data_ids) 

2659 

2660 _config: ButlerConfig 

2661 """Configuration for this Butler instance.""" 

2662 

2663 _registry: SqlRegistry 

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

2665 (`SqlRegistry`). 

2666 

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

2668 accessible only via `SqlRegistry` methods. 

2669 """ 

2670 

2671 storageClasses: StorageClassFactory 

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

2673 describe them (`StorageClassFactory`). 

2674 """ 

2675 

2676 _closed: bool 

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

2678 otherwise. 

2679 """ 

2680 

2681 

2682class _RefGroup(NamedTuple): 

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

2684 `Butler.transfer_from`. 

2685 """ 

2686 

2687 dimensions: DimensionGroup 

2688 run: str 

2689 

2690 

2691class _ImportDatasetsInfo(NamedTuple): 

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

2693 

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

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

2696 

2697 

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

2699 if isinstance(id, uuid.UUID): 

2700 return id 

2701 else: 

2702 return uuid.UUID(id) 

2703 

2704 

2705class _ButlerClosed: 

2706 def __getattr__(self, name: str) -> Any: 

2707 raise RuntimeError("Attempted to use a Butler instance which has been closed.") 

2708 

2709 

2710_BUTLER_CLOSED_INSTANCE: Any = _ButlerClosed() 

2711 

2712 

2713def _retrieve_dataset_type(registry: SqlRegistry, name: str) -> DatasetType | None: 

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

2715 try: 

2716 return registry.getDatasetType(name) 

2717 except MissingDatasetTypeError: 

2718 return None