Coverage for python/lsst/daf/butler/registry/sql_registry.py: 22%

397 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-11 01:27 -0700

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 

28from __future__ import annotations 

29 

30from .. import ddl 

31 

32__all__ = ("SqlRegistry",) 

33 

34import contextlib 

35import logging 

36import warnings 

37from collections import defaultdict 

38from collections.abc import Iterable, Iterator, Mapping, Sequence 

39from typing import TYPE_CHECKING, Any 

40 

41import sqlalchemy 

42 

43from lsst.resources import ResourcePathExpression 

44from lsst.utils.iteration import ensure_iterable 

45 

46from .._collection_type import CollectionType 

47from .._config import Config 

48from .._dataset_ref import DatasetId, DatasetIdGenEnum, DatasetRef 

49from .._dataset_type import DatasetType 

50from .._exceptions import DataIdValueError, DimensionNameError, InconsistentDataIdError 

51from .._storage_class import StorageClassFactory 

52from .._timespan import Timespan 

53from ..dimensions import ( 

54 DataCoordinate, 

55 DataId, 

56 DimensionConfig, 

57 DimensionDataAttacher, 

58 DimensionElement, 

59 DimensionGroup, 

60 DimensionRecord, 

61 DimensionUniverse, 

62) 

63from ..dimensions.record_cache import DimensionRecordCache 

64from ..direct_query_driver import DirectQueryDriver 

65from ..progress import Progress 

66from ..queries import Query 

67from ..registry import ( 

68 CollectionExpressionError, 

69 CollectionSummary, 

70 CollectionTypeError, 

71 ConflictingDefinitionError, 

72 NoDefaultCollectionError, 

73 OrphanedRecordError, 

74 RegistryConfig, 

75 RegistryDefaults, 

76 queries, 

77) 

78from ..registry.interfaces import ChainedCollectionRecord, ReadOnlyDatabaseError, RunRecord 

79from ..registry.managers import RegistryManagerInstances, RegistryManagerTypes 

80from ..registry.wildcards import CollectionWildcard, DatasetTypeWildcard 

81from ..utils import transactional 

82 

83if TYPE_CHECKING: 

84 from .._butler_config import ButlerConfig 

85 from ..datastore._datastore import DatastoreOpaqueTable 

86 from ..datastore.stored_file_info import StoredDatastoreItemInfo 

87 from ..registry.interfaces import ( 

88 CollectionRecord, 

89 Database, 

90 DatastoreRegistryBridgeManager, 

91 ObsCoreTableManager, 

92 ) 

93 

94 

95_LOG = logging.getLogger(__name__) 

96 

97 

98class SqlRegistry: 

99 """Butler Registry implementation that uses SQL database as backend. 

100 

101 Parameters 

102 ---------- 

103 database : `Database` 

104 Database instance to store Registry. 

105 defaults : `RegistryDefaults` 

106 Default collection search path and/or output `~CollectionType.RUN` 

107 collection. 

108 managers : `RegistryManagerInstances` 

109 All the managers required for this registry. 

110 """ 

111 

112 defaultConfigFile: str | None = None 

113 """Path to configuration defaults. Accessed within the ``configs`` resource 

114 or relative to a search path. Can be None if no defaults specified. 

115 """ 

116 

117 @classmethod 

118 def forceRegistryConfig( 

119 cls, config: ButlerConfig | RegistryConfig | Config | str | None 

120 ) -> RegistryConfig: 

121 """Force the supplied config to a `RegistryConfig`. 

122 

123 Parameters 

124 ---------- 

125 config : `RegistryConfig`, `Config` or `str` or `None` 

126 Registry configuration, if missing then default configuration will 

127 be loaded from registry.yaml. 

128 

129 Returns 

130 ------- 

131 registry_config : `RegistryConfig` 

132 A registry config. 

133 """ 

134 if not isinstance(config, RegistryConfig): 

135 if isinstance(config, str | Config) or config is None: 

136 config = RegistryConfig(config) 

137 else: 

138 raise ValueError(f"Incompatible Registry configuration: {config}") 

139 return config 

140 

141 @classmethod 

142 def createFromConfig( 

143 cls, 

144 config: RegistryConfig | str | None = None, 

145 dimensionConfig: DimensionConfig | str | None = None, 

146 butlerRoot: ResourcePathExpression | None = None, 

147 ) -> SqlRegistry: 

148 """Create registry database and return `SqlRegistry` instance. 

149 

150 This method initializes database contents, database must be empty 

151 prior to calling this method. 

152 

153 Parameters 

154 ---------- 

155 config : `RegistryConfig` or `str`, optional 

156 Registry configuration, if missing then default configuration will 

157 be loaded from registry.yaml. 

158 dimensionConfig : `DimensionConfig` or `str`, optional 

159 Dimensions configuration, if missing then default configuration 

160 will be loaded from dimensions.yaml. 

161 butlerRoot : convertible to `lsst.resources.ResourcePath`, optional 

162 Path to the repository root this `SqlRegistry` will manage. 

163 

164 Returns 

165 ------- 

166 registry : `SqlRegistry` 

167 A new `SqlRegistry` instance. 

168 """ 

169 config = cls.forceRegistryConfig(config) 

170 config.replaceRoot(butlerRoot) 

171 

172 if isinstance(dimensionConfig, str): 

173 dimensionConfig = DimensionConfig(dimensionConfig) 

174 elif dimensionConfig is None: 

175 dimensionConfig = DimensionConfig() 

176 elif not isinstance(dimensionConfig, DimensionConfig): 

177 raise TypeError(f"Incompatible Dimension configuration type: {type(dimensionConfig)}") 

178 

179 DatabaseClass = config.getDatabaseClass() 

180 database = DatabaseClass.fromUri( 

181 config.connectionString, 

182 origin=config.get("origin", 0), 

183 namespace=config.get("namespace"), 

184 allow_temporary_tables=config.areTemporaryTablesAllowed, 

185 ) 

186 managerTypes = RegistryManagerTypes.fromConfig(config) 

187 managers = managerTypes.makeRepo(database, dimensionConfig) 

188 return cls(database, RegistryDefaults(), managers) 

189 

190 @classmethod 

191 def fromConfig( 

192 cls, 

193 config: ButlerConfig | RegistryConfig | Config | str, 

194 butlerRoot: ResourcePathExpression | None = None, 

195 writeable: bool = True, 

196 defaults: RegistryDefaults | None = None, 

197 ) -> SqlRegistry: 

198 """Create `Registry` subclass instance from `config`. 

199 

200 Registry database must be initialized prior to calling this method. 

201 

202 Parameters 

203 ---------- 

204 config : `ButlerConfig`, `RegistryConfig`, `Config` or `str` 

205 Registry configuration. 

206 butlerRoot : `lsst.resources.ResourcePathExpression`, optional 

207 Path to the repository root this `Registry` will manage. 

208 writeable : `bool`, optional 

209 If `True` (default) create a read-write connection to the database. 

210 defaults : `RegistryDefaults`, optional 

211 Default collection search path and/or output `~CollectionType.RUN` 

212 collection. 

213 

214 Returns 

215 ------- 

216 registry : `SqlRegistry` 

217 A new `SqlRegistry` subclass instance. 

218 """ 

219 config = cls.forceRegistryConfig(config) 

220 config.replaceRoot(butlerRoot) 

221 DatabaseClass = config.getDatabaseClass() 

222 database = DatabaseClass.fromUri( 

223 config.connectionString, 

224 origin=config.get("origin", 0), 

225 namespace=config.get("namespace"), 

226 writeable=writeable, 

227 allow_temporary_tables=config.areTemporaryTablesAllowed, 

228 ) 

229 managerTypes = RegistryManagerTypes.fromConfig(config) 

230 with database.session(): 

231 managers = managerTypes.loadRepo(database) 

232 if defaults is None: 

233 defaults = RegistryDefaults() 

234 return cls(database, defaults, managers) 

235 

236 def __init__( 

237 self, 

238 database: Database, 

239 defaults: RegistryDefaults, 

240 managers: RegistryManagerInstances, 

241 ): 

242 self._db = database 

243 self._managers = managers 

244 self.storageClasses = StorageClassFactory() 

245 # This is public to SqlRegistry's internal-to-daf_butler callers, but 

246 # it is intentionally not part of RegistryShim. 

247 self.dimension_record_cache = DimensionRecordCache( 

248 self._managers.dimensions.universe, 

249 fetch=self._managers.dimensions.fetch_cache_dict, 

250 ) 

