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

421 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-12-19 01:11 -0800

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

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

7# for details of code ownership. 

8# 

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

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

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

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

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

14# 

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

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

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

18# (at your option) any later version. 

19# 

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

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

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

23# GNU General Public License for more details. 

24# 

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

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

27 

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) 

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

78from ..registry.managers import RegistryManagerInstances, RegistryManagerTypes 

79from ..registry.wildcards import CollectionWildcard, DatasetTypeWildcard 

80from ..utils import transactional 

81 

82if TYPE_CHECKING: 

83 from .._butler_config import ButlerConfig 

84 from ..datastore._datastore import DatastoreOpaqueTable 

85 from ..datastore.stored_file_info import StoredDatastoreItemInfo 

86 from ..registry.interfaces import ( 

87 CollectionRecord, 

88 Database, 

89 DatastoreRegistryBridgeManager, 

90 ObsCoreTableManager, 

91 ) 

92 

93 

94_LOG = logging.getLogger(__name__) 

95 

96 

97class SqlRegistry: 

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

99 

100 Parameters 

101 ---------- 

102 database : `Database` 

103 Database instance to store Registry. 

104 defaults : `RegistryDefaults` 

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

106 collection. 

107 managers : `RegistryManagerInstances` 

108 All the managers required for this registry. 

109 """ 

110 

111 defaultConfigFile: str | None = None 

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

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

114 """ 

115 

116 @classmethod 

117 def forceRegistryConfig( 

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

119 ) -> RegistryConfig: 

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

121 

122 Parameters 

123 ---------- 

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

125 Registry configuration, if missing then default configuration will 

126 be loaded from registry.yaml. 

127 

128 Returns 

129 ------- 

130 registry_config : `RegistryConfig` 

131 A registry config. 

132 """ 

133 if not isinstance(config, RegistryConfig): 

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

135 config = RegistryConfig(config) 

136 else: 

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

138 return config 

139 

140 @classmethod 

141 def createFromConfig( 

142 cls, 

143 config: RegistryConfig | str | None = None, 

144 dimensionConfig: DimensionConfig | str | None = None, 

145 butlerRoot: ResourcePathExpression | None = None, 

146 ) -> SqlRegistry: 

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

148 

149 This method initializes database contents, database must be empty 

150 prior to calling this method. 

151 

152 Parameters 

153 ---------- 

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

155 Registry configuration, if missing then default configuration will 

156 be loaded from registry.yaml. 

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

158 Dimensions configuration, if missing then default configuration 

159 will be loaded from dimensions.yaml. 

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

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

162 

163 Returns 

164 ------- 

165 registry : `SqlRegistry` 

166 A new `SqlRegistry` instance. 

167 """ 

168 config = cls.forceRegistryConfig(config) 

169 config.replaceRoot(butlerRoot) 

170 

171 if isinstance(dimensionConfig, str): 

172 dimensionConfig = DimensionConfig(dimensionConfig) 

173 elif dimensionConfig is None: 

174 dimensionConfig = DimensionConfig() 

175 elif not isinstance(dimensionConfig, DimensionConfig): 

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

177 

178 managerTypes = RegistryManagerTypes.fromConfig(config) 

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 

187 try: 

188 managers = managerTypes.makeRepo(database, dimensionConfig) 

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

190 except Exception: 

191 database.dispose() 

192 raise 

193 

194 @classmethod 

195 def fromConfig( 

196 cls, 

197 config: ButlerConfig | RegistryConfig | Config | str, 

198 butlerRoot: ResourcePathExpression | None = None, 

199 writeable: bool = True, 

200 defaults: RegistryDefaults | None = None, 

201 ) -> SqlRegistry: 

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

203 

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

205 

206 Parameters 

207 ---------- 

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

209 Registry configuration. 

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

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

212 writeable : `bool`, optional 

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

214 defaults : `RegistryDefaults`, optional 

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

216 collection. 

217 

218 Returns 

219 ------- 

220 registry : `SqlRegistry` 

221 A new `SqlRegistry` subclass instance. 

