Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

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 program is free software: you can redistribute it and/or modify 

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# GNU General Public License for more details. 

18# 

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

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

21 

22# 

23# Design notes for this module are in 

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

25# 

26 

27from __future__ import annotations 

28 

29__all__ = ("DataCoordinate", "DataId", "DataIdKey", "DataIdValue") 

30 

31from abc import abstractmethod 

32import numbers 

33from typing import ( 

34 AbstractSet, 

35 Any, 

36 Dict, 

37 Iterator, 

38 Mapping, 

39 Optional, 

40 Tuple, 

41 TYPE_CHECKING, 

42 Union, 

43) 

44 

45from lsst.sphgeom import Region 

46from ..named import NamedKeyDict, NamedKeyMapping, NameLookupMapping, NamedValueAbstractSet 

47from ..timespan import Timespan 

48from ._elements import Dimension, DimensionElement 

49from ._graph import DimensionGraph 

50from ._records import DimensionRecord 

51 

52if TYPE_CHECKING: # Imports needed only for type annotations; may be circular. 52 ↛ 53line 52 didn't jump to line 53, because the condition on line 52 was never true

53 from ._universe import DimensionUniverse 

54 

55DataIdKey = Union[str, Dimension] 

56"""Type annotation alias for the keys that can be used to index a 

57DataCoordinate. 

58""" 

59 

60DataIdValue = Union[str, int, None] 

61"""Type annotation alias for the values that can be present in a 

62DataCoordinate or other data ID. 

63""" 

64 

65 

66def _intersectRegions(*args: Region) -> Optional[Region]: 

67 """Return the intersection of several regions. 

68 

69 For internal use by `ExpandedDataCoordinate` only. 

70 

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

72 

73 This is currently a placeholder; it actually returns `NotImplemented` 

74 (it does *not* raise an exception) when multiple regions are given, which 

75 propagates to `ExpandedDataCoordinate`. This reflects the fact that we 

76 don't want to fail to construct an `ExpandedDataCoordinate` entirely when 

77 we can't compute its region, and at present we don't have a high-level use 

78 case for the regions of these particular data IDs. 

79 """ 

80 if len(args) == 0: 

81 return None 

82 elif len(args) == 1: 

83 return args[0] 

84 else: 

85 return NotImplemented 

86 

87 

88class DataCoordinate(NamedKeyMapping[Dimension, DataIdValue]): 

89 """An immutable data ID dictionary that guarantees that its key-value pairs 

90 identify at least all required dimensions in a `DimensionGraph`. 

91 

92 `DataCoordinateSet` itself is an ABC, but provides `staticmethod` factory 

93 functions for private concrete implementations that should be sufficient 

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

95 the others (`makeEmpty`, `fromRequiredValues`, and `fromFullValues`) are 

96 more specialized and perform little or no checking of inputs. 

97 

98 Notes 

99 ----- 

100 Like any data ID class, `DataCoordinate` behaves like a dictionary, but 

101 with some subtleties: 

102 

103 - Both `Dimension` instances and `str` names thereof may be used as keys 

104 in lookup operations, but iteration (and `keys`) will yield `Dimension` 

105 instances. The `names` property can be used to obtain the corresponding 

106 `str` names. 

107 

108 - Lookups for implied dimensions (those in ``self.graph.implied``) are 

109 supported if and only if `hasFull` returns `True`, and are never 

110 included in iteration or `keys`. The `full` property may be used to 

111 obtain a mapping whose keys do include implied dimensions. 

112 

113 - Equality comparison with other mappings is supported, but it always 

114 considers only required dimensions (as well as requiring both operands 

115 to identify the same dimensions). This is not quite consistent with the 

116 way mappings usually work - normally differing keys imply unequal 

117 mappings - but it makes sense in this context because data IDs with the 

118 same values for required dimensions but different values for implied 

119 dimensions represent a serious problem with the data that 

120 `DataCoordinate` cannot generally recognize on its own, and a data ID 

121 that knows implied dimension values should still be able to compare as 

122 equal to one that does not. This is of course not the way comparisons 

123 between simple `dict` data IDs work, and hence using a `DataCoordinate` 

124 instance for at least one operand in any data ID comparison is strongly 

125 recommended. 

126 """ 

127 

128 __slots__ = () 

129 

130 @staticmethod 