251 # Intentionally invoke property setter to initialize defaults. This 

252 # can only be done after most of the rest of Registry has already been 

253 # initialized, and must be done before the property getter is used. 

254 self.defaults = defaults 

255 # TODO: This is currently initialized by `make_datastore_tables`, 

256 # eventually we'll need to do it during construction. 

257 # The mapping is indexed by the opaque table name. 

258 self._datastore_record_classes: Mapping[str, type[StoredDatastoreItemInfo]] = {} 

259 

260 def __str__(self) -> str: 

261 return str(self._db) 

262 

263 def __repr__(self) -> str: 

264 return f"SqlRegistry({self._db!r}, {self.dimensions!r})" 

265 

266 def isWriteable(self) -> bool: 

267 """Return `True` if this registry allows write operations, and `False` 

268 otherwise. 

269 """ 

270 return self._db.isWriteable() 

271 

272 def copy(self, defaults: RegistryDefaults | None = None) -> SqlRegistry: 

273 """Create a new `SqlRegistry` backed by the same data repository 

274 as this one and sharing a database connection pool with it, but with 

275 independent defaults and database sessions. 

276 

277 Parameters 

278 ---------- 

279 defaults : `~lsst.daf.butler.registry.RegistryDefaults`, optional 

280 Default collections and data ID values for the new registry. If 

281 not provided, ``self.defaults`` will be used (but future changes 

282 to either registry's defaults will not affect the other). 

283 

284 Returns 

285 ------- 

286 copy : `SqlRegistry` 

287 A new `SqlRegistry` instance with its own defaults. 

288 """ 

289 if defaults is None: 

290 # No need to copy, because `RegistryDefaults` is immutable; we 

291 # effectively copy on write. 

292 defaults = self.defaults 

293 db = self._db.clone() 

294 result = SqlRegistry(db, defaults, self._managers.clone(db)) 

295 result._datastore_record_classes = dict(self._datastore_record_classes) 

296 result.dimension_record_cache.load_from(self.dimension_record_cache) 

297 return result 

298 

299 @property 

300 def dimensions(self) -> DimensionUniverse: 

301 """Definitions of all dimensions recognized by this `Registry` 

302 (`DimensionUniverse`). 

303 """ 

304 return self._managers.dimensions.universe 

305 

306 @property 

307 def defaults(self) -> RegistryDefaults: 

308 """Default collection search path and/or output `~CollectionType.RUN` 

309 collection (`~lsst.daf.butler.registry.RegistryDefaults`). 

310 

311 This is an immutable struct whose components may not be set 

312 individually, but the entire struct can be set by assigning to this 

313 property. 

314 """ 

315 return self._defaults 

316 

317 @defaults.setter 

318 def defaults(self, value: RegistryDefaults) -> None: 

319 if value.run is not None: 

320 self.registerRun(value.run) 

321 value.finish(self) 

322 self._defaults = value 

323 

324 def refresh(self) -> None: 

325 """Refresh all in-memory state by querying the database. 

326 

327 This may be necessary to enable querying for entities added by other 

328 registry instances after this one was constructed. 

329 """ 

330 self.dimension_record_cache.reset() 

331 with self._db.transaction(): 

332 self._managers.refresh() 

333 

334 def refresh_collection_summaries(self) -> None: 

335 """Refresh content of the collection summary tables in the database. 

336 

337 This only cleans dataset type summaries, we may want to add cleanup of 

338 governor summaries later. 

339 """ 

340 for dataset_type in self.queryDatasetTypes(): 

341 self._managers.datasets.refresh_collection_summaries(dataset_type) 

342 

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

344 """Return context manager that enables caching. 

345 

346 Returns 

347 ------- 

348 manager 

349 A context manager that enables client-side caching. Entering 

350 the context returns `None`. 

351 """ 

352 return self._managers.caching_context_manager() 

353 

354 @contextlib.contextmanager 

355 def transaction(self, *, savepoint: bool = False) -> Iterator[None]: 

356 """Return a context manager that represents a transaction. 

357 

358 Parameters 

359 ---------- 

360 savepoint : `bool` 

361 Whether to issue a SAVEPOINT in the database. 

362 

363 Yields 

364 ------ 

365 `None` 

366 """ 

367 with self._db.transaction(savepoint=savepoint): 

368 yield 

369 

370 def resetConnectionPool(self) -> None: 

371 """Reset SQLAlchemy connection pool for `SqlRegistry` database. 

372 

373 This operation is useful when using registry with fork-based 

374 multiprocessing. To use registry across fork boundary one has to make 

375 sure that there are no currently active connections (no session or 

376 transaction is in progress) and connection pool is reset using this 

377 method. This method should be called by the child process immediately 

378 after the fork. 

379 """ 

380 self._db._engine.dispose() 

381 

382 def registerOpaqueTable(self, tableName: str, spec: ddl.TableSpec) -> None: 

383 """Add an opaque (to the `Registry`) table for use by a `Datastore` or 

384 other data repository client. 

385 

386 Opaque table records can be added via `insertOpaqueData`, retrieved via 

387 `fetchOpaqueData`, and removed via `deleteOpaqueData`. 

388 

389 Parameters 

390 ---------- 

391 tableName : `str` 

392 Logical name of the opaque table. This may differ from the 

393 actual name used in the database by a prefix and/or suffix. 

394 spec : `ddl.TableSpec` 

395 Specification for the table to be added. 

396 """ 

397 self._managers.opaque.register(tableName, spec) 

398 

399 @transactional 

400 def insertOpaqueData(self, tableName: str, *data: dict) -> None: 

401 """Insert records into an opaque table. 

402 

403 Parameters 

404 ---------- 

405 tableName : `str` 

406 Logical name of the opaque table. Must match the name used in a 

407 previous call to `registerOpaqueTable`. 

408 *data 

409 Each additional positional argument is a dictionary that represents 

410 a single row to be added. 

411 """ 

412 self._managers.opaque[tableName].insert(*data) 

413 

414 def fetchOpaqueData(self, tableName: str, **where: Any) -> Iterator[Mapping[str, Any]]: 

415 """Retrieve records from an opaque table. 

416 

417 Parameters 

418 ---------- 

419 tableName : `str` 

420 Logical name of the opaque table. Must match the name used in a 

421 previous call to `registerOpaqueTable`. 

422 **where 

423 Additional keyword arguments are interpreted as equality 

424 constraints that restrict the returned rows (combined with AND); 

425 keyword arguments are column names and values are the values they 

426 must have. 

427 

428 Yields 

429 ------ 

430 row : `dict` 

431 A dictionary representing a single result row. 

432 """ 

433 yield from self._managers.opaque[tableName].fetch(**where) 

434 

435 @transactional 

436 def deleteOpaqueData(self, tableName: str, **where: Any) -> None: 

437 """Remove records from an opaque table. 

438 

439 Parameters 

440 ---------- 

441 tableName : `str` 

442 Logical name of the opaque table. Must match the name used in a 

443 previous call to `registerOpaqueTable`. 

444 **where 

445 Additional keyword arguments are interpreted as equality 

446 constraints that restrict the deleted rows (combined with AND); 

447 keyword arguments are column names and values are the values they 

448 must have. 

449 """ 

450 self._managers.opaque[tableName].delete(where.keys(), where) 

451 

452 def registerCollection( 

453 self, name: str, type: CollectionType = CollectionType.TAGGED, doc: str | None = None 

454 ) -> bool: 

455 """Add a new collection if one with the given name does not exist. 

456 

457 Parameters 

458 ---------- 

459 name : `str` 

460 The name of the collection to create. 

461 type : `CollectionType` 

462 Enum value indicating the type of collection to create. 

463 doc : `str`, optional 

464 Documentation string for the collection. 

465 

466 Returns 

467 ------- 

468 registered : `bool` 

469 Boolean indicating whether the collection was already registered 

470 or was created by this call. 

471 

472 Notes 

473 ----- 

474 This method cannot be called within transactions, as it needs to be 

475 able to perform its own transaction to be concurrent. 

476 """ 

477 _, registered = self._managers.collections.register(name, type, doc=doc) 

478 return registered 

479 

480 def getCollectionType(self, name: str) -> CollectionType: 

481 """Return an enumeration value indicating the type of the given 

482 collection. 

483 

484 Parameters 

485 ---------- 

486 name : `str` 

487 The name of the collection. 

488 

489 Returns 

490 ------- 

491 type : `CollectionType` 

492 Enum value indicating the type of this collection. 

493 

494 Raises 

495 ------ 

496 lsst.daf.butler.registry.MissingCollectionError 

497 Raised if no collection with the given name exists. 

498 """ 

