Coverage for python/lsst/daf/butler/dimensions/_universe.py: 39%

178 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-05-29 08:15 +0000

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

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

7# for details of code ownership. 

8# 

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

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

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

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

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

14# 

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

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

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

18# (at your option) any later version. 

19# 

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

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

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

23# GNU General Public License for more details. 

24# 

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

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

27 

28from __future__ import annotations 

29 

30__all__ = ["DimensionUniverse"] 

31 

32import logging 

33import pickle 

34import warnings 

35from collections import defaultdict 

36from collections.abc import Iterable, Mapping, Sequence 

37from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, overload 

38 

39from lsst.utils.classes import cached_getter, immutable 

40 

41from .._config import Config 

42from .._exceptions import InconsistentUniverseError 

43from .._named import NamedValueAbstractSet, NamedValueSet 

44from .._topology import TopologicalFamily, TopologicalSpace 

45from .._utilities.thread_safe_cache import ThreadSafeCache 

46from ._config import _DEFAULT_NAMESPACE, DimensionConfig 

47from ._database import DatabaseDimensionElement 

48from ._elements import Dimension, DimensionElement 

49from ._governor import GovernorDimension 

50from ._group import DimensionGroup 

51from ._skypix import SkyPixDimension, SkyPixSystem 

52 

53if TYPE_CHECKING: # Imports needed only for type annotations; may be circular. 

54 from .construction import DimensionConstructionBuilder 

55 

56 

57E = TypeVar("E", bound=DimensionElement) 

58_LOG = logging.getLogger(__name__) 

59 

60 

61@immutable 

62class DimensionUniverse: # numpydoc ignore=PR02 

63 """Self-consistent set of dimensions. 

64 

65 A parent class that represents a complete, self-consistent set of 

66 dimensions and their relationships. 

67 

68 `DimensionUniverse` is not a class-level singleton, but all instances are 

69 tracked in a singleton map keyed by the version number and namespace 

70 in the configuration they were loaded from. Because these universes 

71 are solely responsible for constructing `DimensionElement` instances, 

72 these are also indirectly tracked by that singleton as well. 

73 

74 Parameters 

75 ---------- 

76 config : `Config`, optional 

77 Configuration object from which dimension definitions can be extracted. 

78 Ignored if ``builder`` is provided, or if ``version`` is provided and 

79 an instance with that version already exists. 

80 version : `int`, optional 

81 Integer version for this `DimensionUniverse`. If not provided, a 

82 version will be obtained from ``builder`` or ``config``. 

83 namespace : `str`, optional 

84 Namespace of this `DimensionUniverse`, combined with the version 

85 to provide universe safety for registries that use different 

86 dimension definitions. 

87 builder : `DimensionConstructionBuilder`, optional 

88 Builder object used to initialize a new instance. Ignored if 

89 ``version`` is provided and an instance with that version already 

90 exists. Should not have had `~DimensionConstructionBuilder.finish` 

91 called; this will be called if needed by `DimensionUniverse`. 

92 use_cache : `bool`, optional 

93 If `True` or not provided, cache `DimensionUniverse` instances globally 

94 to avoid creating more than one `DimensionUniverse` instance for a 

95 given configuration. 

96 """ 

97 

98 _instances: ClassVar[ThreadSafeCache[tuple[int, str], DimensionUniverse]] = ThreadSafeCache() 

99 """Singleton dictionary of all instances, keyed by version. 

100 

101 For internal use only. 

102 """ 

103 

104 def __new__( 

105 cls, 

106 config: Config | None = None, 

107 *, 

108 version: int | None = None, 

109 namespace: str | None = None, 

110 builder: DimensionConstructionBuilder | None = None, 

111 use_cache: bool = True, 

112 ) -> DimensionUniverse: 

113 # Try to get a version first, to look for existing instances; try to 

114 # do as little work as possible at this stage. 

115 if version is None: 

116 if builder is None: 

117 config = DimensionConfig(config) 

118 version = config["version"] 

119 else: 

120 version = builder.version 

121 

122 if use_cache is not True: 