131 def standardize( 

132 mapping: Optional[NameLookupMapping[Dimension, DataIdValue]] = None, 

133 *, 

134 graph: Optional[DimensionGraph] = None, 

135 universe: Optional[DimensionUniverse] = None, 

136 defaults: Optional[DataCoordinate] = None, 

137 **kwargs: Any 

138 ) -> DataCoordinate: 

139 """Adapt an arbitrary mapping and/or additional arguments into a true 

140 `DataCoordinate`, or augment an existing one. 

141 

142 Parameters 

143 ---------- 

144 mapping : `~collections.abc.Mapping`, optional 

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

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

147 graph : `DimensionGraph` 

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

149 If not provided, will be inferred from the keys of ``mapping`` and 

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

151 is already a `DataCoordinate`. 

152 universe : `DimensionUniverse` 

153 All known dimensions and their relationships; used to expand 

154 and validate dependencies when ``graph`` is not provided. 

155 defaults : `DataCoordinate`, optional 

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

157 never used to infer ``graph``, and are ignored if a different value 

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

159 **kwargs 

160 Additional keyword arguments are treated like additional key-value 

161 pairs in ``mapping``. 

162 

163 Returns 

164 ------- 

165 coordinate : `DataCoordinate` 

166 A validated `DataCoordinate` instance. 

167 

168 Raises 

169 ------ 

170 TypeError 

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

172 KeyError 

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

174 """ 

175 d: Dict[str, DataIdValue] = {} 

176 if isinstance(mapping, DataCoordinate): 

177 if graph is None: 

178 if not kwargs: 

179 # Already standardized to exactly what we want. 

180 return mapping 

181 elif kwargs.keys().isdisjoint(graph.dimensions.names): 

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

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

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

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

186 # code here pull out only what it needs. 

187 return mapping.subset(graph) 

188 assert universe is None or universe == mapping.universe 

189 universe = mapping.universe 

190 d.update((name, mapping[name]) for name in mapping.graph.required.names) 

191 if mapping.hasFull(): 

192 d.update((name, mapping[name]) for name in mapping.graph.implied.names) 

193 elif isinstance(mapping, NamedKeyMapping): 

194 d.update(mapping.byName()) 

195 elif mapping is not None: 

196 d.update(mapping) 

197 d.update(kwargs) 

198 if graph is None: 

199 if defaults is not None: 

200 universe = defaults.universe 

201 elif universe is None: 

202 raise TypeError("universe must be provided if graph is not.") 

203 graph = DimensionGraph(universe, names=d.keys()) 

204 if not graph.dimensions: 

205 return DataCoordinate.makeEmpty(graph.universe) 

206 if defaults is not None: 

207 if defaults.hasFull(): 

208 for k, v in defaults.full.items(): 

209 d.setdefault(k.name, v) 

210 else: 

211 for k, v in defaults.items(): 

212 d.setdefault(k.name, v) 

213 if d.keys() >= graph.dimensions.names: 

214 values = tuple(d[name] for name in graph._dataCoordinateIndices.keys()) 

215 else: 

216 try: 

217 values = tuple(d[name] for name in graph.required.names) 

218 except KeyError as err: 

219 raise KeyError(f"No value in data ID ({mapping}) for required dimension {err}.") from err 

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

221 # numbers.Integral; convert that to int. 

222 values = tuple(int(val) if isinstance(val, numbers.Integral) # type: ignore 

223 else val for val in values) 

224 return _BasicTupleDataCoordinate(graph, values) 

225 

226 @staticmethod 

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

228 """Return an empty `DataCoordinate` that identifies the null set of 

229 dimensions. 

230 

231 Parameters 

232 ---------- 

233 universe : `DimensionUniverse` 

234 Universe to which this null dimension set belongs. 

235 

236 Returns 

237 ------- 

238 dataId : `DataCoordinate` 

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

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

241 and `records` are just empty mappings. 

242 """ 

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

244 

245 @staticmethod 

246 def fromRequiredValues(graph: DimensionGraph, values: Tuple[DataIdValue, ...]) -> DataCoordinate: 

247 """Construct a `DataCoordinate` from a tuple of dimension values that 

248 identify only required dimensions. 

249 

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

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

252 

253 Parameters 

254 ---------- 

255 graph : `DimensionGraph` 

256 Dimensions this data ID will identify. 

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

258 Tuple of primary key values corresponding to ``graph.required``, 

259 in that order. 

260 

261 Returns 

262 ------- 

263 dataId : `DataCoordinate` 

264 A data ID object that identifies the given dimensions. 

265 ``dataId.hasFull()`` will return `True` if and only if 

266 ``graph.implied`` is empty, and ``dataId.hasRecords()`` will never 

267 return `True`. 

268 """ 