499 return self._managers.collections.find(name).type 

500 

501 def get_collection_record(self, name: str) -> CollectionRecord: 

502 """Return the record for this collection. 

503 

504 Parameters 

505 ---------- 

506 name : `str` 

507 Name of the collection for which the record is to be retrieved. 

508 

509 Returns 

510 ------- 

511 record : `CollectionRecord` 

512 The record for this collection. 

513 """ 

514 return self._managers.collections.find(name) 

515 

516 def registerRun(self, name: str, doc: str | None = None) -> bool: 

517 """Add a new run if one with the given name does not exist. 

518 

519 Parameters 

520 ---------- 

521 name : `str` 

522 The name of the run to create. 

523 doc : `str`, optional 

524 Documentation string for the collection. 

525 

526 Returns 

527 ------- 

528 registered : `bool` 

529 Boolean indicating whether a new run was registered. `False` 

530 if it already existed. 

531 

532 Notes 

533 ----- 

534 This method cannot be called within transactions, as it needs to be 

535 able to perform its own transaction to be concurrent. 

536 """ 

537 _, registered = self._managers.collections.register(name, CollectionType.RUN, doc=doc) 

538 return registered 

539 

540 @transactional 

541 def removeCollection(self, name: str) -> None: 

542 """Remove the given collection from the registry. 

543 

544 Parameters 

545 ---------- 

546 name : `str` 

547 The name of the collection to remove. 

548 

549 Raises 

550 ------ 

551 lsst.daf.butler.registry.MissingCollectionError 

552 Raised if no collection with the given name exists. 

553 sqlalchemy.exc.IntegrityError 

554 Raised if the database rows associated with the collection are 

555 still referenced by some other table, such as a dataset in a 

556 datastore (for `~CollectionType.RUN` collections only) or a 

557 `~CollectionType.CHAINED` collection of which this collection is 

558 a child. 

559 

560 Notes 

561 ----- 

562 If this is a `~CollectionType.RUN` collection, all datasets and quanta 

563 in it will removed from the `Registry` database. This requires that 

564 those datasets be removed (or at least trashed) from any datastores 

565 that hold them first. 

566 

567 A collection may not be deleted as long as it is referenced by a 

568 `~CollectionType.CHAINED` collection; the ``CHAINED`` collection must 

569 be deleted or redefined first. 

570 """ 

571 self._managers.collections.remove(name) 

572 

573 def getCollectionChain(self, parent: str) -> tuple[str, ...]: 

574 """Return the child collections in a `~CollectionType.CHAINED` 

575 collection. 

576 

577 Parameters 

578 ---------- 

579 parent : `str` 

580 Name of the chained collection. Must have already been added via 

581 a call to `Registry.registerCollection`. 

582 

583 Returns 

584 ------- 

585 children : `~collections.abc.Sequence` [ `str` ] 

586 An ordered sequence of collection names that are searched when the 

587 given chained collection is searched. 

588 

589 Raises 

590 ------ 

591 lsst.daf.butler.registry.MissingCollectionError 

592 Raised if ``parent`` does not exist in the `Registry`. 

593 lsst.daf.butler.registry.CollectionTypeError 

594 Raised if ``parent`` does not correspond to a 

595 `~CollectionType.CHAINED` collection. 

596 """ 

597 record = self._managers.collections.find(parent) 

598 if record.type is not CollectionType.CHAINED: 

599 raise CollectionTypeError(f"Collection '{parent}' has type {record.type.name}, not CHAINED.") 

600 assert isinstance(record, ChainedCollectionRecord) 

601 return record.children 

602 

603 @transactional 

604 def setCollectionChain(self, parent: str, children: Any, *, flatten: bool = False) -> None: 

605 """Define or redefine a `~CollectionType.CHAINED` collection. 

606 

607 Parameters 

608 ---------- 

609 parent : `str` 

610 Name of the chained collection. Must have already been added via 

611 a call to `Registry.registerCollection`. 

612 children : collection expression 

613 An expression defining an ordered search of child collections, 

614 generally an iterable of `str`; see 

615 :ref:`daf_butler_collection_expressions` for more information. 

616 flatten : `bool`, optional 

617 If `True` (`False` is default), recursively flatten out any nested 

618 `~CollectionType.CHAINED` collections in ``children`` first. 

619 

620 Raises 

621 ------ 

622 lsst.daf.butler.registry.MissingCollectionError 

623 Raised when any of the given collections do not exist in the 

624 `Registry`. 

625 lsst.daf.butler.registry.CollectionTypeError 

626 Raised if ``parent`` does not correspond to a 

627 `~CollectionType.CHAINED` collection. 

628 CollectionCycleError 

629 Raised if the given collections contains a cycle. 

630 

631 Notes 

632 ----- 

633 If this function is called within a call to ``Butler.transaction``, it 

634 will hold a lock that prevents other processes from modifying the 

635 parent collection until the end of the transaction. Keep these 

636 transactions short. 

637 """ 

638 children = CollectionWildcard.from_expression(children).require_ordered() 

639 if flatten: 

640 children = self.queryCollections(children, flattenChains=True) 

641 

642 self._managers.collections.update_chain(parent, list(children), allow_use_in_caching_context=True) 

643 

644 def getCollectionParentChains(self, collection: str) -> set[str]: 

645 """Return the CHAINED collections that directly contain the given one. 

646 

647 Parameters 

648 ---------- 

649 collection : `str` 

650 Name of the collection. 

651 

652 Returns 

653 ------- 

654 chains : `set` of `str` 

655 Set of `~CollectionType.CHAINED` collection names. 

656 """ 

657 return self._managers.collections.getParentChains(self._managers.collections.find(collection).key) 

658 

659 def getCollectionDocumentation(self, collection: str) -> str | None: 

660 """Retrieve the documentation string for a collection. 

661 

662 Parameters 

663 ---------- 

664 collection : `str` 

665 Name of the collection. 

666 

667 Returns 

668 ------- 

669 docs : `str` or `None` 

670 Docstring for the collection with the given name. 

671 """ 

672 return self._managers.collections.getDocumentation(self._managers.collections.find(collection).key) 

673 

674 def setCollectionDocumentation(self, collection: str, doc: str | None) -> None: 

675 """Set the documentation string for a collection. 

676 

677 Parameters 

678 ---------- 

679 collection : `str` 

680 Name of the collection. 

681 doc : `str` or `None` 

682 Docstring for the collection with the given name; will replace any 

683 existing docstring. Passing `None` will remove any existing 

684 docstring. 

685 """ 

686 self._managers.collections.setDocumentation(self._managers.collections.find(collection).key, doc) 

687 

688 def getCollectionSummary(self, collection: str) -> CollectionSummary: 

689 """Return a summary for the given collection. 

690 

691 Parameters 

692 ---------- 

693 collection : `str` 

694 Name of the collection for which a summary is to be retrieved. 

695 

696 Returns 

697 ------- 

698 summary : `~lsst.daf.butler.registry.CollectionSummary` 

699 Summary of the dataset types and governor dimension values in 

700 this collection. 

701 """ 

702 record = self._managers.collections.find(collection) 

703 return self._managers.datasets.getCollectionSummary(record) 

704 

705 def registerDatasetType(self, datasetType: DatasetType) -> bool: 

706 """Add a new `DatasetType` to the Registry. 

707 

708 It is not an error to register the same `DatasetType` twice. 

709 

710 Parameters 

711 ---------- 

712 datasetType : `DatasetType` 

713 The `DatasetType` to be added. 

714 

715 Returns 

716 ------- 

717 inserted : `bool` 

718 `True` if ``datasetType`` was inserted, `False` if an identical 

719 existing `DatasetType` was found. Note that in either case the 

720 DatasetType is guaranteed to be defined in the Registry 

721 consistently with the given definition. 

722 

723 Raises 

724 ------ 

725 ValueError 

726 Raised if the dimensions or storage class are invalid. 

727 lsst.daf.butler.registry.ConflictingDefinitionError 

728 Raised if this `DatasetType` is already registered with a different 

729 definition. 

730 

731 Notes 

732 ----- 

733 This method cannot be called within transactions, as it needs to be 

734 able to perform its own transaction to be concurrent. 

735 """ 

736 return self._managers.datasets.register_dataset_type(datasetType) 

