Coverage for python / lsst / daf / butler / dimensions / _coordinate.py: 34%

389 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-29 01:27 -0800

1# This file is part of daf_butler. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

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

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

7# for details of code ownership. 

8# 

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

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

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

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

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

14# 

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

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

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

18# (at your option) any later version. 

19# 

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

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

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

23# GNU General Public License for more details. 

24# 

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

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

27 

28# 

29# Design notes for this module are in 

30# doc/lsst.daf.butler/dev/dataCoordinate.py. 

31# 

32 

33from __future__ import annotations 

34 

35__all__ = ( 

36 "DataCoordinate", 

37 "DataId", 

38 "SerializedDataCoordinate", 

39 "SerializedDataId", 

40) 

41 

42import numbers 

43from abc import abstractmethod 

44from collections.abc import Callable, Iterable, Iterator, Mapping 

45from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeAlias, cast, overload 

46 

47import pydantic 

48 

49from lsst.sphgeom import IntersectionRegion, Region 

50 

51from .._exceptions import DimensionNameError 

52from .._timespan import Timespan 

53from ..json import from_json_pydantic, to_json_pydantic 

54from ..persistence_context import PersistenceContextVars 

55from ._group import DimensionGroup 

56from ._records import DataIdValue, DimensionRecord, SerializedDimensionRecord 

57 

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

59 from ..registry import Registry 

60 from ._universe import DimensionUniverse 

61 

62SerializedDataId: TypeAlias = dict[str, DataIdValue] 

63"""Simplified model for serializing the ``mapping`` property of 

64`DataCoordinate`. 

65""" 

66 

67 

68class SerializedDataCoordinate(pydantic.BaseModel): 

69 """Simplified model for serializing a `DataCoordinate`.""" 

70 

71 dataId: SerializedDataId 

72 records: dict[str, SerializedDimensionRecord] | None = None 

73 

74 @classmethod 

75 def direct(cls, *, dataId: SerializedDataId, records: dict[str, dict] | None) -> SerializedDataCoordinate: 

76 """Construct a `SerializedDataCoordinate` directly without validators. 

77 

78 Parameters 

79 ---------- 

80 dataId : `SerializedDataId` 

81 The data ID. 

82 records : `dict` or `None` 

83 The dimension records. 

84 

85 Notes 

86 ----- 

87 This differs from the pydantic "construct" method in that the arguments 

88 are explicitly what the model requires, and it will recurse through 

89 members, constructing them from their corresponding `direct` methods. 

90 

91 This method should only be called when the inputs are trusted. 

92 """ 

93 key = (frozenset(dataId.items()), records is not None) 

94 cache = PersistenceContextVars.serializedDataCoordinateMapping.get() 

95 if cache is not None and (result := cache.get(key)) is not None: 

96 return result 

97 

98 if records is None: 

99 serialized_records = None 

100 else: 

101 serialized_records = {k: SerializedDimensionRecord.direct(**v) for k, v in records.items()} 

102 

103 node = cls.model_construct(dataId=dataId, records=serialized_records) 

104 

105 if cache is not None: 

106 cache[key] = node 

107 return node 

108 

109 

110def _intersectRegions(*args: Region) -> Region | None: 

111 """Return the intersection of several regions. 

112 

113 For internal use by `ExpandedDataCoordinate` only. 

114 

115 If no regions are provided, returns `None`. 

116 """ 

117 if len(args) == 0: 

118 return None 

119 else: 

120 result = args[0] 

121 for n in range(1, len(args)): 

122 result = IntersectionRegion(result, args[n]) 

123 return result 

124 

125 

126class DataCoordinate: 

127 """A validated data ID. 

128 

129 DataCoordinate guarantees that its key-value pairs identify at least all 

130 required dimensions in a `DimensionGroup`. 

131 

132 Notes 

133 ----- 

134 `DataCoordinate` is an ABC, but it provides `staticmethod` factory 

135 functions for private concrete implementations that should be sufficient 

136 for most purposes. `standardize` is the most flexible and safe of these; 

137 the others (`make_empty`, `from_required_values`, and `from_full_values`) 

138 are more specialized and perform little or no checking of inputs. 

139 

140 Lookups for implied dimensions (those in ``self.dimensions.implied``) are 

141 supported if and only if `has_full_values` is `True`. This also sets the 

142 keys of the `mapping` attribute. This means that `DataCoordinate` equality 

143 is not the same as testing for equality on the `mapping` attribute 

144 (instead, it is the same as testing for equality on the `required` 

145 attribute). 

146 

147 See also :ref:`lsst.daf.butler-dimensions_data_ids` 

148 """ 

149 

150 __slots__ = () 

151 

152 _serializedType: ClassVar[type[pydantic.BaseModel]] = SerializedDataCoordinate 

153 

154 @staticmethod 

155 def standardize( 

156 mapping: Mapping[str, DataIdValue] | DataCoordinate | None = None, 

157 *, 

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

159 universe: DimensionUniverse | None = None, 

160 defaults: DataCoordinate | None = None, 

161 **kwargs: Any, 

162 ) -> DataCoordinate: 