123 warnings.warn( 

124 "use_cache parameter is no longer supported and is ignored. Will be removed after v30.", 

125 category=FutureWarning, 

126 stacklevel=2, 

127 ) 

128 

129 # Then a namespace. 

130 if namespace is None: 

131 if builder is None: 

132 config = DimensionConfig(config) 

133 namespace = config.get("namespace", _DEFAULT_NAMESPACE) 

134 else: 

135 namespace = builder.namespace 

136 # if still None use the default 

137 if namespace is None: 

138 namespace = _DEFAULT_NAMESPACE 

139 

140 # See if an equivalent instance already exists. 

141 existing_instance = cls._instances.get((version, namespace)) 

142 if existing_instance is not None: 

143 return existing_instance 

144 

145 # Ensure we have a builder, building one from config if necessary. 

146 if builder is None: 

147 config = DimensionConfig(config) 

148 builder = config.makeBuilder() 

149 

150 # Delegate to the builder for most of the construction work. 

151 builder.finish() 

152 

153 # Create the universe instance and create core attributes, mostly 

154 # copying from builder. 

155 self: DimensionUniverse = object.__new__(cls) 

156 assert self is not None 

157 self._cached_groups = ThreadSafeCache() 

158 self._dimensions = builder.dimensions 

159 self._elements = builder.elements 

160 self._topology = builder.topology 

161 self.dimensionConfig = builder.config 

162 commonSkyPix = self._dimensions[builder.commonSkyPixName] 

163 assert isinstance(commonSkyPix, SkyPixDimension) 

164 self.commonSkyPix = commonSkyPix 

165 

166 # Attach self to all elements. 

167 for element in self._elements: 

168 element.universe = self 

169 

170 # Add attribute for special subsets of the graph. 

171 self._empty = DimensionGroup(self, (), _conform=False) 

172 

173 # Use the version number and namespace from the config as a key in 

174 # the singleton dict containing all instances; that will let us 

175 # transfer dimension objects between processes using pickle without 

176 # actually going through real initialization, as long as a universe 

177 # with the same version and namespace has already been constructed in 

178 # the receiving process. 

179 self._version = version 

180 self._namespace = namespace 

181 

182 # Build mappings from element to index. These are used for 

183 # topological-sort comparison operators in DimensionElement itself. 

184 self._elementIndices = {name: i for i, name in enumerate(self._elements.names)} 

185 # Same for dimension to index, sorted topologically across required 

186 # and implied. This is used for encode/decode. 

187 self._dimensionIndices = {name: i for i, name in enumerate(self._dimensions.names)} 

188 

189 self._populates = defaultdict(NamedValueSet) 

190 for element in self._elements: 

191 if element.populated_by is not None: 

192 self._populates[element.populated_by.name].add(element) 

193 

194 return cls._instances.set_or_get((self._version, self._namespace), self) 

195 

196 @property 

197 def version(self) -> int: 

198 """The version number of this universe. 

199 

200 Returns 

201 ------- 

202 version : `int` 

203 An integer representing the version number of this universe. 

204 Uniquely defined when combined with the `namespace`. 

205 """ 

206 return self._version 

207 

208 @property 

209 def namespace(self) -> str: 

210 """The namespace associated with this universe. 

211 

212 Returns 

213 ------- 

214 namespace : `str` 

215 The namespace. When combined with the `version` can uniquely 

216 define this universe. 

217 """ 

218 return self._namespace 

219 

220 def isCompatibleWith(self, other: DimensionUniverse) -> bool: 

221 """Check compatibility between this `DimensionUniverse` and another. 

222 

223 Parameters 

224 ---------- 

225 other : `DimensionUniverse` 

226 The other `DimensionUniverse` to check for compatibility. 

227 

228 Returns 

229 ------- 

230 results : `bool` 

231 If the other `DimensionUniverse` is compatible with this one return 

232 `True`, else `False`. 

233 """ 

234 # Different namespaces mean that these universes cannot be compatible. 

235 if self.namespace != other.namespace: 

236 return False 

237 if self.version != other.version: 