269 assert len(graph.required) == len(values), \ 

270 f"Inconsistency between dimensions {graph.required} and required values {values}." 

271 return _BasicTupleDataCoordinate(graph, values) 

272 

273 @staticmethod 

274 def fromFullValues(graph: DimensionGraph, values: Tuple[DataIdValue, ...]) -> DataCoordinate: 

275 """Construct a `DataCoordinate` from a tuple of dimension values that 

276 identify all dimensions. 

277 

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

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

280 

281 Parameters 

282 ---------- 

283 graph : `DimensionGraph` 

284 Dimensions this data ID will identify. 

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

286 Tuple of primary key values corresponding to 

287 ``itertools.chain(graph.required, graph.implied)``, in that order. 

288 Note that this is _not_ the same order as ``graph.dimensions``, 

289 though these contain the same elements. 

290 

291 Returns 

292 ------- 

293 dataId : `DataCoordinate` 

294 A data ID object that identifies the given dimensions. 

295 ``dataId.hasFull()`` will return `True` if and only if 

296 ``graph.implied`` is empty, and ``dataId.hasRecords()`` will never 

297 return `True`. 

298 """ 

299 assert len(graph.dimensions) == len(values), \ 

300 f"Inconsistency between dimensions {graph.dimensions} and full values {values}." 

301 return _BasicTupleDataCoordinate(graph, values) 

302 

303 def __hash__(self) -> int: 

304 return hash((self.graph,) + tuple(self[d.name] for d in self.graph.required)) 

305 

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

307 if not isinstance(other, DataCoordinate): 

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

309 return self.graph == other.graph and all(self[d.name] == other[d.name] for d in self.graph.required) 

310 

311 def __repr__(self) -> str: 

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

313 # printing out the whole DimensionUniverse the graph is derived from. 

314 # So we print something that mostly looks like a dict, but doesn't 

315 # quote its keys: that's both more compact and something that can't 

316 # be mistaken for an actual dict or something that could be exec'd. 

317 terms = [f"{d}: {self[d]!r}" for d in self.graph.required.names] 

318 if self.hasFull() and self.graph.required != self.graph.dimensions: 

319 terms.append("...") 

320 return "{{{}}}".format(', '.join(terms)) 

321 

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

323 # Allow DataCoordinate to be sorted 

324 if not isinstance(other, type(self)): 

325 return NotImplemented 

326 # Form tuple of tuples for each DataCoordinate: 

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

328 # __eq__ can not be true simultaneously with __lt__ being true. 

329 self_kv = tuple(self.items()) 

330 other_kv = tuple(other.items()) 

331 

332 return self_kv < other_kv 

333 

334 def __iter__(self) -> Iterator[Dimension]: 

335 return iter(self.keys()) 

336 

337 def __len__(self) -> int: 

338 return len(self.keys()) 

339 

340 def keys(self) -> NamedValueAbstractSet[Dimension]: 

341 return self.graph.required 

342 

343 @property 

344 def names(self) -> AbstractSet[str]: 

345 """The names of the required dimensions identified by this data ID, in 

346 the same order as `keys` (`collections.abc.Set` [ `str` ]). 

347 """ 

348 return self.keys().names 

349 

350 @abstractmethod 

351 def subset(self, graph: DimensionGraph) -> DataCoordinate: 

352 """Return a `DataCoordinate` whose graph is a subset of ``self.graph``. 

353 

354 Parameters 

355 ---------- 

356 graph : `DimensionGraph` 

357 The dimensions identified by the returned `DataCoordinate`. 

358 

359 Returns 

360 ------- 

361 coordinate : `DataCoordinate` 

362 A `DataCoordinate` instance that identifies only the given 

363 dimensions. May be ``self`` if ``graph == self.graph``. 

364 

365 Raises 

366 ------ 

367 KeyError 

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

369 is unknown. This may happen if ``graph.issubset(self.graph)`` is 

370 `False`, or even if ``graph.issubset(self.graph)`` is `True`, if 

371 ``self.hasFull()`` is `False` and 

372 ``graph.required.issubset(self.graph.required)`` is `False`. As 

373 an example of the latter case, consider trying to go from a data ID 

374 with dimensions {instrument, physical_filter, band} to 

375 just {instrument, band}; band is implied by 

376 physical_filter and hence would have no value in the original data 

377 ID if ``self.hasFull()`` is `False`. 

378 

379 Notes 

380 ----- 

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

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

383 The converse does not hold. 

384 """ 