163 """Standardize the supplied dataId. 

164 

165 Adapts an arbitrary mapping and/or additional arguments into a true 

166 `DataCoordinate`, or augment an existing one. 

167 

168 Parameters 

169 ---------- 

170 mapping : `~collections.abc.Mapping` or `DataCoordinate`, optional 

171 An informal data ID that maps dimensions or dimension names to 

172 their primary key values (may also be a true `DataCoordinate`). 

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

174 optional 

175 The dimensions to be identified by the new `DataCoordinate`. If not 

176 provided, will be inferred from the keys of ``mapping`` and 

177 ``**kwargs``, and ``universe`` must be provided unless ``mapping`` 

178 is already a `DataCoordinate`. 

179 universe : `DimensionUniverse` 

180 All known dimensions and their relationships; used to expand and 

181 validate dependencies when ``dimensions`` is not provided. 

182 defaults : `DataCoordinate`, optional 

183 Default dimension key-value pairs to use when needed. These are 

184 never used to infer ``group``, and are ignored if a different value 

185 is provided for the same key in ``mapping`` or `**kwargs``. 

186 **kwargs 

187 Additional keyword arguments are treated like additional key-value 

188 pairs in ``mapping``. 

189 

190 Returns 

191 ------- 

192 coordinate : `DataCoordinate` 

193 A validated `DataCoordinate` instance. 

194 

195 Raises 

196 ------ 

197 TypeError 

198 Raised if the set of optional arguments provided is not supported. 

199 DimensionNameError 

200 Raised if a key-value pair for a required dimension is missing. 

201 """ 

202 universe = universe or getattr(dimensions, "universe", None) or getattr(mapping, "universe", None) 

203 if universe is None: 

204 raise TypeError("universe must be provided, either directly or via dimensions or mapping.") 

205 if dimensions is not None: 

206 dimensions = universe.conform(dimensions) 

207 new_mapping: dict[str, DataIdValue] = {} 

208 if isinstance(mapping, DataCoordinate): 

209 if dimensions is None: 

210 if not kwargs: 

211 # Already standardized to exactly what we want. 

212 return mapping 

213 elif kwargs.keys().isdisjoint(dimensions.names): 

214 # User provided kwargs, but told us not to use them by 

215 # passing in dimensions that are disjoint from those kwargs. 

216 # This is not necessarily user error - it's a useful pattern 

217 # to pass in all of the key-value pairs you have and let the 

218 # code here pull out only what it needs. 

219 return mapping.subset(dimensions.names) 

220 new_mapping.update((name, mapping[name]) for name in mapping.dimensions.required) 

221 if mapping.hasFull(): 

222 new_mapping.update((name, mapping[name]) for name in mapping.dimensions.implied) 

223 elif mapping is not None: 

224 new_mapping.update(mapping) 

225 new_mapping.update(kwargs) 

226 if dimensions is None: 

227 if defaults is not None: 

228 universe = defaults.universe 

229 elif universe is None: 

230 raise TypeError("universe must be provided if dimensions is not.") 

231 dimensions = DimensionGroup(universe, new_mapping.keys()) 

232 if not dimensions: 

233 return DataCoordinate.make_empty(universe) 

234 # Some backends cannot handle numpy.int64 type which is a subclass of 

235 # numbers.Integral; convert that to int. 

236 for k, v in new_mapping.items(): 

237 if isinstance(v, numbers.Integral): # type: ignore 

238 new_mapping[k] = int(v) # type: ignore 

239 if defaults is not None: 

240 for k, v in defaults.mapping.items(): 

241 new_mapping.setdefault(k, v) 

242 if new_mapping.keys() >= dimensions.names: 

243 return DataCoordinate.from_full_values( 

244 dimensions, tuple(new_mapping[name] for name in dimensions.data_coordinate_keys) 

245 ) 

246 else: 

247 try: 

248 values = tuple(new_mapping[name] for name in dimensions.required) 

249 except KeyError as err: 

250 raise DimensionNameError( 

251 f"No value in data ID ({mapping}) for required dimension {err}." 

252 ) from err 

253 return DataCoordinate.from_required_values(dimensions, values) 

254 

255 @property 

256 @abstractmethod 

257 def mapping(self) -> Mapping[str, DataIdValue]: 

258 """A mapping view of the data ID with keys for all dimensions it has 

259 values for. 

260 """ 

261 raise NotImplementedError() 

262 

263 @property 

264 @abstractmethod 

265 def required(self) -> Mapping[str, DataIdValue]: 

266 """A mapping view of the data ID with keys for just its required 

267 dimensions. 

268 """ 

269 raise NotImplementedError() 

270 

271 @property 

272 @abstractmethod 

273 def required_values(self) -> tuple[DataIdValue, ...]: 

274 """The required values (only) of this data ID as a tuple. 

275 

276 Element order is consistent with `required`. 

277 

278 In contexts where all data IDs have the same dimensions, comparing and 

279 hashing these tuples can be much faster than comparing the original 

280 `DataCoordinate` instances. 

281 """ 

282 raise NotImplementedError() 

283 

284 @property 

285 def full_values(self) -> tuple[DataIdValue, ...]: 

286 """The full values (only) of this data ID as a tuple. 

287 

288 Element order is consistent with `DimensionGroup.data_coordinate_keys`, 

289 i.e. all required dimensions followed by all implied dimensions. 

290 """ 

291 raise ValueError(f"DataCoordinate {self} has only required values.") 

292 

293 @staticmethod 

294 def makeEmpty(universe: DimensionUniverse) -> DataCoordinate: 