222 """ 

223 config = cls.forceRegistryConfig(config) 

224 config.replaceRoot(butlerRoot) 

225 if defaults is None: 

226 defaults = RegistryDefaults() 

227 DatabaseClass = config.getDatabaseClass() 

228 database = DatabaseClass.fromUri( 

229 config.connectionString, 

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

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

232 writeable=writeable, 

233 allow_temporary_tables=config.areTemporaryTablesAllowed, 

234 ) 

235 try: 

236 managerTypes = RegistryManagerTypes.fromConfig(config) 

237 with database.session(): 

238 managers = managerTypes.loadRepo(database) 

239 

240 return cls(database, defaults, managers) 

241 except Exception: 

242 database.dispose() 

243 raise 

244 

245 def __init__( 

246 self, 

247 database: Database, 

248 defaults: RegistryDefaults, 

249 managers: RegistryManagerInstances, 

250 ): 

251 self._db = database 

252 self._managers = managers 

253 if managers.obscore is not None: 

254 managers.obscore.set_query_function(self._query) 

255 self.storageClasses = StorageClassFactory() 

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

257 # it is intentionally not part of RegistryShim. 

258 self.dimension_record_cache = DimensionRecordCache( 

259 self._managers.dimensions.universe, 

260 fetch=self._managers.dimensions.fetch_cache_dict, 

261 ) 

262 # Intentionally invoke property setter to initialize defaults. This 

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

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

265 self.defaults = defaults 

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

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

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

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

270 self._is_clone = False 

271 

272 def close(self) -> None: 

273 # Connection pool is shared between cloned instances, so only the root 

274 # instance should close it. 

275 # Note: The underlying SQLAlchemy call will create a fresh connection 

276 # pool, so nothing breaks if the root instance is accidentally closed 

277 # before the clones are finished -- we just have a small performance 

278 # hit from re-creating the connections. 

279 if not self._is_clone: 

280 self._db.dispose() 

281 

282 def __str__(self) -> str: 

283 return str(self._db) 

284 

285 def __repr__(self) -> str: 

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

287 

288 def isWriteable(self) -> bool: 

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

290 otherwise. 

291 """ 

292 return self._db.isWriteable() 

293 

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

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

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

297 independent defaults and database sessions. 

298 

299 Parameters 

300 ---------- 

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

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

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

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

305 

306 Returns 

307 ------- 

308 copy : `SqlRegistry` 

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

310 """ 

311 if defaults is None: 

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

313 # effectively copy on write. 

314 defaults = self.defaults 

315 db = self._db.clone() 

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

317 result._datastore_record_classes = dict(self._datastore_record_classes) 

318 result.dimension_record_cache.load_from(self.dimension_record_cache) 

319 result._is_clone = True 

320 return result 

321 

322 @property 

323 def dimensions(self) -> DimensionUniverse: 

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

325 (`DimensionUniverse`). 

326 """ 

327 return self._managers.dimensions.universe 

328 

329 @property 

330 def defaults(self) -> RegistryDefaults: 

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

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

333 

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

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

336 property. 

337 """ 

338 return self._defaults 

339 

340 @defaults.setter 

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

342 if value.run is not None: 

343 self.registerRun(value.run) 

344 value.finish(self) 

345 self._defaults = value 

346 

347 def refresh(self) -> None: 

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

349 

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

351 registry instances after this one was constructed. 

352 """ 

353 self.dimension_record_cache.reset() 

354 with self._db.transaction(): 

355 self._managers.refresh() 

356 

357 def refresh_collection_summaries(self) -> None: 

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

359 

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

361 governor summaries later. 

362 """ 

363 for dataset_type in self.queryDatasetTypes(): 

364 self._managers.datasets.refresh_collection_summaries(dataset_type) 

365 

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

367 """Return context manager that enables caching. 

368 

369 Returns 

370 ------- 

371 manager 

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

373 the context returns `None`. 

374 """ 

375 return self._managers.caching_context_manager() 

376 

377 @contextlib.contextmanager 

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

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

380 

381 Parameters 

382 ---------- 

383 savepoint : `bool` 

384 Whether to issue a SAVEPOINT in the database. 

385 

386 Yields 

387 ------ 

388 `None` 

389 """ 

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

391 yield 

392 

393 def resetConnectionPool(self) -> None: 

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

395 

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

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

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

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

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

401 after the fork. 

402 """ 

403 self._db._engine.dispose() 

404 

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

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

407 other data repository client. 

408 

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

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

411 

412 Parameters 

413 ---------- 

414 tableName : `str` 

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

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

417 spec : `ddl.TableSpec` 

418 Specification for the table to be added. 

419 """ 

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

421 

422 @transactional 

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

424 """Insert records into an opaque table. 

425 

426 Parameters 

427 ---------- 

428 tableName : `str` 

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

430 previous call to `registerOpaqueTable`. 

431 *data 

432 Each additional positional argument is a dictionary that represents 

433 a single row to be added. 

434 """ 

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

436 

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

438 """Retrieve records from an opaque table. 

439 

440 Parameters 

441 ---------- 

442 tableName : `str` 

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

444 previous call to `registerOpaqueTable`. 

445 **where 

446 Additional keyword arguments are interpreted as equality 

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

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

449 must have. 

450 

451 Yields 

452 ------ 

453 row : `dict` 

454 A dictionary representing a single result row. 

455 """ 

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

457 

458 @transactional 

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

460 """Remove records from an opaque table. 

461 

462 Parameters 

463 ---------- 

464 tableName : `str` 

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

466 previous call to `registerOpaqueTable`. 

467 **where 

468 Additional keyword arguments are interpreted as equality 

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

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

471 must have. 

472 """ 

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

474 

475 def registerCollection( 

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

477 ) -> bool: 

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

479 

480 Parameters 

481 ---------- 

482 name : `str` 

483 The name of the collection to create. 

484 type : `CollectionType` 