737 

738 def removeDatasetType(self, name: str | tuple[str, ...]) -> None: 

739 """Remove the named `DatasetType` from the registry. 

740 

741 .. warning:: 

742 

743 Registry implementations can cache the dataset type definitions. 

744 This means that deleting the dataset type definition may result in 

745 unexpected behavior from other butler processes that are active 

746 that have not seen the deletion. 

747 

748 Parameters 

749 ---------- 

750 name : `str` or `tuple` [`str`] 

751 Name of the type to be removed or tuple containing a list of type 

752 names to be removed. Wildcards are allowed. 

753 

754 Raises 

755 ------ 

756 lsst.daf.butler.registry.OrphanedRecordError 

757 Raised if an attempt is made to remove the dataset type definition 

758 when there are already datasets associated with it. 

759 

760 Notes 

761 ----- 

762 If the dataset type is not registered the method will return without 

763 action. 

764 """ 

765 for datasetTypeExpression in ensure_iterable(name): 

766 # Catch any warnings from the caller specifying a component 

767 # dataset type. This will result in an error later but the 

768 # warning could be confusing when the caller is not querying 

769 # anything. 

770 with warnings.catch_warnings(): 

771 warnings.simplefilter("ignore", category=FutureWarning) 

772 datasetTypes = list(self.queryDatasetTypes(datasetTypeExpression)) 

773 if not datasetTypes: 

774 _LOG.info("Dataset type %r not defined", datasetTypeExpression) 

775 else: 

776 for datasetType in datasetTypes: 

777 self._managers.datasets.remove_dataset_type(datasetType.name) 

778 _LOG.info("Removed dataset type %r", datasetType.name) 

779 

780 def getDatasetType(self, name: str) -> DatasetType: 

781 """Get the `DatasetType`. 

782 

783 Parameters 

784 ---------- 

785 name : `str` 

786 Name of the type. 

787 

788 Returns 

789 ------- 

790 type : `DatasetType` 

791 The `DatasetType` associated with the given name. 

792 

793 Raises 

794 ------ 

795 lsst.daf.butler.registry.MissingDatasetTypeError 

796 Raised if the requested dataset type has not been registered. 

797 

798 Notes 

799 ----- 

800 This method handles component dataset types automatically, though most 

801 other registry operations do not. 

802 """ 

803 parent_name, component = DatasetType.splitDatasetTypeName(name) 

804 parent_dataset_type = self._managers.datasets.get_dataset_type(parent_name) 

805 if component is None: 

806 return parent_dataset_type 

807 else: 

808 return parent_dataset_type.makeComponentDatasetType(component) 

809 

810 def supportsIdGenerationMode(self, mode: DatasetIdGenEnum) -> bool: 

811 """Test whether the given dataset ID generation mode is supported by 

812 `insertDatasets`. 

813 

814 Parameters 

815 ---------- 

816 mode : `DatasetIdGenEnum` 

817 Enum value for the mode to test. 

818 

819 Returns 

820 ------- 

821 supported : `bool` 

822 Whether the given mode is supported. 

823 """ 

824 return True 

825 

826 @transactional 

827 def insertDatasets( 

828 self, 

829 datasetType: DatasetType | str, 

830 dataIds: Iterable[DataId], 

831 run: str | None = None, 

832 expand: bool = True, 

833 idGenerationMode: DatasetIdGenEnum = DatasetIdGenEnum.UNIQUE, 

834 ) -> list[DatasetRef]: 

835 """Insert one or more datasets into the `Registry`. 

836 

837 This always adds new datasets; to associate existing datasets with 

838 a new collection, use ``associate``. 

839 

840 Parameters 

841 ---------- 

842 datasetType : `DatasetType` or `str` 

843 A `DatasetType` or the name of one. 

844 dataIds : `~collections.abc.Iterable` of `dict` or `DataCoordinate` 

845 Dimension-based identifiers for the new datasets. 

846 run : `str`, optional 

847 The name of the run that produced the datasets. Defaults to 

848 ``self.defaults.run``. 

849 expand : `bool`, optional 

850 If `True` (default), expand data IDs as they are inserted. This is 

851 necessary in general to allow datastore to generate file templates, 

852 but it may be disabled if the caller can guarantee this is 

853 unnecessary. 

854 idGenerationMode : `DatasetIdGenEnum`, optional 

855 Specifies option for generating dataset IDs. By default unique IDs 

856 are generated for each inserted dataset. 

857 

858 Returns 

859 ------- 

860 refs : `list` of `DatasetRef` 

861 Resolved `DatasetRef` instances for all given data IDs (in the same 

862 order). 

863 

864 Raises 

865 ------ 

866 lsst.daf.butler.registry.DatasetTypeError 

867 Raised if ``datasetType`` is not known to registry. 

868 lsst.daf.butler.registry.CollectionTypeError 

869 Raised if ``run`` collection type is not `~CollectionType.RUN`. 

870 lsst.daf.butler.registry.NoDefaultCollectionError 

871 Raised if ``run`` is `None` and ``self.defaults.run`` is `None`. 

872 lsst.daf.butler.registry.ConflictingDefinitionError 

873 If a dataset with the same dataset type and data ID as one of those 

874 given already exists in ``run``. 

875 lsst.daf.butler.registry.MissingCollectionError 

876 Raised if ``run`` does not exist in the registry. 

877 """ 

878 datasetType = self._managers.datasets.conform_exact_dataset_type(datasetType) 

879 if run is None: 

880 if self.defaults.run is None: 

881 raise NoDefaultCollectionError( 

882 "No run provided to insertDatasets, and no default from registry construction." 

883 ) 

884 run = self.defaults.run 

885 runRecord = self._managers.collections.find(run) 

886 if runRecord.type is not CollectionType.RUN: 

887 raise CollectionTypeError( 

888 f"Given collection is of type {runRecord.type.name}; RUN collection required." 

889 ) 

890 assert isinstance(runRecord, RunRecord) 

891 

892 expandedDataIds = [ 

893 DataCoordinate.standardize(dataId, dimensions=datasetType.dimensions) for dataId in dataIds 

894 ] 

895 if expand: 

896 _LOG.debug("Expanding %d data IDs", len(expandedDataIds)) 

897 expandedDataIds = self.expand_data_ids(expandedDataIds) 

898 _LOG.debug("Finished expanding data IDs") 

899 

900 try: 

901 refs = list( 

902 self._managers.datasets.insert(datasetType.name, runRecord, expandedDataIds, idGenerationMode) 

903 ) 

904 if self._managers.obscore: 

905 self._managers.obscore.add_datasets(refs) 

906 except sqlalchemy.exc.IntegrityError as err: 

907 raise ConflictingDefinitionError( 

908 "A database constraint failure was triggered by inserting " 

909 f"one or more datasets of type {datasetType} into " 

910 f"collection '{run}'. " 

911 "This probably means a dataset with the same data ID " 

912 "and dataset type already exists, but it may also mean a " 

913 "dimension row is missing." 

914 ) from err 

915 return refs 

916 

917 @transactional 

918 def _importDatasets( 

919 self, 

920 datasets: Iterable[DatasetRef], 

921 expand: bool = True, 

922 ) -> list[DatasetRef]: 

923 """Import one or more datasets into the `Registry`. 

924 

925 Difference from `insertDatasets` method is that this method accepts 

926 `DatasetRef` instances which should already be resolved and have a 

927 dataset ID. If registry supports globally-unique dataset IDs (e.g. 

928 `uuid.UUID`) then datasets which already exist in the registry will be 

929 ignored if imported again. 

930 

931 Parameters 

932 ---------- 

933 datasets : `~collections.abc.Iterable` of `DatasetRef` 

934 Datasets to be inserted. All `DatasetRef` instances must have 

935 identical ``run`` attributes. ``run`` 

936 attribute can be `None` and defaults to ``self.defaults.run``. 

937 Datasets can specify ``id`` attribute which will be used for 

938 inserted datasets. 

939 Datasets can be of multiple dataset types, but all the dataset 

940 types must have the same set of dimensions. 

941 expand : `bool`, optional 

942 If `True` (default), expand data IDs as they are inserted. This is 

943 necessary in general, but it may be disabled if the caller can 

944 guarantee this is unnecessary. 

945 

946 Returns 

947 ------- 

948 refs : `list` of `DatasetRef` 

949 Resolved `DatasetRef` instances for all given data IDs (in the same 

950 order). If any of ``datasets`` has an ID which already exists in 

951 the database then it will not be inserted or updated, but a 

952 resolved `DatasetRef` will be returned for it in any case. 

953 

954 Raises 

955 ------ 

956 lsst.daf.butler.registry.NoDefaultCollectionError 

957 Raised if ``run`` is `None` and ``self.defaults.run`` is `None`. 

958 lsst.daf.butler.registry.DatasetTypeError 

959 Raised if a dataset type is not known to registry. 

960 lsst.daf.butler.registry.ConflictingDefinitionError 

961 If a dataset with the same dataset type and data ID as one of those 

962 given already exists in ``run``. 

963 lsst.daf.butler.registry.MissingCollectionError 

964 Raised if ``run`` does not exist in the registry. 

965 

966 Notes 

967 ----- 

968 This method is considered package-private and internal to Butler 

969 implementation. Clients outside daf_butler package should not use this 

970 method. 

971 """ 