295 """Return an empty `DataCoordinate`. 

296 

297 It identifies the null set of dimensions. 

298 

299 Parameters 

300 ---------- 

301 universe : `DimensionUniverse` 

302 Universe to which this null dimension set belongs. 

303 

304 Returns 

305 ------- 

306 dataId : `DataCoordinate` 

307 A data ID object that identifies no dimensions. `hasFull` and 

308 `hasRecords` are guaranteed to return `True`, because both `full` 

309 and `records` are just empty mappings. 

310 """ 

311 return DataCoordinate.make_empty(universe) 

312 

313 @staticmethod 

314 def make_empty(universe: DimensionUniverse) -> DataCoordinate: 

315 """Return an empty `DataCoordinate`. 

316 

317 It identifies the null set of dimensions. 

318 

319 Parameters 

320 ---------- 

321 universe : `DimensionUniverse` 

322 Universe to which this null dimension set belongs. 

323 

324 Returns 

325 ------- 

326 data_id : `DataCoordinate` 

327 A data ID object that identifies no dimensions. `hasFull` and 

328 `hasRecords` are guaranteed to return `True`, because both `full` 

329 and `records` are just empty mappings. 

330 """ 

331 return _ExpandedTupleDataCoordinate(universe.empty, (), {}) 

332 

333 @staticmethod 

334 def from_required_values(dimensions: DimensionGroup, values: tuple[DataIdValue, ...]) -> DataCoordinate: 

335 """Construct a `DataCoordinate` from required dimension values. 

336 

337 This is a low-level interface with at most assertion-level checking of 

338 inputs. Most callers should use `standardize` instead. 

339 

340 Parameters 

341 ---------- 

342 dimensions : `DimensionGroup` 

343 Dimensions this data ID will identify. 

344 values : `tuple` [ `int` or `str` ] 

345 Tuple of primary key values corresponding to 

346 ``dimensions.required``, in that order. 

347 

348 Returns 

349 ------- 

350 data_id : `DataCoordinate` 

351 A data ID object that identifies the given dimensions. 

352 ``dataId.hasFull()`` will return `True` only if 

353 ``dimensions.implied`` is empty. ``dataId.hasRecords()`` will 

354 return `True` if and only if ``dimensions`` is empty. 

355 """ 

356 assert len(dimensions.required) == len(values), ( 

357 f"Inconsistency between dimensions {dimensions.required} and required values {values}." 

358 ) 

359 if not dimensions: 

360 return DataCoordinate.make_empty(dimensions.universe) 

361 if not dimensions.implied: 

362 return _FullTupleDataCoordinate(dimensions, values) 

363 return _RequiredTupleDataCoordinate(dimensions, values) 

364 

365 @staticmethod 

366 def from_full_values(dimensions: DimensionGroup, values: tuple[DataIdValue, ...]) -> DataCoordinate: 

367 """Construct a `DataCoordinate` from all dimension values. 

368 

369 This is a low-level interface with at most assertion-level checking of 

370 inputs. Most callers should use `standardize` instead. 

371 

372 Parameters 

373 ---------- 

374 dimensions : `DimensionGroup` 

375 Dimensions this data ID will identify. 

376 values : `tuple` [ `int` or `str` ] 

377 Tuple of primary key values corresponding to 

378 ``itertools.chain(dimensions.required, dimensions.implied)``, in 

379 that order. Note that this is _not_ the same order as 

380 ``dimensions.names``, though these contain the same elements. 

381 

382 Returns 

383 ------- 

384 data_id : `DataCoordinate` 

385 A data ID object that identifies the given dimensions. 

386 ``dataId.hasFull()`` will always return `True`. 

387 ``dataId.hasRecords()`` will only return `True` if ``dimensions`` 

388 is empty. 

389 """ 

390 assert len(dimensions) == len(values), ( 

391 f"Inconsistency between dimensions {dimensions.data_coordinate_keys} and full values {values}." 

392 ) 

393 if not dimensions: 

394 return DataCoordinate.make_empty(dimensions.universe) 

395 return _FullTupleDataCoordinate(dimensions, values) 

396 

397 def __bool__(self) -> bool: 

398 return bool(self.dimensions) 

399 

400 def __hash__(self) -> int: 

401 return hash((self.dimensions,) + self.required_values) 

402 

403 def __eq__(self, other: Any) -> bool: 

404 if not isinstance(other, DataCoordinate): 

405 other = DataCoordinate.standardize(other, universe=self.universe) 

406 return self.dimensions == other.dimensions and self.required_values == other.required_values 

407 

408 @abstractmethod 

409 def __getitem__(self, key: str) -> DataIdValue: 

410 raise NotImplementedError() 

411 

412 def __contains__(self, key: str) -> bool: 

413 try: 

414 self.__getitem__(key) 

415 return True 

416 except KeyError: 

417 return False 

418 

419 @overload 

420 def get(self, key: str) -> DataIdValue | None: ... 420 ↛ exitline 420 didn't return from function 'get' because

421 

422 @overload 

423 def get(self, key: str, default: int) -> int: ... 423 ↛ exitline 423 didn't return from function 'get' because

424 

425 @overload 

426 def get(self, key: str, default: str) -> str: ... 426 ↛ exitline 426 didn't return from function 'get' because

427 

428 @overload 