485 Enum value indicating the type of collection to create. 

486 doc : `str`, optional 

487 Documentation string for the collection. 

488 

489 Returns 

490 ------- 

491 registered : `bool` 

492 Boolean indicating whether the collection was already registered 

493 or was created by this call. 

494 

495 Notes 

496 ----- 

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

498 able to perform its own transaction to be concurrent. 

499 """ 

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

501 return registered 

502 

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

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

505 collection. 

506 

507 Parameters 

508 ---------- 

509 name : `str` 

510 The name of the collection. 

511 

512 Returns 

513 ------- 

514 type : `CollectionType` 

515 Enum value indicating the type of this collection. 

516 

517 Raises 

518 ------ 

519 lsst.daf.butler.registry.MissingCollectionError 

520 Raised if no collection with the given name exists. 

521 """ 

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

523 

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

525 """Return the record for this collection. 

526 

527 Parameters 

528 ---------- 

529 name : `str` 

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

531 

532 Returns 

533 ------- 

534 record : `CollectionRecord` 

535 The record for this collection. 

536 """ 

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

538 

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

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

541 

542 Parameters 

543 ---------- 

544 name : `str` 

545 The name of the run to create. 

546 doc : `str`, optional 

547 Documentation string for the collection. 

548 

549 Returns 

550 ------- 

551 registered : `bool` 

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

553 if it already existed. 

554 

555 Notes 

556 ----- 

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

558 able to perform its own transaction to be concurrent. 

559 """ 

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

561 return registered 

562 

563 @transactional 

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

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

566 

567 Parameters 

568 ---------- 

569 name : `str` 

570 The name of the collection to remove. 

571 

572 Raises 

573 ------ 

574 lsst.daf.butler.registry.MissingCollectionError 

575 Raised if no collection with the given name exists. 

576 sqlalchemy.exc.IntegrityError 

577 Raised if the database rows associated with the collection are 

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

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

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

581 a child. 

582 

583 Notes 

584 ----- 

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

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

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

588 that hold them first. 

589 

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

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

592 be deleted or redefined first. 

593 """ 

594 self._managers.collections.remove(name) 

595 

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

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

598 collection. 

599 

600 Parameters 

601 ---------- 

602 parent : `str` 

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

604 a call to `Registry.registerCollection`. 

605 

606 Returns 

607 ------- 

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

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

610 given chained collection is searched. 

611 

612 Raises 

613 ------ 

614 lsst.daf.butler.registry.MissingCollectionError 

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

616 lsst.daf.butler.registry.CollectionTypeError 

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

618 `~CollectionType.CHAINED` collection. 

619 """ 

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

621 if record.type is not CollectionType.CHAINED: 

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

623 assert isinstance(record, ChainedCollectionRecord) 

624 return record.children 

625 

626 @transactional 

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

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

629 

630 Parameters 

631 ---------- 

632 parent : `str` 

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

634 a call to `Registry.registerCollection`. 

635 children : collection expression 

636 An expression defining an ordered search of child collections, 

637 generally an iterable of `str`; see 

638 :ref:`daf_butler_collection_expressions` for more information. 

639 flatten : `bool`, optional 

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

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

642 

643 Raises 

644 ------ 

645 lsst.daf.butler.registry.MissingCollectionError 

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

647 `Registry`. 

648 lsst.daf.butler.registry.CollectionTypeError 

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

650 `~CollectionType.CHAINED` collection. 

651 CollectionCycleError 

652 Raised if the given collections contains a cycle. 

653 

654 Notes 

655 ----- 

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

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

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

659 transactions short. 

660 """ 

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

662 if flatten: 

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

664 

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

666 

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

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

669 

670 Parameters 

671 ---------- 

672 collection : `str` 

673 Name of the collection. 

674 

675 Returns 

676 ------- 

677 chains : `set` of `str` 

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

679 """ 

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

681 

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

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

684 

685 Parameters 

686 ---------- 

687 collection : `str` 

688 Name of the collection. 

689 

690 Returns 

691 ------- 

692 docs : `str` or `None` 

693 Docstring for the collection with the given name. 

694 """ 

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

696 

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

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

699 

700 Parameters 

701 ---------- 

702 collection : `str` 

703 Name of the collection. 

704 doc : `str` or `None` 

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

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

707 docstring. 

708 """ 

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

710 

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

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

713 

714 Parameters 

715 ---------- 

716 collection : `str` 

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

718 

719 Returns 

720 ------- 

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

722 Summary of the dataset types and governor dimension values in 

723 this collection. 

724 """ 

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

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

727 

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

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

730 

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

732 

733 Parameters 

734 ---------- 

735 datasetType : `DatasetType` 

736 The `DatasetType` to be added. 

737 

738 Returns 

739 ------- 

740 inserted : `bool` 

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

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

743 DatasetType is guaranteed to be defined in the Registry 

744 consistently with the given definition. 

745 

746 Raises 

747 ------ 

748 ValueError 