972 datasets = list(datasets) 

973 if not datasets: 

974 # nothing to do 

975 return [] 

976 

977 # find run name 

978 runs = {dataset.run for dataset in datasets} 

979 if len(runs) != 1: 

980 raise ValueError(f"Multiple run names in input datasets: {runs}") 

981 run = runs.pop() 

982 

983 runRecord = self._managers.collections.find(run) 

984 if runRecord.type is not CollectionType.RUN: 

985 raise CollectionTypeError( 

986 f"Given collection '{runRecord.name}' is of type {runRecord.type.name};" 

987 " RUN collection required." 

988 ) 

989 assert isinstance(runRecord, RunRecord) 

990 

991 if expand: 

992 _LOG.debug("Expanding %d data IDs", len(datasets)) 

993 datasets = self.expand_refs(datasets) 

994 _LOG.debug("Finished expanding data IDs") 

995 

996 try: 

997 self._managers.datasets.import_(runRecord, datasets) 

998 if self._managers.obscore: 

999 self._managers.obscore.add_datasets(datasets) 

1000 except sqlalchemy.exc.IntegrityError as err: 

1001 raise ConflictingDefinitionError( 

1002 "A database constraint failure was triggered by inserting " 

1003 f"one or more datasets into collection '{run}'. " 

1004 "This probably means a dataset with the same data ID " 

1005 "and dataset type already exists, but it may also mean a " 

1006 "dimension row is missing." 

1007 ) from err 

1008 return datasets 

1009 

1010 def getDataset(self, id: DatasetId) -> DatasetRef | None: 

1011 """Retrieve a Dataset entry. 

1012 

1013 Parameters 

1014 ---------- 

1015 id : `DatasetId` 

1016 The unique identifier for the dataset. 

1017 

1018 Returns 

1019 ------- 

1020 ref : `DatasetRef` or `None` 

1021 A ref to the Dataset, or `None` if no matching Dataset 

1022 was found. 

1023 """ 

1024 return self._managers.datasets.getDatasetRef(id) 

1025 

1026 @transactional 

1027 def removeDatasets(self, refs: Iterable[DatasetRef]) -> None: 

1028 """Remove datasets from the Registry. 

1029 

1030 The datasets will be removed unconditionally from all collections. 

1031 `Datastore` records will *not* be deleted; the caller is responsible 

1032 for ensuring that the dataset has already been removed from all 

1033 Datastores. 

1034 

1035 Parameters 

1036 ---------- 

1037 refs : `~collections.abc.Iterable` [`DatasetRef`] 

1038 References to the datasets to be removed. Should be considered 

1039 invalidated upon return. 

1040 

1041 Raises 

1042 ------ 

1043 lsst.daf.butler.registry.OrphanedRecordError 

1044 Raised if any dataset is still present in any `Datastore`. 

1045 """ 

1046 try: 

1047 self._managers.datasets.delete(refs) 

1048 except sqlalchemy.exc.IntegrityError as err: 

1049 raise OrphanedRecordError( 

1050 "One or more datasets is still present in one or more Datastores." 

1051 ) from err 

1052 

1053 @transactional 

1054 def associate(self, collection: str, refs: Iterable[DatasetRef]) -> None: 

1055 """Add existing datasets to a `~CollectionType.TAGGED` collection. 

1056 

1057 If a DatasetRef with the same exact ID is already in a collection 

1058 nothing is changed. If a `DatasetRef` with the same `DatasetType` and 

1059 data ID but with different ID exists in the collection, 

1060 `~lsst.daf.butler.registry.ConflictingDefinitionError` is raised. 

1061 

1062 Parameters 

1063 ---------- 

1064 collection : `str` 

1065 Indicates the collection the datasets should be associated with. 

1066 refs : `~collections.abc.Iterable` [ `DatasetRef` ] 

1067 An iterable of resolved `DatasetRef` instances that already exist 

1068 in this `Registry`. 

1069 

1070 Raises 

1071 ------ 

1072 lsst.daf.butler.registry.ConflictingDefinitionError 

1073 If a Dataset with the given `DatasetRef` already exists in the 

1074 given collection. 

1075 lsst.daf.butler.registry.MissingCollectionError 

1076 Raised if ``collection`` does not exist in the registry. 

1077 lsst.daf.butler.registry.CollectionTypeError 

1078 Raise adding new datasets to the given ``collection`` is not 

1079 allowed. 

1080 """ 

1081 progress = Progress("lsst.daf.butler.Registry.associate", level=logging.DEBUG) 

1082 collectionRecord = self._managers.collections.find(collection) 

1083 for datasetType, refsForType in progress.iter_item_chunks( 

1084 DatasetRef.iter_by_type(refs), desc="Associating datasets by type" 

1085 ): 

1086 try: 

1087 self._managers.datasets.associate(datasetType, collectionRecord, refsForType) 

1088 if self._managers.obscore: 

1089 # If a TAGGED collection is being monitored by ObsCore 

1090 # manager then we may need to save the dataset. 

1091 self._managers.obscore.associate(refsForType, collectionRecord) 

1092 except sqlalchemy.exc.IntegrityError as err: 

1093 raise ConflictingDefinitionError( 

1094 f"Constraint violation while associating dataset of type {datasetType.name} with " 

1095 f"collection {collection}. This probably means that one or more datasets with the same " 

1096 "dataset type and data ID already exist in the collection, but it may also indicate " 

1097 "that the datasets do not exist." 

1098 ) from err 

1099 

1100 @transactional 

1101 def disassociate(self, collection: str, refs: Iterable[DatasetRef]) -> None: 

1102 """Remove existing datasets from a `~CollectionType.TAGGED` collection. 

1103 

1104 ``collection`` and ``ref`` combinations that are not currently 

1105 associated are silently ignored. 

1106 

1107 Parameters 

1108 ---------- 

1109 collection : `str` 

1110 The collection the datasets should no longer be associated with. 

1111 refs : `~collections.abc.Iterable` [ `DatasetRef` ] 

1112 An iterable of resolved `DatasetRef` instances that already exist 

1113 in this `Registry`. 

1114 

1115 Raises 

1116 ------ 

1117 lsst.daf.butler.AmbiguousDatasetError 

1118 Raised if any of the given dataset references is unresolved. 

1119 lsst.daf.butler.registry.MissingCollectionError 

1120 Raised if ``collection`` does not exist in the registry. 

1121 lsst.daf.butler.registry.CollectionTypeError 

1122 Raise adding new datasets to the given ``collection`` is not 

1123 allowed. 

1124 """ 

1125 progress = Progress("lsst.daf.butler.Registry.disassociate", level=logging.DEBUG) 

1126 collectionRecord = self._managers.collections.find(collection) 

1127 for datasetType, refsForType in progress.iter_item_chunks( 

1128 DatasetRef.iter_by_type(refs), desc="Disassociating datasets by type" 

1129 ): 

1130 self._managers.datasets.disassociate(datasetType, collectionRecord, refsForType) 

1131 if self._managers.obscore: 

1132 self._managers.obscore.disassociate(refsForType, collectionRecord) 

1133 

1134 @transactional 

1135 def certify(self, collection: str, refs: Iterable[DatasetRef], timespan: Timespan) -> None: 