429 def get(self, key: str, default: DataIdValue | None) -> DataIdValue | None: ... 429 ↛ exitline 429 didn't return from function 'get' because

430 

431 def get(self, key: str, default: DataIdValue | None = None) -> DataIdValue | None: 

432 try: 

433 return self.__getitem__(key) 

434 except KeyError: 

435 return default 

436 

437 def __repr__(self) -> str: 

438 # We can't make repr yield something that could be exec'd here without 

439 # printing out the whole DimensionUniverse. 

440 return str(self.mapping) 

441 

442 def __lt__(self, other: Any) -> bool: 

443 if not isinstance(other, DataCoordinate): 

444 return NotImplemented 

445 # Unlike repr() we only use required keys here to ensure that __eq__ 

446 # can not be true simultaneously with __lt__ being true. 

447 return self.required_values < other.required_values 

448 

449 @abstractmethod 

450 def subset(self, dimensions: DimensionGroup | Iterable[str]) -> DataCoordinate: 

451 """Return a `DataCoordinate` whose diensions are a subset of 

452 ``self.dimensions``. 

453 

454 Parameters 

455 ---------- 

456 dimensions : `DimensionGroup` or `~collections.abc.Iterable` [ `str` ] 

457 The dimensions identified by the returned `DataCoordinate`. 

458 

459 Returns 

460 ------- 

461 coordinate : `DataCoordinate` 

462 A `DataCoordinate` instance that identifies only the given 

463 dimensions. May be ``self`` if ``dimensions == self.dimensions``. 

464 

465 Raises 

466 ------ 

467 KeyError 

468 Raised if the primary key value for one or more required dimensions 

469 is unknown. This may happen even if the required subset of the new 

470 dimensions are not a subset of the dimensions actually known by 

471 this data ID.. As an example, consider trying to go from a data ID 

472 with dimensions {instrument, physical_filter, band} to just 

473 {instrument, band}; band is implied by physical_filter and hence 

474 would have no value in the original data ID if ``self.hasFull()`` 

475 is `False`. 

476 

477 Notes 

478 ----- 

479 If `hasFull` and `hasRecords` return `True` on ``self``, they will 

480 return `True` (respectively) on the returned `DataCoordinate` as well. 

481 The converse does not hold. 

482 """ 

483 raise NotImplementedError() 

484 

485 @abstractmethod 

486 def union(self, other: DataCoordinate) -> DataCoordinate: 

487 """Combine two data IDs. 

488 

489 Yields a new one that identifies all dimensions that either of them 

490 identify. 

491 

492 Parameters 

493 ---------- 

494 other : `DataCoordinate` 

495 Data ID to combine with ``self``. 

496 

497 Returns 

498 ------- 

499 unioned : `DataCoordinate` 

500 A `DataCoordinate` instance that satisfies 

501 ``unioned.dimensions == self.dimensions.union(other.dimensions)``. 

502 Will preserve ``hasFull`` and ``hasRecords`` whenever possible. 

503 

504 Notes 

505 ----- 

506 No checking for consistency is performed on values for keys that 

507 ``self`` and ``other`` have in common, and which value is included in 

508 the returned data ID is not specified. 

509 """ 

510 raise NotImplementedError() 

511 

512 @abstractmethod 

513 def expanded(self, records: Mapping[str, DimensionRecord | None]) -> DataCoordinate: 

514 """Return a `DataCoordinate` that holds the given records. 

515 

516 Guarantees that `hasRecords` returns `True`. 

517 

518 This is a low-level interface with at most assertion-level checking of 

519 inputs. Most callers should use `Registry.expandDataId` instead. 

520 

521 Parameters 

522 ---------- 

523 records : `~collections.abc.Mapping` [ `str`, `DimensionRecord` or \ 

524 `None` ] 

525 A`~collections.abc.Mapping` with `str` (dimension element name) 

526 keys and `DimensionRecord` values. Keys must cover all elements in 

527 ``self.dimensions.elements``. Values may be `None`, but only to 

528 reflect actual NULL values in the database, not just records that 

529 have not been fetched. 

530 """ 

531 raise NotImplementedError() 

532 

533 @property 

534 def universe(self) -> DimensionUniverse: 

535 """Universe that defines all known compatible dimensions. 

536 

537 The universe will be compatible with this coordinate 

538 (`DimensionUniverse`). 

539 """ 

540 return self.dimensions.universe 

541 

542 @property 

543 @abstractmethod 

544 def dimensions(self) -> DimensionGroup: 

545 """Dimensions identified by this data ID (`DimensionGroup`). 

546 

547 Note that values are only required to be present for dimensions in 

548 ``self.dimensions.required``; all others may be retrieved (from a 

549 `Registry`) given these. 

550 """ 

551 raise NotImplementedError() 

552 

553 @abstractmethod 

554 def hasFull(self) -> bool: 

555 """Whether this data ID contains implied and required values. 

556 

557 Returns 

558 ------- 

559 state : `bool` 

560 If `True`, ``__getitem__``, `get`, and ``__contains__`` (but not 

561 ``keys``!) will act as though the mapping includes key-value pairs 

562 for implied dimensions, and the ``full`` property may be used. If 

563 `False`, these operations only include key-value pairs for required 

564 dimensions, and accessing ``full`` is an error. Always `True` if 

565 there are no implied dimensions. 

566 """ 