238 _LOG.info( 

239 "Universes share a namespace %r but have differing versions (%d != %d). " 

240 " This could be okay but may be responsible for dimension errors later.", 

241 self.namespace, 

242 self.version, 

243 other.version, 

244 ) 

245 

246 # For now assume compatibility if versions differ. 

247 return True 

248 

249 def __repr__(self) -> str: 

250 return f"DimensionUniverse({self._version}, {self._namespace})" 

251 

252 def __getitem__(self, name: str) -> DimensionElement: 

253 return self._elements[name] 

254 

255 def __contains__(self, name: Any) -> bool: 

256 return name in self._elements 

257 

258 def get(self, name: str, default: DimensionElement | None = None) -> DimensionElement | None: 

259 """Return the `DimensionElement` with the given name or a default. 

260 

261 Parameters 

262 ---------- 

263 name : `str` 

264 Name of the element. 

265 default : `DimensionElement`, optional 

266 Element to return if the named one does not exist. Defaults to 

267 `None`. 

268 

269 Returns 

270 ------- 

271 element : `DimensionElement` 

272 The named element. 

273 """ 

274 return self._elements.get(name, default) 

275 

276 def getStaticElements(self) -> NamedValueAbstractSet[DimensionElement]: 

277 """Return a set of all static elements in this universe. 

278 

279 Non-static elements that are created as needed may also exist, but 

280 these are guaranteed to have no direct relationships to other elements 

281 (though they may have spatial or temporal relationships). 

282 

283 Returns 

284 ------- 

285 elements : `NamedValueAbstractSet` [ `DimensionElement` ] 

286 A frozen set of `DimensionElement` instances. 

287 """ 

288 return self._elements 

289 

290 def getStaticDimensions(self) -> NamedValueAbstractSet[Dimension]: 

291 """Return a set of all static dimensions in this universe. 

292 

293 Non-static dimensions that are created as needed may also exist, but 

294 these are guaranteed to have no direct relationships to other elements 

295 (though they may have spatial or temporal relationships). 

296 

297 Returns 

298 ------- 

299 dimensions : `NamedValueAbstractSet` [ `Dimension` ] 

300 A frozen set of `Dimension` instances. 

301 """ 

302 return self._dimensions 

303 

304 def getGovernorDimensions(self) -> NamedValueAbstractSet[GovernorDimension]: 

305 """Return a set of all `GovernorDimension` instances in this universe. 

306 

307 Returns 

308 ------- 

309 governors : `NamedValueAbstractSet` [ `GovernorDimension` ] 

310 A frozen set of `GovernorDimension` instances. 

311 """ 

312 return self.governor_dimensions 

313 

314 def getDatabaseElements(self) -> NamedValueAbstractSet[DatabaseDimensionElement]: 

315 """Return set of all `DatabaseDimensionElement` instances in universe. 

316 

317 This does not include `GovernorDimension` instances, which are backed 

318 by the database but do not inherit from `DatabaseDimensionElement`. 

319 

320 Returns 

321 ------- 

322 elements : `NamedValueAbstractSet` [ `DatabaseDimensionElement` ] 

323 A frozen set of `DatabaseDimensionElement` instances. 

324 """ 

325 return self.database_elements 

326 

327 @property 

328 def elements(self) -> NamedValueAbstractSet[DimensionElement]: 

329 """All dimension elements defined in this universe.""" 

330 return self._elements 

331 

332 @property 

333 def dimensions(self) -> NamedValueAbstractSet[Dimension]: 

334 """All dimensions defined in this universe.""" 

335 return self._dimensions 

336 

337 @property 

338 @cached_getter 

339 def governor_dimensions(self) -> NamedValueAbstractSet[GovernorDimension]: 

340 """All governor dimensions defined in this universe. 

341 

342 Governor dimensions serve as special required dependencies of other 

343 dimensions, with special handling in dimension query expressions and 

344 collection summaries. Governor dimension records are stored in the 

345 database but the set of such values is expected to be small enough 

346 for all values to be cached by all clients. 

347 """ 