1136 """Associate one or more datasets with a calibration collection and a 

1137 validity range within it. 

1138 

1139 Parameters 

1140 ---------- 

1141 collection : `str` 

1142 The name of an already-registered `~CollectionType.CALIBRATION` 

1143 collection. 

1144 refs : `~collections.abc.Iterable` [ `DatasetRef` ] 

1145 Datasets to be associated. 

1146 timespan : `Timespan` 

1147 The validity range for these datasets within the collection. 

1148 

1149 Raises 

1150 ------ 

1151 lsst.daf.butler.AmbiguousDatasetError 

1152 Raised if any of the given `DatasetRef` instances is unresolved. 

1153 lsst.daf.butler.registry.ConflictingDefinitionError 

1154 Raised if the collection already contains a different dataset with 

1155 the same `DatasetType` and data ID and an overlapping validity 

1156 range. 

1157 DatasetTypeError 

1158 Raised if ``ref.datasetType.isCalibration() is False`` for any ref. 

1159 CollectionTypeError 

1160 Raised if 

1161 ``collection.type is not CollectionType.CALIBRATION``. 

1162 """ 

1163 progress = Progress("lsst.daf.butler.Registry.certify", level=logging.DEBUG) 

1164 collectionRecord = self._managers.collections.find(collection) 

1165 for datasetType, refsForType in progress.iter_item_chunks( 

1166 DatasetRef.iter_by_type(refs), desc="Certifying datasets by type" 

1167 ): 

1168 self._managers.datasets.certify( 

1169 datasetType, 

1170 collectionRecord, 

1171 refsForType, 

1172 timespan, 

1173 context=queries.SqlQueryContext(self._db, self._managers.column_types), 

1174 ) 

1175 

1176 @transactional 

1177 def decertify( 

1178 self, 

1179 collection: str, 

1180 datasetType: str | DatasetType, 

1181 timespan: Timespan, 

1182 *, 

1183 dataIds: Iterable[DataId] | None = None, 

1184 ) -> None: 

1185 """Remove or adjust datasets to clear a validity range within a 

1186 calibration collection. 

1187 

1188 Parameters 

1189 ---------- 

1190 collection : `str` 

1191 The name of an already-registered `~CollectionType.CALIBRATION` 

1192 collection. 

1193 datasetType : `str` or `DatasetType` 

1194 Name or `DatasetType` instance for the datasets to be decertified. 

1195 timespan : `Timespan`, optional 

1196 The validity range to remove datasets from within the collection. 

1197 Datasets that overlap this range but are not contained by it will 

1198 have their validity ranges adjusted to not overlap it, which may 

1199 split a single dataset validity range into two. 

1200 dataIds : iterable [`dict` or `DataCoordinate`], optional 

1201 Data IDs that should be decertified within the given validity range 

1202 If `None`, all data IDs for ``self.datasetType`` will be 

1203 decertified. 

1204 

1205 Raises 

1206 ------ 

1207 DatasetTypeError 

1208 Raised if ``datasetType.isCalibration() is False``. 

1209 CollectionTypeError 

1210 Raised if 

1211 ``collection.type is not CollectionType.CALIBRATION``. 

1212 """ 

1213 collectionRecord = self._managers.collections.find(collection) 

1214 if isinstance(datasetType, str): 

1215 datasetType = self.getDatasetType(datasetType) 

1216 standardizedDataIds = None 

1217 if dataIds is not None: 

1218 standardizedDataIds = [ 

1219 DataCoordinate.standardize(d, dimensions=datasetType.dimensions) for d in dataIds 

1220 ] 

1221 self._managers.datasets.decertify( 

1222 datasetType, 

1223 collectionRecord, 

1224 timespan, 

1225 data_ids=standardizedDataIds, 

1226 context=queries.SqlQueryContext(self._db, self._managers.column_types), 

1227 ) 

1228 

1229 def getDatastoreBridgeManager(self) -> DatastoreRegistryBridgeManager: 

1230 """Return an object that allows a new `Datastore` instance to 

1231 communicate with this `Registry`. 

1232 

1233 Returns 

1234 ------- 

1235 manager : `~.interfaces.DatastoreRegistryBridgeManager` 

1236 Object that mediates communication between this `Registry` and its 

1237 associated datastores. 

1238 """ 

1239 return self._managers.datastores 

1240 

1241 def getDatasetLocations(self, ref: DatasetRef) -> Iterable[str]: 

1242 """Retrieve datastore locations for a given dataset. 

1243 

1244 Parameters 

1245 ---------- 

1246 ref : `DatasetRef` 

1247 A reference to the dataset for which to retrieve storage 

1248 information. 

1249 

1250 Returns 

1251 ------- 

1252 datastores : `~collections.abc.Iterable` [ `str` ] 

1253 All the matching datastores holding this dataset. 

1254 

1255 Raises 

1256 ------ 

1257 lsst.daf.butler.AmbiguousDatasetError 

1258 Raised if ``ref.id`` is `None`. 

1259 """ 

1260 return self._managers.datastores.findDatastores(ref) 

1261 

1262 def expandDataId( 

1263 self, 

1264 dataId: DataId | None = None, 

1265 *, 

1266 dimensions: Iterable[str] | DimensionGroup | None = None, 

1267 records: Mapping[str, DimensionRecord | None] | None = None, 

1268 withDefaults: bool = True, 

1269 **kwargs: Any, 

1270 ) -> DataCoordinate: 

1271 """Expand a dimension-based data ID to include additional information. 

1272 

1273 Parameters 

1274 ---------- 

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

1276 Data ID to be expanded; augmented and overridden by ``kwargs``. 

1277 dimensions : `~collections.abc.Iterable` [ `str` ], \ 

1278 `DimensionGroup`, optional 

1279 The dimensions to be identified by the new `DataCoordinate`. 

1280 If not provided, will be inferred from the keys of ``dataId`` and 

1281 ``**kwargs``, and ``universe`` must be provided unless ``dataId`` 

1282 is already a `DataCoordinate`. 

1283 records : `~collections.abc.Mapping` [`str`, `DimensionRecord`], \ 

1284 optional 

1285 Dimension record data to use before querying the database for that 

1286 data, keyed by element name. 

1287 withDefaults : `bool`, optional 

1288 Utilize ``self.defaults.dataId`` to fill in missing governor 

1289 dimension key-value pairs. Defaults to `True` (i.e. defaults are 

1290 used). 

1291 **kwargs 

1292 Additional keywords are treated like additional key-value pairs for 

1293 ``dataId``, extending and overriding. 

1294 

1295 Returns 

1296 ------- 

1297 expanded : `DataCoordinate` 

1298 A data ID that includes full metadata for all of the dimensions it 

1299 identifies, i.e. guarantees that ``expanded.hasRecords()`` and 

1300 ``expanded.hasFull()`` both return `True`. 

1301 

1302 Raises 

1303 ------ 

1304 lsst.daf.butler.registry.DataIdError 

1305 Raised when ``dataId`` or keyword arguments specify unknown 

1306 dimensions or values, or when a resulting data ID contains 

1307 contradictory key-value pairs, according to dimension 

1308 relationships. 

1309 

1310 Notes 

1311 ----- 

1312 This method cannot be relied upon to reject invalid data ID values 

1313 for dimensions that do actually not have any record columns. For 

1314 efficiency reasons the records for these dimensions (which have only 

1315 dimension key values that are given by the caller) may be constructed 

1316 directly rather than obtained from the registry database. 

1317 """ 

1318 if not withDefaults: 

1319 defaults = None 

1320 else: 

1321 defaults = self.defaults.dataId 

1322 standardized = DataCoordinate.standardize( 

1323 dataId, 

1324 dimensions=dimensions, 

1325 universe=self.dimensions, 

1326 defaults=defaults, 

1327 **kwargs, 

1328 ) 

1329 if standardized.hasRecords(): 

1330 return standardized 

1331 if records is None: 

1332 records = {} 

1333 else: 

1334 records = dict(records) 

1335 if isinstance(dataId, DataCoordinate) and dataId.hasRecords(): 

1336 for element_name in dataId.dimensions.elements: 

1337 records[element_name] = dataId.records[element_name] 

1338 keys: dict[str, str | int] = dict(standardized.mapping) 

1339 for element_name in standardized.dimensions.lookup_order: 

1340 element = self.dimensions[element_name] 

1341 record = records.get(element_name, ...) # Use ... to mean not found; None might mean NULL 

1342 if record is ...: 

1343 if element_name in self.dimensions.dimensions.names and keys.get(element_name) is None: 

1344 raise DimensionNameError(f"No value or null value for dimension {element_name}.") 

1345 else: 

1346 record = self._managers.dimensions.fetch_one( 

1347 element_name, 

1348 DataCoordinate.standardize(keys, dimensions=element.minimal_group), 

1349 self.dimension_record_cache, 

1350 ) 