749 Raised if the dimensions or storage class are invalid. 

750 lsst.daf.butler.registry.ConflictingDefinitionError 

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

752 definition. 

753 

754 Notes 

755 ----- 

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

757 able to perform its own transaction to be concurrent. 

758 """ 

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

760 

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

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

763 

764 .. warning:: 

765 

766 Registry implementations can cache the dataset type definitions. 

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

768 unexpected behavior from other butler processes that are active 

769 that have not seen the deletion. 

770 

771 Parameters 

772 ---------- 

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

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

775 names to be removed. Wildcards are allowed. 

776 

777 Raises 

778 ------ 

779 lsst.daf.butler.registry.OrphanedRecordError 

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

781 when there are already datasets associated with it. 

782 

783 Notes 

784 ----- 

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

786 action. 

787 """ 

788 for datasetTypeExpression in ensure_iterable(name): 

789 # Catch any warnings from the caller specifying a component 

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

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

792 # anything. 

793 with warnings.catch_warnings(): 

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

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

796 if not datasetTypes: 

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

798 else: 

799 for datasetType in datasetTypes: 

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

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

802 

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

804 """Get the `DatasetType`. 

805 

806 Parameters 

807 ---------- 

808 name : `str` 

809 Name of the type. 

810 

811 Returns 

812 ------- 

813 type : `DatasetType` 

814 The `DatasetType` associated with the given name. 

815 

816 Raises 

817 ------ 

818 lsst.daf.butler.registry.MissingDatasetTypeError 

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

820 

821 Notes 

822 ----- 

823 This method handles component dataset types automatically, though most 

824 other registry operations do not. 

825 """ 

826 parent_name, component = DatasetType.splitDatasetTypeName(name) 

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

828 if component is None: 

829 return parent_dataset_type 

830 else: 

831 return parent_dataset_type.makeComponentDatasetType(component) 

832 

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

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

835 `insertDatasets`. 

836 

837 Parameters 

838 ---------- 

839 mode : `DatasetIdGenEnum` 

840 Enum value for the mode to test. 

841 

842 Returns 

843 ------- 

844 supported : `bool` 

845 Whether the given mode is supported. 

846 """ 

847 return True 

848 

849 @transactional 

850 def insertDatasets( 

851 self, 

852 datasetType: DatasetType | str, 

853 dataIds: Iterable[DataId], 

854 run: str | None = None, 

855 expand: bool = True, 

856 idGenerationMode: DatasetIdGenEnum = DatasetIdGenEnum.UNIQUE, 

857 ) -> list[DatasetRef]: 

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

859 

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

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

862 

863 Parameters 

864 ---------- 

865 datasetType : `DatasetType` or `str` 

866 A `DatasetType` or the name of one. 

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

868 Dimension-based identifiers for the new datasets. 

869 run : `str`, optional 

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

871 ``self.defaults.run``. 

872 expand : `bool`, optional 

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

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

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

876 unnecessary. 

877 idGenerationMode : `DatasetIdGenEnum`, optional 

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

879 are generated for each inserted dataset. 

880 

881 Returns 

882 ------- 

883 refs : `list` of `DatasetRef` 

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

885 order). 

886 

887 Raises 

888 ------ 

889 lsst.daf.butler.registry.DatasetTypeError 

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

891 lsst.daf.butler.registry.CollectionTypeError 

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

893 lsst.daf.butler.registry.NoDefaultCollectionError 

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

895 lsst.daf.butler.registry.ConflictingDefinitionError 

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

897 given already exists in ``run``. 

898 lsst.daf.butler.registry.MissingCollectionError 

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

900 """ 

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

902 if run is None: 

903 if self.defaults.run is None: 

904 raise NoDefaultCollectionError( 

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

906 ) 

907 run = self.defaults.run 

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

909 if runRecord.type is not CollectionType.RUN: 

910 raise CollectionTypeError( 

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

912 ) 

913 assert isinstance(runRecord, RunRecord) 

914 

915 expandedDataIds = [ 

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

917 ] 

918 if expand: 

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

920 expandedDataIds = self.expand_data_ids(expandedDataIds) 

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

922 

923 try: 

924 refs = list( 

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

926 ) 

927 if self._managers.obscore: 

928 self._managers.obscore.add_datasets(refs) 

929 except sqlalchemy.exc.IntegrityError as err: 

930 raise ConflictingDefinitionError( 

931 "A database constraint failure was triggered by inserting " 

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

933 f"collection '{run}'. " 

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

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

936 "dimension row is missing." 

937 ) from err 

938 return refs 

939 

940 @transactional 

941 def _importDatasets( 

942 self, 

943 datasets: Iterable[DatasetRef], 

944 expand: bool = True, 

945 assume_new: bool = False, 

946 ) -> list[DatasetRef]: 

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

948 

949 This differs from `insertDatasets` method in that this method accepts 

950 `DatasetRef` instances, which already have a dataset ID. 

951 

952 Parameters 

953 ---------- 

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

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