567 raise NotImplementedError() 

568 

569 @abstractmethod 

570 def hasRecords(self) -> bool: 

571 """Whether this data ID contains records. 

572 

573 These are the records for all of the dimension elements it identifies. 

574 

575 Returns 

576 ------- 

577 state : `bool` 

578 If `True`, the following attributes may be accessed: 

579 

580 - `records` 

581 - `region` 

582 - `timespan` 

583 - `pack` 

584 

585 If `False`, accessing any of these is considered a logic error. 

586 """ 

587 raise NotImplementedError() 

588 

589 @property 

590 def records(self) -> Mapping[str, DimensionRecord | None]: 

591 """A mapping that contains `DimensionRecord` objects for all 

592 elements identified by this data ID. 

593 

594 Notes 

595 ----- 

596 The values of this mapping may be `None` if and only if there is no 

597 record for that element with these dimensions in the database (which 

598 means some foreign key field must have a NULL value). 

599 

600 Accessing this attribute if `hasRecords` returns `False` is a logic 

601 error that may raise an exception of unspecified type either 

602 immediately or when the returned mapping is used, depending on the 

603 implementation and whether assertions are enabled. 

604 """ 

605 assert self.hasRecords(), "records may only be accessed if hasRecords() returns True." 

606 return _DataCoordinateRecordsView(self) 

607 

608 @abstractmethod 

609 def _record(self, name: str) -> DimensionRecord | None: 

610 """Protected implementation hook that backs the ``records`` attribute. 

611 

612 Parameters 

613 ---------- 

614 name : `str` 

615 The name of a `DimensionElement`, guaranteed to be in 

616 ``self.dimensions.elements``. 

617 

618 Returns 

619 ------- 

620 record : `DimensionRecord` or `None` 

621 The dimension record for the given element identified by this 

622 data ID, or `None` if there is no such record. 

623 """ 

624 raise NotImplementedError() 

625 

626 @property 

627 def region(self) -> Region | None: 

628 """Spatial region associated with this data ID. 

629 

630 (`lsst.sphgeom.Region` or `None`). 

631 

632 This is `None` if and only if ``self.dimensions.spatial`` is empty. 

633 

634 Accessing this attribute if `hasRecords` returns `False` is a logic 

635 error that may or may not raise an exception, depending on the 

636 implementation and whether assertions are enabled. 

637 """ 

638 assert self.hasRecords(), "region may only be accessed if hasRecords() returns True." 

639 regions = [] 

640 for family in self.dimensions.spatial: 

641 element = family.choose(self.dimensions) 

642 record = self._record(element.name) 

643 if record is None or record.region is None: 

644 return None 

645 else: 

646 regions.append(record.region) 

647 return _intersectRegions(*regions) 

648 

649 @property 

650 def timespan(self) -> Timespan | None: 

651 """Temporal interval associated with this data ID. 

652 

653 (`Timespan` or `None`). 

654 

655 This is `None` if and only if ``self.dimensions.temporal`` is empty. 

656 

657 Accessing this attribute if `hasRecords` returns `False` is a logic 

658 error that may or may not raise an exception, depending on the 

659 implementation and whether assertions are enabled. 

660 """ 

661 assert self.hasRecords(), "timespan may only be accessed if hasRecords() returns True." 

662 timespans = [] 

663 for family in self.dimensions.temporal: 

664 element = family.choose(self.dimensions) 

665 record = self._record(element.name) 

666 # DimensionRecord subclasses for temporal elements always have 

667 # .timespan, but they're dynamic so this can't be type-checked. 

668 if record is None or record.timespan is None: 

669 return None 

670 else: 

671 timespans.append(record.timespan) 

672 if not timespans: 

673 return None 

674 elif len(timespans) == 1: 

675 return timespans[0] 

676 else: 

677 return Timespan.intersection(*timespans) 

678 

679 def to_simple(self, minimal: bool = False) -> SerializedDataCoordinate: 

680 """Convert this class to a simple python type. 

681 

682 This is suitable for serialization. 

683 

684 Parameters 

685 ---------- 

686 minimal : `bool`, optional 

687 Use minimal serialization. If set the records will not be attached. 

688 

689 Returns 

690 ------- 

691 simple : `SerializedDataCoordinate` 

692 The object converted to simple form. 

693 """ 

694 # Convert to a dict form 

695 records: dict[str, SerializedDimensionRecord] | None 

696 if not minimal and self.hasRecords(): 

697 records = { 

698 k: v.to_simple() for k in self.dimensions.elements if (v := self.records[k]) is not None 

699 } 

700 else: 

701 records = None 

702 

703 return SerializedDataCoordinate(dataId=dict(self.mapping), records=records) 

704 

705 @classmethod 

706 def from_simple( 

707 cls, 

708 simple: SerializedDataCoordinate, 

709 universe: DimensionUniverse | None = None, 

710 registry: Registry | None = None, 

711 ) -> DataCoordinate: 

712 """Construct a new object from the simplified form. 

713 

714 The data is assumed to be of the form returned from the `to_simple` 

715 method. 

716 

717 Parameters 

718 ---------- 

719 simple : `dict` of [`str`, `typing.Any`] 

720 The `dict` returned by `to_simple()`. 

721 universe : `DimensionUniverse` 

722 Object that manages all known dimensions. 

723 registry : `lsst.daf.butler.Registry`, optional 

724 Registry from which a universe can be extracted. Can be `None` 

725 if universe is provided explicitly. 

726 

727 Returns 

728 ------- 

729 dataId : `DataCoordinate` 

730 Newly-constructed object. 

731 """ 