1351 records[element_name] = record 

1352 if record is not None: 

1353 for d in element.implied: 

1354 value = getattr(record, d.name) 

1355 if keys.setdefault(d.name, value) != value: 

1356 raise InconsistentDataIdError( 

1357 f"Data ID {standardized} has {d.name}={keys[d.name]!r}, " 

1358 f"but {element_name} implies {d.name}={value!r}." 

1359 ) 

1360 else: 

1361 if element_name in standardized.dimensions.names: 

1362 raise DataIdValueError( 

1363 f"Could not fetch record for dimension {element.name} via keys {keys}." 

1364 ) 

1365 if element.defines_relationships: 

1366 raise InconsistentDataIdError( 

1367 f"Could not fetch record for element {element_name} via keys {keys}, " 

1368 "but it is marked as defining relationships; this means one or more dimensions are " 

1369 "have inconsistent values.", 

1370 ) 

1371 return DataCoordinate.standardize(keys, dimensions=standardized.dimensions).expanded(records=records) 

1372 

1373 def expand_data_ids(self, data_ids: Iterable[DataCoordinate]) -> list[DataCoordinate]: 

1374 output = list(data_ids) 

1375 

1376 grouped_by_dimensions: defaultdict[DimensionGroup, list[int]] = defaultdict(list) 

1377 for i, data_id in enumerate(data_ids): 

1378 if not data_id.hasRecords(): 

1379 grouped_by_dimensions[data_id.dimensions].append(i) 

1380 

1381 if not grouped_by_dimensions: 

1382 # All given DataCoordinate values are already expanded. 

1383 return output 

1384 

1385 attacher = DimensionDataAttacher( 

1386 cache=self.dimension_record_cache, 

1387 dimensions=DimensionGroup.union(*grouped_by_dimensions.keys(), universe=self.dimensions), 

1388 ) 

1389 with self._query() as query: 

1390 for dimensions, indexes in grouped_by_dimensions.items(): 

1391 expanded = attacher.attach(dimensions, (output[index] for index in indexes), query) 

1392 for index, data_id in zip(indexes, expanded): 

1393 output[index] = data_id 

1394 

1395 return output 

1396 

1397 def expand_refs(self, dataset_refs: list[DatasetRef]) -> list[DatasetRef]: 

1398 expanded_ids = self.expand_data_ids([ref.dataId for ref in dataset_refs]) 

1399 return [ref.expanded(data_id) for ref, data_id in zip(dataset_refs, expanded_ids)] 

1400 

1401 def insertDimensionData( 

1402 self, 

1403 element: DimensionElement | str, 

1404 *data: Mapping[str, Any] | DimensionRecord, 

1405 conform: bool = True, 

1406 replace: bool = False, 

1407 skip_existing: bool = False, 

1408 ) -> None: 

1409 """Insert one or more dimension records into the database. 

1410 

1411 Parameters 

1412 ---------- 

1413 element : `DimensionElement` or `str` 

1414 The `DimensionElement` or name thereof that identifies the table 

1415 records will be inserted into. 

1416 *data : `dict` or `DimensionRecord` 

1417 One or more records to insert. 

1418 conform : `bool`, optional 

1419 If `False` (`True` is default) perform no checking or conversions, 

1420 and assume that ``element`` is a `DimensionElement` instance and 

1421 ``data`` is a one or more `DimensionRecord` instances of the 

1422 appropriate subclass. 

1423 replace : `bool`, optional 

1424 If `True` (`False` is default), replace existing records in the 

1425 database if there is a conflict. 

1426 skip_existing : `bool`, optional 

1427 If `True` (`False` is default), skip insertion if a record with 

1428 the same primary key values already exists. Unlike 

1429 `syncDimensionData`, this will not detect when the given record 

1430 differs from what is in the database, and should not be used when 

1431 this is a concern. 

1432 """ 

1433 if isinstance(element, str): 

1434 element = self.dimensions[element] 

1435 if conform: 

1436 records = [ 

1437 row if isinstance(row, DimensionRecord) else element.RecordClass(**row) for row in data 

1438 ] 

1439 else: 

1440 # Ignore typing since caller said to trust them with conform=False. 

1441 records = data # type: ignore 

1442 if element.name in self.dimension_record_cache: 

1443 self.dimension_record_cache.reset() 

1444 self._managers.dimensions.insert( 

1445 element, 

1446 *records, 

1447 replace=replace, 

1448 skip_existing=skip_existing, 

1449 ) 

1450 

1451 def syncDimensionData( 

1452 self, 

1453 element: DimensionElement | str, 

1454 row: Mapping[str, Any] | DimensionRecord, 

1455 conform: bool = True, 

1456 update: bool = False, 

1457 ) -> bool | dict[str, Any]: 

1458 """Synchronize the given dimension record with the database, inserting 

1459 if it does not already exist and comparing values if it does. 

1460 

1461 Parameters 

1462 ---------- 

1463 element : `DimensionElement` or `str` 

1464 The `DimensionElement` or name thereof that identifies the table 

1465 records will be inserted into. 

1466 row : `dict` or `DimensionRecord` 

1467 The record to insert. 

1468 conform : `bool`, optional 

1469 If `False` (`True` is default) perform no checking or conversions, 

1470 and assume that ``element`` is a `DimensionElement` instance and 

1471 ``data`` is a `DimensionRecord` instances of the appropriate 

1472 subclass. 

1473 update : `bool`, optional 

1474 If `True` (`False` is default), update the existing record in the 

1475 database if there is a conflict. 

1476 

1477 Returns 

1478 ------- 

1479 inserted_or_updated : `bool` or `dict` 

1480 `True` if a new row was inserted, `False` if no changes were 

1481 needed, or a `dict` mapping updated column names to their old 

1482 values if an update was performed (only possible if 

1483 ``update=True``). 

1484 

1485 Raises 

1486 ------ 

1487 lsst.daf.butler.registry.ConflictingDefinitionError 

1488 Raised if the record exists in the database (according to primary 

1489 key lookup) but is inconsistent with the given one. 

1490 """ 

1491 if conform: 

1492 if isinstance(element, str): 

1493 element = self.dimensions[element] 

1494 record = row if isinstance(row, DimensionRecord) else element.RecordClass(**row) 

1495 else: 

1496 # Ignore typing since caller said to trust them with conform=False. 

1497 record = row # type: ignore 

1498 if record.definition.name in self.dimension_record_cache: 

1499 self.dimension_record_cache.reset() 

1500 return self._managers.dimensions.sync(record, update=update) 

1501 

1502 def queryDatasetTypes( 

1503 self, 

1504 expression: Any = ..., 

1505 *, 

1506 missing: list[str] | None = None, 

1507 ) -> Iterable[DatasetType]: 

1508 """Iterate over the dataset types whose names match an expression. 

1509 

1510 Parameters 

1511 ---------- 

1512 expression : dataset type expression, optional 

1513 An expression that fully or partially identifies the dataset types 

1514 to return, such as a `str`, `re.Pattern`, or iterable thereof. 

1515 ``...`` can be used to return all dataset types, and is the 

1516 default. See :ref:`daf_butler_dataset_type_expressions` for more 

1517 information. 

1518 missing : `list` of `str`, optional 

1519 String dataset type names that were explicitly given (i.e. not 

1520 regular expression patterns) but not found will be appended to this 

1521 list, if it is provided. 

1522 

1523 Returns 

1524 ------- 

1525 dataset_types : `~collections.abc.Iterable` [ `DatasetType`] 

1526 An `~collections.abc.Iterable` of `DatasetType` instances whose 

1527 names match ``expression``. 

1528 

1529 Raises 

1530 ------ 

1531 lsst.daf.butler.registry.DatasetTypeExpressionError 

1532 Raised when ``expression`` is invalid. 

1533 """ 

1534 wildcard = DatasetTypeWildcard.from_expression(expression) 

1535 return self._managers.datasets.resolve_wildcard(wildcard, missing=missing) 

1536 

1537 def queryCollections( 

1538 self, 

1539 expression: Any = ..., 

1540 datasetType: DatasetType | None = None, 

1541 collectionTypes: Iterable[CollectionType] | CollectionType = CollectionType.all(), 

1542 flattenChains: bool = False, 

1543 includeChains: bool | None = None, 

1544 ) -> Sequence[str]: 