385 raise NotImplementedError() 

386 

387 @abstractmethod 

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

389 """Combine two data IDs, yielding a new one that identifies all 

390 dimensions that either of them identify. 

391 

392 Parameters 

393 ---------- 

394 other : `DataCoordinate` 

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

396 

397 Returns 

398 ------- 

399 unioned : `DataCoordinate` 

400 A `DataCoordinate` instance that satisfies 

401 ``unioned.graph == self.graph.union(other.graph)``. Will preserve 

402 ``hasFull`` and ``hasRecords`` whenever possible. 

403 

404 Notes 

405 ----- 

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

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

408 the returned data ID is not specified. 

409 """ 

410 raise NotImplementedError() 

411 

412 @abstractmethod 

413 def expanded(self, records: NameLookupMapping[DimensionElement, Optional[DimensionRecord]] 

414 ) -> DataCoordinate: 

415 """Return a `DataCoordinate` that holds the given records and 

416 guarantees that `hasRecords` returns `True`. 

417 

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

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

420 

421 Parameters 

422 ---------- 

423 records : `Mapping` [ `str`, `DimensionRecord` or `None` ] 

424 A `NamedKeyMapping` with `DimensionElement` keys or a regular 

425 `Mapping` with `str` (`DimensionElement` name) keys and 

426 `DimensionRecord` values. Keys must cover all elements in 

427 ``self.graph.elements``. Values may be `None`, but only to reflect 

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

429 been fetched. 

430 """ 

431 raise NotImplementedError() 

432 

433 @property 

434 def universe(self) -> DimensionUniverse: 

435 """The universe that defines all known dimensions compatible with 

436 this coordinate (`DimensionUniverse`). 

437 """ 

438 return self.graph.universe 

439 

440 @property 

441 @abstractmethod 

442 def graph(self) -> DimensionGraph: 

443 """The dimensions identified by this data ID (`DimensionGraph`). 

444 

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

446 ``self.graph.required``; all others may be retrieved (from a 

447 `Registry`) given these. 

448 """ 

449 raise NotImplementedError() 

450 

451 @abstractmethod 

452 def hasFull(self) -> bool: 

453 """Whether this data ID contains values for implied as well as 

454 required dimensions. 

455 

456 Returns 

457 ------- 

458 state : `bool` 

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

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

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

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

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

464 there are no implied dimensions. 

465 """ 

466 raise NotImplementedError() 

467 

468 @property 

469 def full(self) -> NamedKeyMapping[Dimension, DataIdValue]: 

470 """A mapping that includes key-value pairs for all dimensions in 

471 ``self.graph``, including implied (`NamedKeyMapping`). 

472 

473 Accessing this attribute if `hasFull` returns `False` is a logic error 

474 that may raise an exception of unspecified type either immediately or 

475 when implied keys are accessed via the returned mapping, depending on 

476 the implementation and whether assertions are enabled. 

477 """ 

478 assert self.hasFull(), "full may only be accessed if hasRecords() returns True." 

479 return _DataCoordinateFullView(self) 

480 

481 @abstractmethod 

482 def hasRecords(self) -> bool: 

483 """Whether this data ID contains records for all of the dimension 

484 elements it identifies. 

485 

486 Returns 

487 ------- 

488 state : `bool` 

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

490 

491 - `records` 

492 - `region` 

493 - `timespan` 

494 - `pack` 

495 

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

497 """ 

498 raise NotImplementedError() 

499 

500 @property 

501 def records(self) -> NamedKeyMapping[DimensionElement, Optional[DimensionRecord]]: 

502 """A mapping that contains `DimensionRecord` objects for all elements 

503 identified by this data ID (`NamedKeyMapping`). 

504 

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

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

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

508 

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

510 error that may raise an exception of unspecified type either 

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

512 implementation and whether assertions are enabled. 

513 """ 

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

515 return _DataCoordinateRecordsView(self) 

516 

517 @abstractmethod 