732 key = (frozenset(simple.dataId.items()), simple.records is not None) 

733 cache = PersistenceContextVars.dataCoordinates.get() 

734 if cache is not None and (result := cache.get(key)) is not None: 

735 return result 

736 if universe is None and registry is None: 

737 raise ValueError("One of universe or registry is required to convert a dict to a DataCoordinate") 

738 if universe is None and registry is not None: 

739 universe = registry.dimensions 

740 if universe is None: 

741 # this is for mypy 

742 raise ValueError("Unable to determine a usable universe") 

743 

744 dataId = cls.standardize(simple.dataId, universe=universe) 

745 if simple.records: 

746 dataId = dataId.expanded( 

747 {k: DimensionRecord.from_simple(v, universe=universe) for k, v in simple.records.items()} 

748 ) 

749 if cache is not None: 

750 cache[key] = dataId 

751 return dataId 

752 

753 to_json = to_json_pydantic 

754 from_json: ClassVar[Callable[..., Self]] = cast(Callable[..., Self], classmethod(from_json_pydantic)) 

755 

756 @property 

757 def dataId(self) -> Self: 

758 """Return this `DataCoordinate` instance, unmodified.""" 

759 return self 

760 

761 

762DataId = DataCoordinate | Mapping[str, Any] 

763"""A type-annotation alias for signatures that accept both informal data ID 

764dictionaries and validated `DataCoordinate` instances. 

765""" 

766 

767 

768class _DataCoordinateRecordsView(Mapping[str, DimensionRecord | None]): 

769 """View class for `DataCoordinate.records`. 

770 

771 Provides the default implementation for 

772 `DataCoordinate.records`. 

773 

774 Parameters 

775 ---------- 

776 target : `DataCoordinate` 

777 The `DataCoordinate` instance this object provides a view of. 

778 """ 

779 

780 def __init__(self, target: DataCoordinate): 

781 self._target = target 

782 

783 __slots__ = ("_target",) 

784 

785 def __repr__(self) -> str: 

786 terms = [f"{d}: {self[d]!r}" for d in self._target.dimensions.elements] 

787 return "{{{}}}".format(", ".join(terms)) 

788 

789 def __str__(self) -> str: 

790 return "\n".join(str(v) for v in self.values()) 

791 

792 def __getitem__(self, key: str) -> DimensionRecord | None: 

793 return self._target._record(key) 

794 

795 def __iter__(self) -> Iterator[str]: 

796 return iter(self._target.dimensions.elements) 

797 

798 def __len__(self) -> int: 

799 return len(self._target.dimensions.elements) 

800 

801 

802class _BasicTupleDataCoordinate(DataCoordinate): 

803 """Intermediate base class for the standard implementation of 

804 `DataCoordinate`. 

805 

806 This class should only be accessed outside this module via the 

807 `DataCoordinate` interface, and should only be constructed via the static 

808 methods there. 

809 

810 Parameters 

811 ---------- 

812 dimensions : `DimensionGroup` 

813 The dimensions to be identified. 

814 values : `tuple` [ `int` or `str` ] 

815 Data ID values, ordered to match 

816 ``dimensions.data_coordinate_keys``. May include values for just 

817 required dimensions (which always come first) or all dimensions 

818 (concrete subclasses implementations will care which). 

819 """ 

820 

821 def __init__(self, dimensions: DimensionGroup, values: tuple[DataIdValue, ...]): 

822 self._dimensions = dimensions 

823 self._values = values 

824 

825 __slots__ = ("_dimensions", "_values") 

826 

827 @property 

828 def dimensions(self) -> DimensionGroup: 

829 # Docstring inherited from DataCoordinate. 

830 return self._dimensions 

831 

832 @property 

833 def required(self) -> Mapping[str, DataIdValue]: 

834 # Docstring inherited from DataCoordinate. 

835 return _DataCoordinateRequiredMappingView(self) 

836 

837 def __getitem__(self, key: str) -> DataIdValue: 

838 # Docstring inherited from DataCoordinate. 

839 

840 index = self._dimensions._data_coordinate_indices[key] 

841 try: 

842 return self._values[index] 

843 except IndexError: 

844 # Caller asked for an implied dimension, but this object only has 

845 # values for the required ones. 

846 raise KeyError(key) from None 

847 

848 def hasRecords(self) -> bool: 

849 # Docstring inherited from DataCoordinate. 

850 return False 

851 

852 def _record(self, name: str) -> DimensionRecord | None: 

853 # Docstring inherited from DataCoordinate. 

854 raise AssertionError() 

855 

856 def __getattr__(self, name: str) -> Any: 

857 if name in self.dimensions.elements: 

858 raise AttributeError( 

859 f"Dimension record attribute {name!r} is only available on expanded DataCoordinates." 

860 ) 

861 raise AttributeError(name) 

862 

863 

864class _DataCoordinateRequiredMappingView(Mapping[str, DataIdValue]): 

865 """A DataCoordinate Mapping view class whose keys are just the required 

866 dimensions. 

867 """ 