1545 """Iterate over the collections whose names match an expression. 

1546 

1547 Parameters 

1548 ---------- 

1549 expression : collection expression, optional 

1550 An expression that identifies the collections to return, such as 

1551 a `str` (for full matches or partial matches via globs), 

1552 `re.Pattern` (for partial matches), or iterable thereof. ``...`` 

1553 can be used to return all collections, and is the default. 

1554 See :ref:`daf_butler_collection_expressions` for more information. 

1555 datasetType : `DatasetType`, optional 

1556 If provided, only yield collections that may contain datasets of 

1557 this type. This is a conservative approximation in general; it may 

1558 yield collections that do not have any such datasets. 

1559 collectionTypes : `~collections.abc.Set` [`CollectionType`] or \ 

1560 `CollectionType`, optional 

1561 If provided, only yield collections of these types. 

1562 flattenChains : `bool`, optional 

1563 If `True` (`False` is default), recursively yield the child 

1564 collections of matching `~CollectionType.CHAINED` collections. 

1565 includeChains : `bool`, optional 

1566 If `True`, yield records for matching `~CollectionType.CHAINED` 

1567 collections. Default is the opposite of ``flattenChains``: include 

1568 either CHAINED collections or their children, but not both. 

1569 

1570 Returns 

1571 ------- 

1572 collections : `~collections.abc.Sequence` [ `str` ] 

1573 The names of collections that match ``expression``. 

1574 

1575 Raises 

1576 ------ 

1577 lsst.daf.butler.registry.CollectionExpressionError 

1578 Raised when ``expression`` is invalid. 

1579 

1580 Notes 

1581 ----- 

1582 The order in which collections are returned is unspecified, except that 

1583 the children of a `~CollectionType.CHAINED` collection are guaranteed 

1584 to be in the order in which they are searched. When multiple parent 

1585 `~CollectionType.CHAINED` collections match the same criteria, the 

1586 order in which the two lists appear is unspecified, and the lists of 

1587 children may be incomplete if a child has multiple parents. 

1588 """ 

1589 # Right now the datasetTypes argument is completely ignored, but that 

1590 # is consistent with its [lack of] guarantees. DM-24939 or a follow-up 

1591 # ticket will take care of that. 

1592 if datasetType is not None: 

1593 warnings.warn( 

1594 "The datasetType parameter should no longer be used. It has" 

1595 " never had any effect. Will be removed after v28", 

1596 FutureWarning, 

1597 ) 

1598 try: 

1599 wildcard = CollectionWildcard.from_expression(expression) 

1600 except TypeError as exc: 

1601 raise CollectionExpressionError(f"Invalid collection expression '{expression}'") from exc 

1602 collectionTypes = ensure_iterable(collectionTypes) 

1603 return [ 

1604 record.name 

1605 for record in self._managers.collections.resolve_wildcard( 

1606 wildcard, 

1607 collection_types=frozenset(collectionTypes), 

1608 flatten_chains=flattenChains, 

1609 include_chains=includeChains, 

1610 ) 

1611 ] 

1612 

1613 @contextlib.contextmanager 

1614 def _query(self) -> Iterator[Query]: 

1615 """Context manager returning a `Query` object used for construction 

1616 and execution of complex queries. 

1617 """ 

1618 with self._query_driver(self.defaults.collections, self.defaults.dataId) as driver: 

1619 yield Query(driver) 

1620 

1621 @contextlib.contextmanager 

1622 def _query_driver( 

1623 self, 

1624 default_collections: Iterable[str], 

1625 default_data_id: DataCoordinate, 

1626 ) -> Iterator[DirectQueryDriver]: 

1627 """Set up a `QueryDriver` instance for query execution.""" 

1628 # Query internals do repeated lookups of the same collections, so it 

1629 # benefits from the collection record cache. 

1630 with self._managers.caching_context.enable_collection_record_cache(): 

1631 driver = DirectQueryDriver( 

1632 self._db, 

1633 self.dimensions, 

1634 self._managers, 

1635 self.dimension_record_cache, 

1636 default_collections=default_collections, 

1637 default_data_id=default_data_id, 

1638 ) 

1639 with driver: 

1640 yield driver 

1641 

1642 def get_datastore_records(self, ref: DatasetRef) -> DatasetRef: 

1643 """Retrieve datastore records for given ref. 

1644 

1645 Parameters 

1646 ---------- 

1647 ref : `DatasetRef` 

1648 Dataset reference for which to retrieve its corresponding datastore 

1649 records. 

1650 

1651 Returns 

1652 ------- 

1653 updated_ref : `DatasetRef` 

1654 Dataset reference with filled datastore records. 

1655 

1656 Notes 

1657 ----- 

1658 If this method is called with the dataset ref that is not known to the 

1659 registry then the reference with an empty set of records is returned. 

1660 """ 

1661 datastore_records: dict[str, list[StoredDatastoreItemInfo]] = {} 

1662 for opaque, record_class in self._datastore_record_classes.items(): 

1663 records = self.fetchOpaqueData(opaque, dataset_id=ref.id) 

1664 datastore_records[opaque] = [record_class.from_record(record) for record in records] 

1665 return ref.replace(datastore_records=datastore_records) 

1666 

1667 def store_datastore_records(self, refs: Mapping[str, DatasetRef]) -> None: 

1668 """Store datastore records for given refs. 

1669 

1670 Parameters 

1671 ---------- 

1672 refs : `~collections.abc.Mapping` [`str`, `DatasetRef`] 

1673 Mapping of a datastore name to dataset reference stored in that 

1674 datastore, reference must include datastore records. 

1675 """ 

1676 for datastore_name, ref in refs.items(): 

1677 # Store ref IDs in the bridge table. 

1678 bridge = self._managers.datastores.register(datastore_name) 

1679 bridge.insert([ref]) 

1680 

1681 # store records in opaque tables 

1682 assert ref._datastore_records is not None, "Dataset ref must have datastore records" 

1683 for table_name, records in ref._datastore_records.items(): 

1684 opaque_table = self._managers.opaque.get(table_name) 

1685 assert opaque_table is not None, f"Unexpected opaque table name {table_name}" 

1686 opaque_table.insert(*(record.to_record(dataset_id=ref.id) for record in records)) 

1687 

1688 def make_datastore_tables(self, tables: Mapping[str, DatastoreOpaqueTable]) -> None: 

1689 """Create opaque tables used by datastores. 

1690 

1691 Parameters 

1692 ---------- 

1693 tables : `~collections.abc.Mapping` 

1694 Maps opaque table name to its definition. 

1695 

1696 Notes 

1697 ----- 

1698 This method should disappear in the future when opaque table 

1699 definitions will be provided during `Registry` construction. 

1700 """ 

1701 datastore_record_classes = {} 

1702 for table_name, table_def in tables.items(): 

1703 datastore_record_classes[table_name] = table_def.record_class 

1704 try: 

1705 self._managers.opaque.register(table_name, table_def.table_spec) 

1706 except ReadOnlyDatabaseError: 

1707 # If the database is read only and we just tried and failed to 

1708 # create a table, it means someone is trying to create a 

1709 # read-only butler client for an empty repo. That should be 

1710 # okay, as long as they then try to get any datasets before 

1711 # some other client creates the table. Chances are they're 

1712 # just validating configuration. 

1713 pass 

1714 self._datastore_record_classes = datastore_record_classes 

1715 

1716 def preload_cache(self, *, load_dimension_record_cache: bool) -> None: 

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

1718 

1719 Parameters 

1720 ---------- 

1721 load_dimension_record_cache : `bool` 

1722 If True, preload the dimension record cache. When this cache is 

1723 preloaded, subsequent external changes to governor dimension 

1724 records will not be visible to this Butler. 

1725 """ 

1726 self._managers.datasets.preload_cache() 

1727 

1728 if load_dimension_record_cache: 

1729 self.dimension_record_cache.preload_cache() 

1730 

1731 @property 

1732 def obsCoreTableManager(self) -> ObsCoreTableManager | None: 

1733 """The ObsCore manager instance for this registry 

1734 (`~.interfaces.ObsCoreTableManager` 

1735 or `None`). 

1736 

1737 ObsCore manager may not be implemented for all registry backend, or 

1738 may not be enabled for many repositories. 

1739 """ 

1740 return self._managers.obscore 

1741 

1742 storageClasses: StorageClassFactory 

1743 """All storage classes known to the registry (`StorageClassFactory`). 

1744 """ 

1745 

1746 _defaults: RegistryDefaults 

1747 """Default collections used for registry queries (`RegistryDefaults`)."""