956 identical ``run`` attributes. ``run`` 

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

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

959 inserted datasets. 

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

961 types must have the same set of dimensions. 

962 expand : `bool`, optional 

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

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

965 guarantee this is unnecessary. 

966 assume_new : `bool`, optional 

967 If `True`, assume datasets are new. If `False`, datasets that are 

968 identical to an existing one are ignored. 

969 

970 Returns 

971 ------- 

972 refs : `list` of `DatasetRef` 

973 `DatasetRef` instances for all given data IDs (in the same order). 

974 If any of ``datasets`` has an ID which already exists in the 

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

976 `DatasetRef` will be returned for it in any case. 

977 

978 Raises 

979 ------ 

980 lsst.daf.butler.registry.NoDefaultCollectionError 

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

982 lsst.daf.butler.registry.DatasetTypeError 

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

984 lsst.daf.butler.registry.ConflictingDefinitionError 

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

986 given already exists in ``run``, or if ``assume_new=True`` and at 

987 least one dataset is not new. 

988 lsst.daf.butler.registry.MissingCollectionError 

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

990 

991 Notes 

992 ----- 

993 This method is considered middleware-internal. 

994 """ 

995 datasets = list(datasets) 

996 if not datasets: 

997 # nothing to do 

998 return [] 

999 

1000 # find run name 

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

1002 if len(runs) != 1: 

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

1004 run = runs.pop() 

1005 

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

1007 if runRecord.type is not CollectionType.RUN: 

1008 raise CollectionTypeError( 

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

1010 " RUN collection required." 

1011 ) 

1012 assert isinstance(runRecord, RunRecord) 

1013 

1014 if expand: 

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

1016 datasets = self.expand_refs(datasets) 

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

1018 

1019 try: 

1020 self._managers.datasets.import_(runRecord, datasets, assume_new=assume_new) 

1021 if self._managers.obscore: 

1022 self._managers.obscore.add_datasets(datasets) 

1023 except sqlalchemy.exc.IntegrityError as err: 

1024 raise ConflictingDefinitionError( 

1025 "A database constraint failure was triggered by inserting " 

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

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

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

1029 "dimension row is missing, or the dataset was assumed to be " 

1030 "new when it was not." 

1031 ) from err 

1032 return datasets 

1033 

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

1035 """Retrieve a Dataset entry. 

1036 

1037 Parameters 

1038 ---------- 

1039 id : `DatasetId` 

1040 The unique identifier for the dataset. 

1041 

1042 Returns 

1043 ------- 

1044 ref : `DatasetRef` or `None` 

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

1046 was found. 

1047 """ 

1048 refs = self._managers.datasets.get_dataset_refs([id]) 

1049 if len(refs) == 0: 

1050 return None 

1051 else: 

1052 return refs[0] 

1053 

1054 def _fetch_run_dataset_ids(self, run: str) -> list[DatasetId]: 

1055 """Return the IDs of all datasets in the given ``RUN`` 

1056 collection. 

1057 

1058 Parameters 

1059 ---------- 

1060 run : `str` 

1061 Name of the collection. 

1062 

1063 Returns 

1064 ------- 

1065 dataset_ids : `list` [`uuid.UUID`] 

1066 List of dataset IDs. 

1067 

1068 Notes 

1069 ----- 

1070 This is a middleware-internal interface. 

1071 """ 

1072 run_record = self._managers.collections.find(run) 

1073 if not isinstance(run_record, RunRecord): 

1074 raise CollectionTypeError(f"{run!r} is not a RUN collection.") 

1075 return self._managers.datasets.fetch_run_dataset_ids(run_record) 

1076 

1077 @transactional 

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

1079 """Remove datasets from the Registry. 

1080 

1081 The datasets will be removed unconditionally from all collections. 

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

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

1084 Datastores. 

1085 

1086 Parameters 

1087 ---------- 

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

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

1090 invalidated upon return. 

1091 

1092 Raises 

1093 ------ 

1094 lsst.daf.butler.registry.OrphanedRecordError 

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

1096 """ 

1097 try: 

1098 self._managers.datasets.delete(refs) 

1099 except sqlalchemy.exc.IntegrityError as err: 

1100 raise OrphanedRecordError( 

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

1102 ) from err 

1103 

1104 @transactional 

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

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

1107 

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

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

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

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

1112 

1113 Parameters 

1114 ---------- 

1115 collection : `str` 

1116 Indicates the collection the datasets should be associated with. 

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

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

1119 in this `Registry`. 

1120 

1121 Raises 

1122 ------ 

1123 lsst.daf.butler.registry.ConflictingDefinitionError 

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

1125 given collection. 

1126 lsst.daf.butler.registry.MissingCollectionError 

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

1128 lsst.daf.butler.registry.CollectionTypeError 

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

1130 allowed. 

1131 """ 

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

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

1134 for datasetType, refsForType in progress.iter_item_chunks( 

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

1136 ): 

1137 try: 

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

1139 if self._managers.obscore: 