518 def _record(self, name: str) -> Optional[DimensionRecord]: 

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

520 

521 Parameters 

522 ---------- 

523 name : `str` 

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

525 ``self.graph.elements.names``. 

526 

527 Returns 

528 ------- 

529 record : `DimensionRecord` or `None` 

530 The dimension record for the given element identified by this 

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

532 """ 

533 raise NotImplementedError() 

534 

535 @property 

536 def region(self) -> Optional[Region]: 

537 """The spatial region associated with this data ID 

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

539 

540 This is `None` if and only if ``self.graph.spatial`` is empty. 

541 

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

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

544 implementation and whether assertions are enabled. 

545 """ 

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

547 regions = [] 

548 for family in self.graph.spatial: 

549 element = family.choose(self.graph.elements) 

550 record = self._record(element.name) 

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

552 return None 

553 else: 

554 regions.append(record.region) 

555 return _intersectRegions(*regions) 

556 

557 @property 

558 def timespan(self) -> Optional[Timespan]: 

559 """The temporal interval associated with this data ID 

560 (`Timespan` or `None`). 

561 

562 This is `None` if and only if ``self.graph.timespan`` is empty. 

563 

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

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

566 implementation and whether assertions are enabled. 

567 """ 

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

569 timespans = [] 

570 for family in self.graph.temporal: 

571 element = family.choose(self.graph.elements) 

572 record = self._record(element.name) 

573 # DimensionRecord subclasses for temporal elements always have 

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

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

576 return None 

577 else: 

578 timespans.append(record.timespan) 

579 return Timespan.intersection(*timespans) 

580 

581 def pack(self, name: str, *, returnMaxBits: bool = False) -> Union[Tuple[int, int], int]: 

582 """Pack this data ID into an integer. 

583 

584 Parameters 

585 ---------- 

586 name : `str` 

587 Name of the `DimensionPacker` algorithm (as defined in the 

588 dimension configuration). 

589 returnMaxBits : `bool`, optional 

590 If `True` (`False` is default), return the maximum number of 

591 nonzero bits in the returned integer across all data IDs. 

592 

593 Returns 

594 ------- 

595 packed : `int` 

596 Integer ID. This ID is unique only across data IDs that have 

597 the same values for the packer's "fixed" dimensions. 

598 maxBits : `int`, optional 

599 Maximum number of nonzero bits in ``packed``. Not returned unless 

600 ``returnMaxBits`` is `True`. 

601 

602 Notes 

603 ----- 

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

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

606 implementation and whether assertions are enabled. 

607 """ 

608 assert self.hasRecords(), "pack() may only be called if hasRecords() returns True." 

609 return self.universe.makePacker(name, self).pack(self, returnMaxBits=returnMaxBits) 

610 

611 

612DataId = Union[DataCoordinate, Mapping[str, Any]] 

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

614dictionaries and validated `DataCoordinate` instances. 

615""" 

616 

617 

618class _DataCoordinateFullView(NamedKeyMapping[Dimension, DataIdValue]): 

619 """View class that provides the default implementation for 

620 `DataCoordinate.full`. 

621 

622 Parameters 

623 ---------- 

624 target : `DataCoordinate` 

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

626 """ 

627 def __init__(self, target: DataCoordinate): 

628 self._target = target 

629 

630 __slots__ = ("_target",) 

631 

632 def __repr__(self) -> str: 

633 terms = [f"{d}: {self[d]!r}" for d in self._target.graph.dimensions.names] 

634 return "{{{}}}".format(', '.join(terms)) 

635 

636 def __getitem__(self, key: DataIdKey) -> DataIdValue: 

637 return self._target[key] 

638 

639 def __iter__(self) -> Iterator[Dimension]: 

640 return iter(self.keys()) 

641 

642 def __len__(self) -> int: 

643 return len(self.keys()) 

644 

645 def keys(self) -> NamedValueAbstractSet[Dimension]: 

646 return self._target.graph.dimensions 

647 

648 @property 

649 def names(self) -> AbstractSet[str]: 

650 # Docstring inherited from `NamedKeyMapping`. 

651 return self.keys().names 

652 

653 

654class _DataCoordinateRecordsView(NamedKeyMapping[DimensionElement, Optional[DimensionRecord]]): 

655 """View class that provides the default implementation for 

656 `DataCoordinate.records`. 

657 

658 Parameters 

659 ---------- 

660 target : `DataCoordinate` 

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

662 """ 