868 

869 def __init__(self, target: DataCoordinate): 

870 self._target = target 

871 

872 __slots__ = ("_target",) 

873 

874 def __getitem__(self, key: str) -> DataIdValue: 

875 if key not in self._target.dimensions.required: 

876 raise KeyError(key) 

877 return self._target[key] 

878 

879 def __len__(self) -> int: 

880 return len(self._target.dimensions.required) 

881 

882 def __iter__(self) -> Iterator[str]: 

883 return iter(self._target.dimensions.required) 

884 

885 def __repr__(self) -> str: 

886 return f"{{{', '.join(f'{k}: {v!r}' for k, v in self.items())}}}" 

887 

888 

889class _DataCoordinateFullMappingView(Mapping[str, DataIdValue]): 

890 """A DataCoordinate Mapping view class whose keys are all dimensions.""" 

891 

892 def __init__(self, target: DataCoordinate): 

893 self._target = target 

894 

895 __slots__ = ("_target",) 

896 

897 def __getitem__(self, key: str) -> DataIdValue: 

898 return self._target[key] 

899 

900 def __len__(self) -> int: 

901 return len(self._target.dimensions) 

902 

903 def __iter__(self) -> Iterator[str]: 

904 return iter(self._target.dimensions.data_coordinate_keys) 

905 

906 def __repr__(self) -> str: 

907 return f"{{{', '.join(f'{k}: {v!r}' for k, v in self.items())}}}" 

908 

909 

910class _RequiredTupleDataCoordinate(_BasicTupleDataCoordinate): 

911 """A `DataCoordinate` implementation that has values for required 

912 dimensions only, when implied dimensions already exist. 

913 

914 Note that `_FullTupleDataCoordinate` should be used if there are no 

915 implied dimensions. 

916 

917 This class should only be accessed outside this module via the 

918 `DataCoordinate` interface, and should only be constructed via calls to 

919 `DataCoordinate.from_full_values`. 

920 """ 

921 

922 __slots__ = () 

923 

924 @property 

925 def mapping(self) -> Mapping[str, DataIdValue]: 

926 # Docstring inherited from DataCoordinate. 

927 return _DataCoordinateRequiredMappingView(self) 

928 

929 @property 

930 def required_values(self) -> tuple[DataIdValue, ...]: 

931 # Docstring inherited from DataCoordinate. 

932 return self._values 

933 

934 def subset(self, dimensions: DimensionGroup | Iterable[str]) -> DataCoordinate: 

935 # Docstring inherited from DataCoordinate. 

936 dimensions = self.universe.conform(dimensions) 

937 if self._dimensions == dimensions: 

938 return self 

939 elif self._dimensions.required >= dimensions.names: 

940 return DataCoordinate.from_full_values( 

941 dimensions, 

942 tuple(self[k] for k in dimensions.data_coordinate_keys), 

943 ) 

944 else: 

945 return DataCoordinate.from_required_values( 

946 dimensions, tuple(self[k] for k in dimensions.required) 

947 ) 

948 

949 def union(self, other: DataCoordinate) -> DataCoordinate: 

950 # Docstring inherited from DataCoordinate. 

951 dimensions = self.dimensions.union(other.dimensions) 

952 # See if the other one is already what we want to return. We don't 

953 # shortcut-return 'self' because `other` might have full values or 

954 # even records, and we want to return the more complete data ID. 

955 if other.dimensions == dimensions: 

956 return other 

957 # General case with actual merging of dictionaries. 

958 values = dict(self.mapping) 

959 values.update(other.mapping) 

960 return DataCoordinate.standardize(values, dimensions=dimensions) 

961 

962 def expanded(self, records: Mapping[str, DimensionRecord | None]) -> DataCoordinate: 

963 # Docstring inherited from DataCoordinate 

964 # Extract a complete values tuple from the attributes of the given 

965 # records. It's possible for these to be inconsistent with 

966 # self._values (which is a serious problem, of course), but we've 

967 # documented this as a no-checking API. 

968 values = self._values + tuple( 

969 getattr(records[d], self.universe.dimensions[d].primaryKey.name) for d in self._dimensions.implied 

970 ) 

971 return _ExpandedTupleDataCoordinate(self._dimensions, values, records) 

972 

973 def hasFull(self) -> bool: 

974 # Docstring inherited from DataCoordinate. 

975 return False 

976 

977 def __reduce__(self) -> tuple[Any, ...]: 

978 return (_RequiredTupleDataCoordinate, (self._dimensions, self._values)) 

979 

980 

981class _FullTupleDataCoordinate(_BasicTupleDataCoordinate): 

982 """A `DataCoordinate` implementation that has values for all dimensions. 

983 

984 This class should only be accessed outside this module via the 

985 `DataCoordinate` interface, and should only be constructed via calls to 

986 `DataCoordinate.from_full_values`. 

987 """ 

988 

989 __slots__ = () 

990 

991 @property 

992 def mapping(self) -> Mapping[str, DataIdValue]: 

993 # Docstring inherited from DataCoordinate. 

994 return _DataCoordinateFullMappingView(self) 

995 

996 @property 

997 def required_values(self) -> tuple[DataIdValue, ...]: 

998 # Docstring inherited from DataCoordinate. 

999 return self._values[: len(self._dimensions.required)] 

1000 

1001 @property 