1140 # If a TAGGED collection is being monitored by ObsCore 

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

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

1143 except sqlalchemy.exc.IntegrityError as err: 

1144 raise ConflictingDefinitionError( 

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

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

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

1148 "that the datasets do not exist." 

1149 ) from err 

1150 

1151 @transactional 

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

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

1154 

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

1156 associated are silently ignored. 

1157 

1158 Parameters 

1159 ---------- 

1160 collection : `str` 

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

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

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

1164 in this `Registry`. 

1165 

1166 Raises 

1167 ------ 

1168 lsst.daf.butler.AmbiguousDatasetError 

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

1170 lsst.daf.butler.registry.MissingCollectionError 

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

1172 lsst.daf.butler.registry.CollectionTypeError 

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

1174 allowed. 

1175 """ 

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

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

1178 for datasetType, refsForType in progress.iter_item_chunks( 

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

1180 ): 

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

1182 if self._managers.obscore: 

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

1184 

1185 @transactional 

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

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

1188 validity range within it. 

1189 

1190 Parameters 

1191 ---------- 

1192 collection : `str` 

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

1194 collection. 

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

1196 Datasets to be associated. 

1197 timespan : `Timespan` 

1198 The validity range for these datasets within the collection. 

1199 

1200 Raises 

1201 ------ 

1202 lsst.daf.butler.AmbiguousDatasetError 

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

1204 lsst.daf.butler.registry.ConflictingDefinitionError 

1205 Raised if the collection already contains a different dataset with 

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

1207 range. 

1208 DatasetTypeError 

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

1210 CollectionTypeError 

1211 Raised if 

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

1213 """ 

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

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

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

1217 for datasetType, refsForType in progress.iter_item_chunks( 

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

1219 ): 

1220 self._managers.datasets.certify( 

1221 datasetType, collectionRecord, refsForType, timespan, self._query 

1222 ) 

1223 

1224 @transactional 

1225 def decertify( 

1226 self, 

1227 collection: str, 

1228 datasetType: str | DatasetType, 

1229 timespan: Timespan, 

1230 *, 

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

1232 ) -> None: 

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

1234 calibration collection. 

1235 

1236 Parameters 

1237 ---------- 

1238 collection : `str` 

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

1240 collection. 

1241 datasetType : `str` or `DatasetType` 

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

1243 timespan : `Timespan`, optional 

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

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

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

1247 split a single dataset validity range into two. 

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

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

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

1251 decertified. 

1252 

1253 Raises 

1254 ------ 

1255 DatasetTypeError 

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

1257 CollectionTypeError 

1258 Raised if 

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

1260 """ 

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

1262 if isinstance(datasetType, str): 

1263 datasetType = self.getDatasetType(datasetType) 

1264 standardizedDataIds = None 

1265 if dataIds is not None: 

1266 standardizedDataIds = [ 

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

1268 ] 

1269 self._managers.datasets.decertify( 

1270 datasetType, collectionRecord, timespan, data_ids=standardizedDataIds, query_func=self._query 

1271 ) 

1272 

1273 def getDatastoreBridgeManager(self) -> DatastoreRegistryBridgeManager: 

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

1275 communicate with this `Registry`. 

1276 

1277 Returns 

1278 ------- 

1279 manager : `~.interfaces.DatastoreRegistryBridgeManager` 

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

1281 associated datastores. 

1282 """ 

1283 return self._managers.datastores 

1284 

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

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

1287 

1288 Parameters 

1289 ---------- 

1290 ref : `DatasetRef` 

1291 A reference to the dataset for which to retrieve storage 

1292 information. 

1293 

1294 Returns 

1295 ------- 

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

1297 All the matching datastores holding this dataset. 

1298 

1299 Raises 

1300 ------ 

1301 lsst.daf.butler.AmbiguousDatasetError 

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

1303 """ 

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

1305 

1306 def expandDataId( 

1307 self, 

1308 dataId: DataId | None = None, 

1309 *, 

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

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

1312 withDefaults: bool = True, 

1313 **kwargs: Any, 

1314 ) -> DataCoordinate: 

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

1316 

1317 Parameters 

1318 ---------- 

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

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

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

1322 `DimensionGroup`, optional 

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

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

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

1326 is already a `DataCoordinate`. 

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

1328 optional 

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

1330 data, keyed by element name. 

1331 withDefaults : `bool`, optional 

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

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

1334 used). 

1335 **kwargs 

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

1337 ``dataId``, extending and overriding. 

1338 

1339 Returns 

1340 ------- 

1341 expanded : `DataCoordinate` 

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

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

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

1345 

1346 Raises 

1347 ------ 

1348 lsst.daf.butler.registry.DataIdError 

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

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

1351 contradictory key-value pairs, according to dimension 

1352 relationships. 

1353 

1354 Notes 

1355 ----- 

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

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

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

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

1360 directly rather than obtained from the registry database. 

1361 """ 

1362 if not withDefaults: 

1363 defaults = None 

1364 else: 