663 def __init__(self, target: DataCoordinate): 

664 self._target = target 

665 

666 __slots__ = ("_target",) 

667 

668 def __repr__(self) -> str: 

669 terms = [f"{d}: {self[d]!r}" for d in self._target.graph.elements.names] 

670 return "{{{}}}".format(', '.join(terms)) 

671 

672 def __str__(self) -> str: 

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

674 

675 def __getitem__(self, key: Union[DimensionElement, str]) -> Optional[DimensionRecord]: 

676 if isinstance(key, DimensionElement): 

677 key = key.name 

678 return self._target._record(key) 

679 

680 def __iter__(self) -> Iterator[DimensionElement]: 

681 return iter(self.keys()) 

682 

683 def __len__(self) -> int: 

684 return len(self.keys()) 

685 

686 def keys(self) -> NamedValueAbstractSet[DimensionElement]: 

687 return self._target.graph.elements 

688 

689 @property 

690 def names(self) -> AbstractSet[str]: 

691 # Docstring inherited from `NamedKeyMapping`. 

692 return self.keys().names 

693 

694 

695class _BasicTupleDataCoordinate(DataCoordinate): 

696 """Standard implementation of `DataCoordinate`, backed by a tuple of 

697 values. 

698 

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

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

701 methods there. 

702 

703 Parameters 

704 ---------- 

705 graph : `DimensionGraph` 

706 The dimensions to be identified. 

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

708 Data ID values, ordered to match ``graph._dataCoordinateIndices``. May 

709 include values for just required dimensions (which always come first) 

710 or all dimensions. 

711 """ 

712 def __init__(self, graph: DimensionGraph, values: Tuple[DataIdValue, ...]): 

713 self._graph = graph 

714 self._values = values 

715 

716 __slots__ = ("_graph", "_values") 

717 

718 @property 

719 def graph(self) -> DimensionGraph: 

720 # Docstring inherited from DataCoordinate. 

721 return self._graph 

722 

723 def __getitem__(self, key: DataIdKey) -> DataIdValue: 

724 # Docstring inherited from DataCoordinate. 

725 if isinstance(key, Dimension): 

726 key = key.name 

727 index = self._graph._dataCoordinateIndices[key] 

728 try: 

729 return self._values[index] 

730 except IndexError: 

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

732 # values for the required ones. 

733 raise KeyError(key) from None 

734 

735 def subset(self, graph: DimensionGraph) -> DataCoordinate: 

736 # Docstring inherited from DataCoordinate. 

737 if self._graph == graph: 

738 return self 

739 elif self.hasFull() or self._graph.required >= graph.dimensions: 

740 return _BasicTupleDataCoordinate( 

741 graph, 

742 tuple(self[k] for k in graph._dataCoordinateIndices.keys()), 

743 ) 

744 else: 

745 return _BasicTupleDataCoordinate(graph, tuple(self[k] for k in graph.required.names)) 

746 

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

748 # Docstring inherited from DataCoordinate. 

749 graph = self.graph.union(other.graph) 

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

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

752 if other.graph == graph: 

753 if self.graph == graph: 

754 # Input data IDs have the same graph (which is also the result 

755 # graph), but may not have the same content. 

756 # other might have records; self does not, so try other first. 

757 # If it at least has full values, it's no worse than self. 

758 if other.hasFull(): 

759 return other 

760 else: 

761 return self 

762 elif other.hasFull(): 

763 return other 

764 # There's some chance that neither self nor other has full values, 

765 # but together provide enough to the union to. Let the general 

766 # case below handle that. 

767 elif self.graph == graph: 

768 # No chance at returning records. If self has full values, it's 

769 # the best we can do. 

770 if self.hasFull(): 

771 return self 

772 # General case with actual merging of dictionaries. 

773 values = self.full.byName() if self.hasFull() else self.byName() 

774 values.update(other.full.byName() if other.hasFull() else other.byName()) 

775 return DataCoordinate.standardize(values, graph=graph) 

776 

777 def expanded(self, records: NameLookupMapping[DimensionElement, Optional[DimensionRecord]] 

778 ) -> DataCoordinate: 

779 # Docstring inherited from DataCoordinate 

780 values = self._values 

781 if not self.hasFull(): 

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

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

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

785 # documented this as a no-checking API. 