348 return NamedValueSet(d for d in self._dimensions if isinstance(d, GovernorDimension)).freeze() 

349 

350 @property 

351 @cached_getter 

352 def skypix_dimensions(self) -> NamedValueAbstractSet[SkyPixDimension]: 

353 """All skypix dimensions defined in this universe. 

354 

355 Skypix dimension records are always generated on-the-fly rather than 

356 stored in the database, and they always represent a tiling of the sky 

357 with no overlaps. 

358 """ 

359 result = NamedValueSet[SkyPixDimension]() 

360 for system in self.skypix: 

361 result.update(system) 

362 return result.freeze() 

363 

364 @property 

365 @cached_getter 

366 def database_elements(self) -> NamedValueAbstractSet[DatabaseDimensionElement]: 

367 """All dimension elements whose records are stored in the database, 

368 except governor dimensions. 

369 """ 

370 return NamedValueSet(d for d in self._elements if isinstance(d, DatabaseDimensionElement)).freeze() 

371 

372 @property 

373 @cached_getter 

374 def skypix(self) -> NamedValueAbstractSet[SkyPixSystem]: 

375 """All skypix systems known to this universe. 

376 

377 (`NamedValueAbstractSet` [ `SkyPixSystem` ]). 

378 """ 

379 return NamedValueSet( 

380 [ 

381 family 

382 for family in self._topology[TopologicalSpace.SPATIAL] 

383 if isinstance(family, SkyPixSystem) 

384 ] 

385 ).freeze() 

386 

387 def getElementIndex(self, name: str) -> int: 

388 """Return the position of the named dimension element. 

389 

390 The position is in this universe's sorting of all elements. 

391 

392 Parameters 

393 ---------- 

394 name : `str` 

395 Name of the element. 

396 

397 Returns 

398 ------- 

399 index : `int` 

400 Sorting index for this element. 

401 """ 

402 return self._elementIndices[name] 

403 

404 def getDimensionIndex(self, name: str) -> int: 

405 """Return the position of the named dimension. 

406 

407 This position is in this universe's sorting of all dimensions. 

408 

409 Parameters 

410 ---------- 

411 name : `str` 

412 Name of the dimension. 

413 

414 Returns 

415 ------- 

416 index : `int` 

417 Sorting index for this dimension. 

418 

419 Notes 

420 ----- 

421 The dimension sort order for a universe is consistent with the element 

422 order (all dimensions are elements), and either can be used to sort 

423 dimensions if used consistently. But there are also some contexts in 

424 which contiguous dimension-only indices are necessary or at least 

425 desirable. 

426 """ 

427 return self._dimensionIndices[name] 

428 

429 def conform( 

430 self, 

431 dimensions: Iterable[str] | str | DimensionGroup, 

432 /, 

433 ) -> DimensionGroup: 

434 """Construct a dimension group from an iterable of dimension names. 

435 

436 Parameters 

437 ---------- 

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

439 `DimensionGroup` 

440 Dimensions that must be included in the returned group; their 

441 dependencies will be as well. 

442 

443 Returns 

444 ------- 

445 group : `DimensionGroup` 

446 A `DimensionGroup` instance containing all given dimensions. 

447 """ 

448 match dimensions: 

449 case DimensionGroup(): 

450 if dimensions.universe is not self: 

451 raise InconsistentUniverseError( 

452 f"DimensionGroup {dimensions} belongs to a different universe." 

453 f" Expected universe {self}, but got {dimensions.universe}." 

454 ) 

455 return dimensions 

456 case str() as name: 

457 return self[name].minimal_group 

458 case iterable: 

459 return DimensionGroup(self, set(iterable)) 

460 

461 @overload 

462 def sorted(self, elements: Iterable[Dimension], *, reverse: bool = False) -> Sequence[Dimension]: ... 462 ↛ exitline 462 didn't return from function 'sorted' because

463 

464 @overload 

465 def sorted( 465 ↛ exitline 465 didn't return from function 'sorted' because

466 self, elements: Iterable[DimensionElement | str], *, reverse: bool = False 

467 ) -> Sequence[DimensionElement]: ... 