1365 defaults = self.defaults.dataId 

1366 standardized = DataCoordinate.standardize( 

1367 dataId, 

1368 dimensions=dimensions, 

1369 universe=self.dimensions, 

1370 defaults=defaults, 

1371 **kwargs, 

1372 ) 

1373 if standardized.hasRecords(): 

1374 return standardized 

1375 if records is None: 

1376 records = {} 

1377 else: 

1378 records = dict(records) 

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

1380 for element_name in dataId.dimensions.elements: 

1381 records[element_name] = dataId.records[element_name] 

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

1383 for element_name in standardized.dimensions.lookup_order: 

1384 element = self.dimensions[element_name] 

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

1386 if record is ...: 

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

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

1389 else: 

1390 record = self._managers.dimensions.fetch_one( 

1391 element_name, 

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

1393 self.dimension_record_cache, 

1394 ) 

1395 records[element_name] = record 

1396 if record is not None: 

1397 for d in element.implied: 

1398 value = getattr(record, d.name) 

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

1400 raise InconsistentDataIdError( 

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

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

1403 ) 

1404 else: 

1405 if element_name in standardized.dimensions.names: 

1406 raise DataIdValueError( 

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

1408 ) 

1409 if element.defines_relationships: 

1410 raise InconsistentDataIdError( 

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

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

1413 "have inconsistent values.", 

1414 ) 

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

1416 

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

1418 output = list(data_ids) 

1419 

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

1421 for i, data_id in enumerate(data_ids): 

1422 if not data_id.hasRecords(): 

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

1424 

1425 if not grouped_by_dimensions: 

1426 # All given DataCoordinate values are already expanded. 

1427 return output 

1428 

1429 attacher = DimensionDataAttacher( 

1430 cache=self.dimension_record_cache, 

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

1432 ) 

1433 with self._query() as query: 

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

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

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

1437 output[index] = data_id 

1438 

1439 return output 

1440 

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

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

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

1444 

1445 def insertDimensionData( 

1446 self, 

1447 element: DimensionElement | str, 

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

1449 conform: bool = True, 

1450 replace: bool = False, 

1451 skip_existing: bool = False, 

1452 ) -> None: 

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

1454 

1455 Parameters 

1456 ---------- 

1457 element : `DimensionElement` or `str` 

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

1459 records will be inserted into. 

1460 *data : `dict` or `DimensionRecord` 

1461 One or more records to insert. 

1462 conform : `bool`, optional 

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

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

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

1466 appropriate subclass. 

1467 replace : `bool`, optional 

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

1469 database if there is a conflict. 

1470 skip_existing : `bool`, optional 

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

1472 the same primary key values already exists. Unlike 

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

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

1475 this is a concern. 

1476 """ 

1477 if isinstance(element, str): 

1478 element = self.dimensions[element] 

1479 if conform: 

1480 records = [ 

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

1482 ] 

1483 else: 

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

1485 records = data # type: ignore 

1486 if element.name in self.dimension_record_cache: 

1487 self.dimension_record_cache.reset() 

1488 self._managers.dimensions.insert( 

1489 element, 

1490 *records, 

1491 replace=replace, 

1492 skip_existing=skip_existing, 

1493 ) 

1494 

1495 def syncDimensionData( 

1496 self, 

1497 element: DimensionElement | str, 

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

1499 conform: bool = True, 

1500 update: bool = False, 

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

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

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

1504 

1505 Parameters 

1506 ---------- 

1507 element : `DimensionElement` or `str` 

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

1509 records will be inserted into. 

1510 row : `dict` or `DimensionRecord` 

1511 The record to insert. 

1512 conform : `bool`, optional 

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

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

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

1516 subclass. 

1517 update : `bool`, optional 

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

1519 database if there is a conflict. 

1520 

1521 Returns 

1522 ------- 

1523 inserted_or_updated : `bool` or `dict` 

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

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

1526 values if an update was performed (only possible if 

1527 ``update=True``). 

1528 

1529 Raises 

1530 ------ 

1531 lsst.daf.butler.registry.ConflictingDefinitionError 

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

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

1534 """ 

1535 if conform: 

1536 if isinstance(element, str): 

1537 element = self.dimensions[element] 

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

1539 else: 

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

1541 record = row # type: ignore 

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

1543 self.dimension_record_cache.reset() 

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

1545 

1546 def queryDatasetTypes( 

1547 self, 

1548 expression: Any = ..., 

1549 *, 

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

1551 ) -> Iterable[DatasetType]: 

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

1553 

1554 Parameters 

1555 ---------- 

1556 expression : dataset type expression, optional 

1557 An expression that fully or partially identifies the dataset types 

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

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

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

1561 information. 

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

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

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

1565 list, if it is provided. 

1566 

1567 Returns 

1568 ------- 

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

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

1571 names match ``expression``. 

1572 

1573 Raises 

1574 ------ 

1575 lsst.daf.butler.registry.DatasetTypeExpressionError 