786 values += tuple(getattr(records[d.name], d.primaryKey.name) for d in self._graph.implied) 

787 return _ExpandedTupleDataCoordinate(self._graph, values, records) 

788 

789 def hasFull(self) -> bool: 

790 # Docstring inherited from DataCoordinate. 

791 return len(self._values) == len(self._graph._dataCoordinateIndices) 

792 

793 def hasRecords(self) -> bool: 

794 # Docstring inherited from DataCoordinate. 

795 return False 

796 

797 def _record(self, name: str) -> Optional[DimensionRecord]: 

798 # Docstring inherited from DataCoordinate. 

799 assert False 

800 

801 

802class _ExpandedTupleDataCoordinate(_BasicTupleDataCoordinate): 

803 """A `DataCoordinate` implementation that can hold `DimensionRecord` 

804 objects. 

805 

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

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

808 `DataCoordinate.expanded`. 

809 

810 Parameters 

811 ---------- 

812 graph : `DimensionGraph` 

813 The dimensions to be identified. 

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

815 Data ID values, ordered to match ``graph._dataCoordinateIndices``. 

816 May include values for just required dimensions (which always come 

817 first) or all dimensions. 

818 records : `Mapping` [ `str`, `DimensionRecord` or `None` ] 

819 A `NamedKeyMapping` with `DimensionElement` keys or a regular 

820 `Mapping` with `str` (`DimensionElement` name) keys and 

821 `DimensionRecord` values. Keys must cover all elements in 

822 ``self.graph.elements``. Values may be `None`, but only to reflect 

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

824 been fetched. 

825 """ 

826 def __init__(self, graph: DimensionGraph, values: Tuple[DataIdValue, ...], 

827 records: NameLookupMapping[DimensionElement, Optional[DimensionRecord]]): 

828 super().__init__(graph, values) 

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

830 self._records = records 

831 

832 __slots__ = ("_records",) 

833 

834 def subset(self, graph: DimensionGraph) -> DataCoordinate: 

835 # Docstring inherited from DataCoordinate. 

836 if self._graph == graph: 

837 return self 

838 return _ExpandedTupleDataCoordinate(graph, 

839 tuple(self[k] for k in graph._dataCoordinateIndices.keys()), 

840 records=self._records) 

841 

842 def expanded(self, records: NameLookupMapping[DimensionElement, Optional[DimensionRecord]] 

843 ) -> DataCoordinate: 

844 # Docstring inherited from DataCoordinate. 

845 return self 

846 

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

848 # Docstring inherited from DataCoordinate. 

849 graph = self.graph.union(other.graph) 

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

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

852 if self.graph == graph: 

853 # self has records, so even if other is also a valid result, it's 

854 # no better. 

855 return self 

856 if other.graph == graph: 

857 # If other has full values, and self does not identify some of 

858 # those, it's the base we can do. It may have records, too. 

859 if other.hasFull(): 

860 return other 

861 # If other does not have full values, there's a chance self may 

862 # provide the values needed to complete it. For example, self 

863 # could be {band} while other could be 

864 # {instrument, physical_filter, band}, with band unknown. 

865 # General case with actual merging of dictionaries. 

866 values = self.full.byName() 

867 values.update(other.full.byName() if other.hasFull() else other.byName()) 

868 basic = DataCoordinate.standardize(values, graph=graph) 

869 # See if we can add records. 

870 if self.hasRecords() and other.hasRecords(): 

871 # Sometimes the elements of a union of graphs can contain elements 

872 # that weren't in either input graph (because graph unions are only 

873 # on dimensions). e.g. {visit} | {detector} brings along 

874 # visit_detector_region. 

875 elements = set(graph.elements.names) 

876 elements -= self.graph.elements.names 

877 elements -= other.graph.elements.names 

878 if not elements: 

879 records = NamedKeyDict[DimensionElement, Optional[DimensionRecord]](self.records) 

880 records.update(other.records) 

881 return basic.expanded(records.freeze()) 

882 return basic 

883 

884 def hasFull(self) -> bool: 

885 # Docstring inherited from DataCoordinate. 

886 return True 

887 

888 def hasRecords(self) -> bool: 

889 # Docstring inherited from DataCoordinate. 

890 return True 

891 

892 def _record(self, name: str) -> Optional[DimensionRecord]: 

893 # Docstring inherited from DataCoordinate. 

894 return self._records[name]