468 

469 def sorted(self, elements: Iterable[Any], *, reverse: bool = False) -> list[Any]: 

470 """Return a sorted version of the given iterable of dimension elements. 

471 

472 The universe's sort order is topological (an element's dependencies 

473 precede it), with an unspecified (but deterministic) approach to 

474 breaking ties. 

475 

476 Parameters 

477 ---------- 

478 elements : `~collections.abc.Iterable` of `DimensionElement` 

479 Elements to be sorted. 

480 reverse : `bool`, optional 

481 If `True`, sort in the opposite order. 

482 

483 Returns 

484 ------- 

485 sorted : `~collections.abc.Sequence` [ `Dimension` or \ 

486 `DimensionElement` ] 

487 A sorted sequence containing the same elements that were given. 

488 """ 

489 s = set(elements) 

490 result = [element for element in self._elements if element in s or element.name in s] 

491 if reverse: 

492 result.reverse() 

493 return result 

494 

495 def get_elements_populated_by(self, dimension: Dimension) -> NamedValueAbstractSet[DimensionElement]: 

496 """Return the set of `DimensionElement` objects whose 

497 `~DimensionElement.populated_by` attribute is the given dimension. 

498 

499 Parameters 

500 ---------- 

501 dimension : `Dimension` 

502 The dimension of interest. 

503 

504 Returns 

505 ------- 

506 populated_by : `NamedValueAbstractSet` [ `DimensionElement` ] 

507 The set of elements who say they are populated by the given 

508 dimension. 

509 """ 

510 return self._populates[dimension.name] 

511 

512 @property 

513 def empty(self) -> DimensionGroup: 

514 """The `DimensionGroup` that contains no dimensions.""" 

515 return self._empty 

516 

517 @classmethod 

518 def _unpickle(cls, version: int, namespace: str | None = None) -> DimensionUniverse: 

519 """Return an unpickled dimension universe. 

520 

521 Callable used for unpickling. 

522 

523 For internal use only. 

524 """ 

525 if namespace is None: 

526 # Old pickled universe. 

527 namespace = _DEFAULT_NAMESPACE 

528 instance = cls._instances.get((version, namespace)) 

529 if instance is None: 

530 raise pickle.UnpicklingError( 

531 f"DimensionUniverse with version '{version}' and namespace {namespace!r} " 

532 "not found. Note that DimensionUniverse objects are not " 

533 "truly serialized; when using pickle to transfer them " 

534 "between processes, an equivalent instance with the same " 

535 "version must already exist in the receiving process." 

536 ) 

537 return instance 

538 

539 def __reduce__(self) -> tuple: 

540 return (self._unpickle, (self._version, self._namespace)) 

541 

542 def __deepcopy__(self, memo: dict) -> DimensionUniverse: 

543 # DimensionUniverse is recursively immutable; see note in @immutable 

544 # decorator. 

545 return self 

546 

547 # Class attributes below are shadowed by instance attributes, and are 

548 # present just to hold the docstrings for those instance attributes. 

549 

550 commonSkyPix: SkyPixDimension 

551 """The special skypix dimension that is used to relate all other spatial 

552 dimensions in the `Registry` database (`SkyPixDimension`). 

553 """ 

554 

555 dimensionConfig: DimensionConfig 

556 """The configuration used to create this Universe (`DimensionConfig`).""" 

557 

558 _cached_groups: ThreadSafeCache[frozenset[str], DimensionGroup] 

559 

560 _dimensions: NamedValueAbstractSet[Dimension] 

561 

562 _elements: NamedValueAbstractSet[DimensionElement] 

563 

564 _empty: DimensionGroup 

565 

566 _topology: Mapping[TopologicalSpace, NamedValueAbstractSet[TopologicalFamily]] 

567 

568 _dimensionIndices: dict[str, int] 

569 

570 _elementIndices: dict[str, int] 

571 

572 _populates: defaultdict[str, NamedValueSet[DimensionElement]] 

573 

574 _version: int 

575 

576 _namespace: str