1576 Raised when ``expression`` is invalid. 

1577 """ 

1578 wildcard = DatasetTypeWildcard.from_expression(expression) 

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

1580 

1581 def queryCollections( 

1582 self, 

1583 expression: Any = ..., 

1584 datasetType: DatasetType | None = None, 

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

1586 flattenChains: bool = False, 

1587 includeChains: bool | None = None, 

1588 ) -> Sequence[str]: 

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

1590 

1591 Parameters 

1592 ---------- 

1593 expression : collection expression, optional 

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

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

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

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

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

1599 datasetType : `DatasetType`, optional 

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

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

1602 yield collections that do not have any such datasets. 

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

1604 `CollectionType`, optional 

1605 If provided, only yield collections of these types. 

1606 flattenChains : `bool`, optional 

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

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

1609 includeChains : `bool`, optional 

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

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

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

1613 

1614 Returns 

1615 ------- 

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

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

1618 

1619 Raises 

1620 ------ 

1621 lsst.daf.butler.registry.CollectionExpressionError 

1622 Raised when ``expression`` is invalid. 

1623 

1624 Notes 

1625 ----- 

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

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

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

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

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

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

1632 """ 

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

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

1635 # ticket will take care of that. 

1636 if datasetType is not None: 

1637 warnings.warn( 

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

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

1640 FutureWarning, 

1641 ) 

1642 try: 

1643 wildcard = CollectionWildcard.from_expression(expression) 

1644 except TypeError as exc: 

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

1646 collectionTypes = ensure_iterable(collectionTypes) 

1647 return [ 

1648 record.name 

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

1650 wildcard, 

1651 collection_types=frozenset(collectionTypes), 

1652 flatten_chains=flattenChains, 

1653 include_chains=includeChains, 

1654 ) 

1655 ] 

1656 

1657 @contextlib.contextmanager 

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

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

1660 and execution of complex queries. 

1661 """ 

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

1663 yield Query(driver) 

1664 

1665 @contextlib.contextmanager 

1666 def _query_driver( 

1667 self, 

1668 default_collections: Iterable[str], 

1669 default_data_id: DataCoordinate, 

1670 ) -> Iterator[DirectQueryDriver]: 

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

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

1673 # benefits from the collection record cache. 

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

1675 driver = DirectQueryDriver( 

1676 self._db, 

1677 self.dimensions, 

1678 self._managers, 

1679 self.dimension_record_cache, 

1680 default_collections=default_collections, 

1681 default_data_id=default_data_id, 

1682 ) 

1683 with driver: 

1684 yield driver 

1685 

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

1687 """Retrieve datastore records for given ref. 

1688 

1689 Parameters 

1690 ---------- 

1691 ref : `DatasetRef` 

1692 Dataset reference for which to retrieve its corresponding datastore 

1693 records. 

1694 

1695 Returns 

1696 ------- 

1697 updated_ref : `DatasetRef` 

1698 Dataset reference with filled datastore records. 

1699 

1700 Notes 

1701 ----- 

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

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

1704 """ 

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

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

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

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

1709 return ref.replace(datastore_records=datastore_records) 

1710 

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

1712 """Store datastore records for given refs. 

1713 

1714 Parameters 

1715 ---------- 

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

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

1718 datastore, reference must include datastore records. 

1719 """ 

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

1721 # Store ref IDs in the bridge table. 

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

1723 bridge.insert([ref]) 

1724 

1725 # store records in opaque tables 

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

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

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

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

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

1731 

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

1733 """Create opaque tables used by datastores. 

1734 

1735 Parameters 

1736 ---------- 

1737 tables : `~collections.abc.Mapping` 

1738 Maps opaque table name to its definition. 

1739 

1740 Notes 

1741 ----- 

1742 This method should disappear in the future when opaque table 

1743 definitions will be provided during `Registry` construction. 

1744 """ 

1745 datastore_record_classes = {} 

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

1747 datastore_record_classes[table_name] = table_def.record_class 

1748 try: 

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

1750 except ReadOnlyDatabaseError: 

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

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

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

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

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

1756 # just validating configuration. 

1757 pass 

1758 self._datastore_record_classes = datastore_record_classes 

1759 

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

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

1762 

1763 Parameters 

1764 ---------- 

1765 load_dimension_record_cache : `bool` 

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

1767 preloaded, subsequent external changes to governor dimension 

1768 records will not be visible to this Butler. 

1769 """ 

1770 self._managers.datasets.preload_cache() 

1771 

1772 if load_dimension_record_cache: 

1773 self.dimension_record_cache.preload_cache() 

1774 

1775 @property 

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

1777 """The ObsCore manager instance for this registry 

1778 (`~.interfaces.ObsCoreTableManager` 

1779 or `None`). 

1780 

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

1782 may not be enabled for many repositories. 

1783 """ 

1784 return self._managers.obscore 

1785 

1786 storageClasses: StorageClassFactory 

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

1788 """ 

1789 

1790 _defaults: RegistryDefaults 

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