1002 def full_values(self) -> tuple[DataIdValue, ...]: 

1003 # Docstring inherited from DataCoordinate. 

1004 return self._values 

1005 

1006 def subset(self, dimensions: DimensionGroup | Iterable[str]) -> DataCoordinate: 

1007 # Docstring inherited from DataCoordinate. 

1008 dimensions = self.universe.conform(dimensions) 

1009 if self._dimensions == dimensions: 

1010 return self 

1011 

1012 try: 

1013 values = tuple(self[k] for k in dimensions.data_coordinate_keys) 

1014 except KeyError as e: 

1015 raise DimensionNameError(f"Data ID is missing value for dimension {str(e)}.") 

1016 return DataCoordinate.from_full_values(dimensions, values) 

1017 

1018 def union(self, other: DataCoordinate) -> DataCoordinate: 

1019 # Docstring inherited from DataCoordinate. 

1020 dimensions = self.dimensions.union(other.dimensions) 

1021 # See if one or both input data IDs is already what we want to return; 

1022 # if so, return the most complete one we have. 

1023 if other.dimensions == dimensions and other.hasRecords(): 

1024 return other 

1025 elif self.dimensions == dimensions and not other.hasRecords(): 

1026 return self 

1027 # General case with actual merging of dictionaries. 

1028 values = dict(self.mapping) 

1029 values.update(other.mapping) 

1030 return DataCoordinate.standardize(values, dimensions=dimensions) 

1031 

1032 def expanded(self, records: Mapping[str, DimensionRecord | None]) -> DataCoordinate: 

1033 # Docstring inherited from DataCoordinate 

1034 return _ExpandedTupleDataCoordinate(self._dimensions, self._values, records) 

1035 

1036 def hasFull(self) -> bool: 

1037 # Docstring inherited from DataCoordinate. 

1038 return True 

1039 

1040 def __reduce__(self) -> tuple[Any, ...]: 

1041 return (_FullTupleDataCoordinate, (self._dimensions, self._values)) 

1042 

1043 

1044class _ExpandedTupleDataCoordinate(_FullTupleDataCoordinate): 

1045 """A `DataCoordinate` implementation that directly holds `DimensionRecord` 

1046 objects relevant to it. 

1047 

1048 This class should only be accessed outside this module via the 

1049 `DataCoordinate` interface, and should only be constructed via calls to 

1050 `DataCoordinate.expanded`. 

1051 

1052 Parameters 

1053 ---------- 

1054 dimensions : `DimensionGroup` 

1055 The dimensions to be identified. 

1056 values : `tuple` [ `int` or `str` ] 

1057 Data ID values, ordered to match 

1058 ``dimensions._data_coordinate_indices``. Just include values for all 

1059 dimensions. 

1060 records : `~collections.abc.Mapping` [ `str`, `DimensionRecord` or `None` ] 

1061 A `~collections.abc.Mapping` with `str` (dimension element name) keys 

1062 and `DimensionRecord` values. Keys must cover all elements in 

1063 ``self.dimensions.elements``. Values may be `None`, but only to 

1064 reflect actual NULL values in the database, not just records that have 

1065 not been fetched. 

1066 """ 

1067 

1068 def __init__( 

1069 self, 

1070 dimensions: DimensionGroup, 

1071 values: tuple[DataIdValue, ...], 

1072 records: Mapping[str, DimensionRecord | None], 

1073 ): 

1074 super().__init__(dimensions, values) 

1075 assert super().hasFull(), "This implementation requires full dimension records." 

1076 self._records = records 

1077 

1078 __slots__ = ("_records",) 

1079 

1080 def subset(self, dimensions: DimensionGroup | Iterable[str]) -> DataCoordinate: 

1081 # Docstring inherited from DataCoordinate. 

1082 return super().subset(dimensions).expanded(self._records) 

1083 

1084 def expanded(self, records: Mapping[str, DimensionRecord | None]) -> DataCoordinate: 

1085 # Docstring inherited from DataCoordinate. 

1086 return self 

1087 

1088 def union(self, other: DataCoordinate) -> DataCoordinate: 

1089 # Docstring inherited from DataCoordinate. 

1090 result = super().union(other) 

1091 if not result.hasRecords() and other.hasRecords(): 

1092 records = {e: self._record(e) for e in self.dimensions.elements} | { 

1093 e: other._record(e) for e in other.dimensions.elements 

1094 } 

1095 if records.keys() >= result.dimensions.elements: 

1096 return result.expanded(records) 

1097 return result 

1098 

1099 def hasRecords(self) -> bool: 

1100 # Docstring inherited from DataCoordinate. 

1101 return True 

1102 

1103 def _record(self, name: str) -> DimensionRecord | None: 

1104 # Docstring inherited from DataCoordinate. 

1105 return self._records[name] 

1106 

1107 def __reduce__(self) -> tuple[Any, ...]: 

1108 return (_ExpandedTupleDataCoordinate, (self._dimensions, self._values, self._records)) 

1109 

1110 def __getattr__(self, name: str) -> Any: 

1111 try: 

1112 return self._record(name) 

1113 except KeyError: 

1114 raise AttributeError(name) from None 

1115 

1116 def __dir__(self) -> list[str]: 

1117 result = list(super().__dir__()) 

1118 result.extend(self.dimensions.elements) 

1119 return result