Coverage for python / lsst / daf / butler / dimensions / _coordinate.py: 34%
389 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-22 01:49 -0800
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-22 01:49 -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/>.
28#
29# Design notes for this module are in
30# doc/lsst.daf.butler/dev/dataCoordinate.py.
31#
33from __future__ import annotations
35__all__ = (
36 "DataCoordinate",
37 "DataId",
38 "DataIdKey",
39 "DataIdValue",
40 "SerializedDataCoordinate",
41 "SerializedDataId",
42)
44import numbers
45from abc import abstractmethod
46from collections.abc import Callable, Iterable, Iterator, Mapping
47from typing import TYPE_CHECKING, Any, ClassVar, Self, TypeAlias, cast, overload
49import pydantic
51from lsst.sphgeom import IntersectionRegion, Region
53from .._exceptions import DimensionNameError
54from .._timespan import Timespan
55from ..json import from_json_pydantic, to_json_pydantic
56from ..persistence_context import PersistenceContextVars
57from ._group import DimensionGroup
58from ._records import DataIdKey, DataIdValue, DimensionRecord, SerializedDimensionRecord
60if TYPE_CHECKING: # Imports needed only for type annotations; may be circular.
61 from ..registry import Registry
62 from ._universe import DimensionUniverse
64SerializedDataId: TypeAlias = dict[str, DataIdValue]
65"""Simplified model for serializing the ``mapping`` property of
66`DataCoordinate`.
67"""
70class SerializedDataCoordinate(pydantic.BaseModel):
71 """Simplified model for serializing a `DataCoordinate`."""
73 dataId: SerializedDataId
74 records: dict[str, SerializedDimensionRecord] | None = None
76 @classmethod
77 def direct(cls, *, dataId: SerializedDataId, records: dict[str, dict] | None) -> SerializedDataCoordinate:
78 """Construct a `SerializedDataCoordinate` directly without validators.
80 Parameters
81 ----------
82 dataId : `SerializedDataId`
83 The data ID.
84 records : `dict` or `None`
85 The dimension records.
87 Notes
88 -----
89 This differs from the pydantic "construct" method in that the arguments
90 are explicitly what the model requires, and it will recurse through
91 members, constructing them from their corresponding `direct` methods.
93 This method should only be called when the inputs are trusted.
94 """
95 key = (frozenset(dataId.items()), records is not None)
96 cache = PersistenceContextVars.serializedDataCoordinateMapping.get()
97 if cache is not None and (result := cache.get(key)) is not None:
98 return result
100 if records is None:
101 serialized_records = None
102 else:
103 serialized_records = {k: SerializedDimensionRecord.direct(**v) for k, v in records.items()}
105 node = cls.model_construct(dataId=dataId, records=serialized_records)
107 if cache is not None:
108 cache[key] = node
109 return node
112def _intersectRegions(*args: Region) -> Region | None:
113 """Return the intersection of several regions.
115 For internal use by `ExpandedDataCoordinate` only.
117 If no regions are provided, returns `None`.
118 """
119 if len(args) == 0:
120 return None
121 else:
122 result = args[0]
123 for n in range(1, len(args)):
124 result = IntersectionRegion(result, args[n])
125 return result
128class DataCoordinate:
129 """A validated data ID.
131 DataCoordinate guarantees that its key-value pairs identify at least all
132 required dimensions in a `DimensionGroup`.
134 Notes
135 -----
136 `DataCoordinate` is an ABC, but it provides `staticmethod` factory
137 functions for private concrete implementations that should be sufficient
138 for most purposes. `standardize` is the most flexible and safe of these;
139 the others (`make_empty`, `from_required_values`, and `from_full_values`)
140 are more specialized and perform little or no checking of inputs.
142 Lookups for implied dimensions (those in ``self.dimensions.implied``) are
143 supported if and only if `has_full_values` is `True`. This also sets the
144 keys of the `mapping` attribute. This means that `DataCoordinate` equality
145 is not the same as testing for equality on the `mapping` attribute
146 (instead, it is the same as testing for equality on the `required`
147 attribute).
149 See also :ref:`lsst.daf.butler-dimensions_data_ids`
150 """
152 __slots__ = ()
154 _serializedType: ClassVar[type[pydantic.BaseModel]] = SerializedDataCoordinate
156 @staticmethod
157 def standardize(
158 mapping: Mapping[str, DataIdValue] | DataCoordinate | None = None,
159 *,
160 dimensions: Iterable[str] | DimensionGroup | None = None,
161 universe: DimensionUniverse | None = None,
162 defaults: DataCoordinate | None = None,
163 **kwargs: Any,
164 ) -> DataCoordinate:
165 """Standardize the supplied dataId.
167 Adapts an arbitrary mapping and/or additional arguments into a true
168 `DataCoordinate`, or augment an existing one.
170 Parameters
171 ----------
172 mapping : `~collections.abc.Mapping` or `DataCoordinate`, optional
173 An informal data ID that maps dimensions or dimension names to
174 their primary key values (may also be a true `DataCoordinate`).
175 dimensions : `~collections.abc.Iterable` [ `str` ], `DimensionGroup`, \
176 optional
177 The dimensions to be identified by the new `DataCoordinate`. If not
178 provided, will be inferred from the keys of ``mapping`` and
179 ``**kwargs``, and ``universe`` must be provided unless ``mapping``
180 is already a `DataCoordinate`.
181 universe : `DimensionUniverse`
182 All known dimensions and their relationships; used to expand and
183 validate dependencies when ``dimensions`` is not provided.
184 defaults : `DataCoordinate`, optional
185 Default dimension key-value pairs to use when needed. These are
186 never used to infer ``group``, and are ignored if a different value
187 is provided for the same key in ``mapping`` or `**kwargs``.
188 **kwargs
189 Additional keyword arguments are treated like additional key-value
190 pairs in ``mapping``.
192 Returns
193 -------
194 coordinate : `DataCoordinate`
195 A validated `DataCoordinate` instance.
197 Raises
198 ------
199 TypeError
200 Raised if the set of optional arguments provided is not supported.
201 DimensionNameError
202 Raised if a key-value pair for a required dimension is missing.
203 """
204 universe = universe or getattr(dimensions, "universe", None) or getattr(mapping, "universe", None)
205 if universe is None:
206 raise TypeError("universe must be provided, either directly or via dimensions or mapping.")
207 if dimensions is not None:
208 dimensions = universe.conform(dimensions)
209 new_mapping: dict[str, DataIdValue] = {}
210 if isinstance(mapping, DataCoordinate):
211 if dimensions is None:
212 if not kwargs:
213 # Already standardized to exactly what we want.
214 return mapping
215 elif kwargs.keys().isdisjoint(dimensions.names):
216 # User provided kwargs, but told us not to use them by
217 # passing in dimensions that are disjoint from those kwargs.
218 # This is not necessarily user error - it's a useful pattern
219 # to pass in all of the key-value pairs you have and let the
220 # code here pull out only what it needs.
221 return mapping.subset(dimensions.names)
222 new_mapping.update((name, mapping[name]) for name in mapping.dimensions.required)
223 if mapping.hasFull():
224 new_mapping.update((name, mapping[name]) for name in mapping.dimensions.implied)
225 elif mapping is not None:
226 new_mapping.update(mapping)
227 new_mapping.update(kwargs)
228 if dimensions is None:
229 if defaults is not None:
230 universe = defaults.universe
231 elif universe is None:
232 raise TypeError("universe must be provided if dimensions is not.")
233 dimensions = DimensionGroup(universe, new_mapping.keys())
234 if not dimensions:
235 return DataCoordinate.make_empty(universe)
236 # Some backends cannot handle numpy.int64 type which is a subclass of
237 # numbers.Integral; convert that to int.
238 for k, v in new_mapping.items():
239 if isinstance(v, numbers.Integral): # type: ignore
240 new_mapping[k] = int(v) # type: ignore
241 if defaults is not None:
242 for k, v in defaults.mapping.items():
243 new_mapping.setdefault(k, v)
244 if new_mapping.keys() >= dimensions.names:
245 return DataCoordinate.from_full_values(
246 dimensions, tuple(new_mapping[name] for name in dimensions.data_coordinate_keys)
247 )
248 else:
249 try:
250 values = tuple(new_mapping[name] for name in dimensions.required)
251 except KeyError as err:
252 raise DimensionNameError(
253 f"No value in data ID ({mapping}) for required dimension {err}."
254 ) from err
255 return DataCoordinate.from_required_values(dimensions, values)
257 @property
258 @abstractmethod
259 def mapping(self) -> Mapping[str, DataIdValue]:
260 """A mapping view of the data ID with keys for all dimensions it has
261 values for.
262 """
263 raise NotImplementedError()
265 @property
266 @abstractmethod
267 def required(self) -> Mapping[str, DataIdValue]:
268 """A mapping view of the data ID with keys for just its required
269 dimensions.
270 """
271 raise NotImplementedError()
273 @property
274 @abstractmethod
275 def required_values(self) -> tuple[DataIdValue, ...]:
276 """The required values (only) of this data ID as a tuple.
278 Element order is consistent with `required`.
280 In contexts where all data IDs have the same dimensions, comparing and
281 hashing these tuples can be much faster than comparing the original
282 `DataCoordinate` instances.
283 """
284 raise NotImplementedError()
286 @property
287 def full_values(self) -> tuple[DataIdValue, ...]:
288 """The full values (only) of this data ID as a tuple.
290 Element order is consistent with `DimensionGroup.data_coordinate_keys`,
291 i.e. all required dimensions followed by all implied dimensions.
292 """
293 raise ValueError(f"DataCoordinate {self} has only required values.")
295 @staticmethod
296 def makeEmpty(universe: DimensionUniverse) -> DataCoordinate:
297 """Return an empty `DataCoordinate`.
299 It identifies the null set of dimensions.
301 Parameters
302 ----------
303 universe : `DimensionUniverse`
304 Universe to which this null dimension set belongs.
306 Returns
307 -------
308 dataId : `DataCoordinate`
309 A data ID object that identifies no dimensions. `hasFull` and
310 `hasRecords` are guaranteed to return `True`, because both `full`
311 and `records` are just empty mappings.
312 """
313 return DataCoordinate.make_empty(universe)
315 @staticmethod
316 def make_empty(universe: DimensionUniverse) -> DataCoordinate:
317 """Return an empty `DataCoordinate`.
319 It identifies the null set of dimensions.
321 Parameters
322 ----------
323 universe : `DimensionUniverse`
324 Universe to which this null dimension set belongs.
326 Returns
327 -------
328 data_id : `DataCoordinate`
329 A data ID object that identifies no dimensions. `hasFull` and
330 `hasRecords` are guaranteed to return `True`, because both `full`
331 and `records` are just empty mappings.
332 """
333 return _ExpandedTupleDataCoordinate(universe.empty, (), {})
335 @staticmethod
336 def from_required_values(dimensions: DimensionGroup, values: tuple[DataIdValue, ...]) -> DataCoordinate:
337 """Construct a `DataCoordinate` from required dimension values.
339 This is a low-level interface with at most assertion-level checking of
340 inputs. Most callers should use `standardize` instead.
342 Parameters
343 ----------
344 dimensions : `DimensionGroup`
345 Dimensions this data ID will identify.
346 values : `tuple` [ `int` or `str` ]
347 Tuple of primary key values corresponding to
348 ``dimensions.required``, in that order.
350 Returns
351 -------
352 data_id : `DataCoordinate`
353 A data ID object that identifies the given dimensions.
354 ``dataId.hasFull()`` will return `True` only if
355 ``dimensions.implied`` is empty. ``dataId.hasRecords()`` will
356 return `True` if and only if ``dimensions`` is empty.
357 """
358 assert len(dimensions.required) == len(values), (
359 f"Inconsistency between dimensions {dimensions.required} and required values {values}."
360 )
361 if not dimensions:
362 return DataCoordinate.make_empty(dimensions.universe)
363 if not dimensions.implied:
364 return _FullTupleDataCoordinate(dimensions, values)
365 return _RequiredTupleDataCoordinate(dimensions, values)
367 @staticmethod
368 def from_full_values(dimensions: DimensionGroup, values: tuple[DataIdValue, ...]) -> DataCoordinate:
369 """Construct a `DataCoordinate` from all dimension values.
371 This is a low-level interface with at most assertion-level checking of
372 inputs. Most callers should use `standardize` instead.
374 Parameters
375 ----------
376 dimensions : `DimensionGroup`
377 Dimensions this data ID will identify.
378 values : `tuple` [ `int` or `str` ]
379 Tuple of primary key values corresponding to
380 ``itertools.chain(dimensions.required, dimensions.implied)``, in
381 that order. Note that this is _not_ the same order as
382 ``dimensions.names``, though these contain the same elements.
384 Returns
385 -------
386 data_id : `DataCoordinate`
387 A data ID object that identifies the given dimensions.
388 ``dataId.hasFull()`` will always return `True`.
389 ``dataId.hasRecords()`` will only return `True` if ``dimensions``
390 is empty.
391 """
392 assert len(dimensions) == len(values), (
393 f"Inconsistency between dimensions {dimensions.data_coordinate_keys} and full values {values}."
394 )
395 if not dimensions:
396 return DataCoordinate.make_empty(dimensions.universe)
397 return _FullTupleDataCoordinate(dimensions, values)
399 def __bool__(self) -> bool:
400 return bool(self.dimensions)
402 def __hash__(self) -> int:
403 return hash((self.dimensions,) + self.required_values)
405 def __eq__(self, other: Any) -> bool:
406 if not isinstance(other, DataCoordinate):
407 other = DataCoordinate.standardize(other, universe=self.universe)
408 return self.dimensions == other.dimensions and self.required_values == other.required_values
410 @abstractmethod
411 def __getitem__(self, key: str) -> DataIdValue:
412 raise NotImplementedError()
414 def __contains__(self, key: str) -> bool:
415 try:
416 self.__getitem__(key)
417 return True
418 except KeyError:
419 return False
421 @overload
422 def get(self, key: str) -> DataIdValue | None: ... 422 ↛ exitline 422 didn't return from function 'get' because
424 @overload
425 def get(self, key: str, default: int) -> int: ... 425 ↛ exitline 425 didn't return from function 'get' because
427 @overload
428 def get(self, key: str, default: str) -> str: ... 428 ↛ exitline 428 didn't return from function 'get' because
430 @overload
431 def get(self, key: str, default: DataIdValue | None) -> DataIdValue | None: ... 431 ↛ exitline 431 didn't return from function 'get' because
433 def get(self, key: str, default: DataIdValue | None = None) -> DataIdValue | None:
434 try:
435 return self.__getitem__(key)
436 except KeyError:
437 return default
439 def __repr__(self) -> str:
440 # We can't make repr yield something that could be exec'd here without
441 # printing out the whole DimensionUniverse.
442 return str(self.mapping)
444 def __lt__(self, other: Any) -> bool:
445 if not isinstance(other, DataCoordinate):
446 return NotImplemented
447 # Unlike repr() we only use required keys here to ensure that __eq__
448 # can not be true simultaneously with __lt__ being true.
449 return self.required_values < other.required_values
451 @abstractmethod
452 def subset(self, dimensions: DimensionGroup | Iterable[str]) -> DataCoordinate:
453 """Return a `DataCoordinate` whose diensions are a subset of
454 ``self.dimensions``.
456 Parameters
457 ----------
458 dimensions : `DimensionGroup` or `~collections.abc.Iterable` [ `str` ]
459 The dimensions identified by the returned `DataCoordinate`.
461 Returns
462 -------
463 coordinate : `DataCoordinate`
464 A `DataCoordinate` instance that identifies only the given
465 dimensions. May be ``self`` if ``dimensions == self.dimensions``.
467 Raises
468 ------
469 KeyError
470 Raised if the primary key value for one or more required dimensions
471 is unknown. This may happen even if the required subset of the new
472 dimensions are not a subset of the dimensions actually known by
473 this data ID.. As an example, consider trying to go from a data ID
474 with dimensions {instrument, physical_filter, band} to just
475 {instrument, band}; band is implied by physical_filter and hence
476 would have no value in the original data ID if ``self.hasFull()``
477 is `False`.
479 Notes
480 -----
481 If `hasFull` and `hasRecords` return `True` on ``self``, they will
482 return `True` (respectively) on the returned `DataCoordinate` as well.
483 The converse does not hold.
484 """
485 raise NotImplementedError()
487 @abstractmethod
488 def union(self, other: DataCoordinate) -> DataCoordinate:
489 """Combine two data IDs.
491 Yields a new one that identifies all dimensions that either of them
492 identify.
494 Parameters
495 ----------
496 other : `DataCoordinate`
497 Data ID to combine with ``self``.
499 Returns
500 -------
501 unioned : `DataCoordinate`
502 A `DataCoordinate` instance that satisfies
503 ``unioned.dimensions == self.dimensions.union(other.dimensions)``.
504 Will preserve ``hasFull`` and ``hasRecords`` whenever possible.
506 Notes
507 -----
508 No checking for consistency is performed on values for keys that
509 ``self`` and ``other`` have in common, and which value is included in
510 the returned data ID is not specified.
511 """
512 raise NotImplementedError()
514 @abstractmethod
515 def expanded(self, records: Mapping[str, DimensionRecord | None]) -> DataCoordinate:
516 """Return a `DataCoordinate` that holds the given records.
518 Guarantees that `hasRecords` returns `True`.
520 This is a low-level interface with at most assertion-level checking of
521 inputs. Most callers should use `Registry.expandDataId` instead.
523 Parameters
524 ----------
525 records : `~collections.abc.Mapping` [ `str`, `DimensionRecord` or \
526 `None` ]
527 A`~collections.abc.Mapping` with `str` (dimension element name)
528 keys and `DimensionRecord` values. Keys must cover all elements in
529 ``self.dimensions.elements``. Values may be `None`, but only to
530 reflect actual NULL values in the database, not just records that
531 have not been fetched.
532 """
533 raise NotImplementedError()
535 @property
536 def universe(self) -> DimensionUniverse:
537 """Universe that defines all known compatible dimensions.
539 The universe will be compatible with this coordinate
540 (`DimensionUniverse`).
541 """
542 return self.dimensions.universe
544 @property
545 @abstractmethod
546 def dimensions(self) -> DimensionGroup:
547 """Dimensions identified by this data ID (`DimensionGroup`).
549 Note that values are only required to be present for dimensions in
550 ``self.dimensions.required``; all others may be retrieved (from a
551 `Registry`) given these.
552 """
553 raise NotImplementedError()
555 @abstractmethod
556 def hasFull(self) -> bool:
557 """Whether this data ID contains implied and required values.
559 Returns
560 -------
561 state : `bool`
562 If `True`, `__getitem__`, `get`, and `__contains__` (but not
563 `keys`!) will act as though the mapping includes key-value pairs
564 for implied dimensions, and the `full` property may be used. If
565 `False`, these operations only include key-value pairs for required
566 dimensions, and accessing `full` is an error. Always `True` if
567 there are no implied dimensions.
568 """
569 raise NotImplementedError()
571 @abstractmethod
572 def hasRecords(self) -> bool:
573 """Whether this data ID contains records.
575 These are the records for all of the dimension elements it identifies.
577 Returns
578 -------
579 state : `bool`
580 If `True`, the following attributes may be accessed:
582 - `records`
583 - `region`
584 - `timespan`
585 - `pack`
587 If `False`, accessing any of these is considered a logic error.
588 """
589 raise NotImplementedError()
591 @property
592 def records(self) -> Mapping[str, DimensionRecord | None]:
593 """A mapping that contains `DimensionRecord` objects for all
594 elements identified by this data ID.
596 Notes
597 -----
598 The values of this mapping may be `None` if and only if there is no
599 record for that element with these dimensions in the database (which
600 means some foreign key field must have a NULL value).
602 Accessing this attribute if `hasRecords` returns `False` is a logic
603 error that may raise an exception of unspecified type either
604 immediately or when the returned mapping is used, depending on the
605 implementation and whether assertions are enabled.
606 """
607 assert self.hasRecords(), "records may only be accessed if hasRecords() returns True."
608 return _DataCoordinateRecordsView(self)
610 @abstractmethod
611 def _record(self, name: str) -> DimensionRecord | None:
612 """Protected implementation hook that backs the ``records`` attribute.
614 Parameters
615 ----------
616 name : `str`
617 The name of a `DimensionElement`, guaranteed to be in
618 ``self.dimensions.elements``.
620 Returns
621 -------
622 record : `DimensionRecord` or `None`
623 The dimension record for the given element identified by this
624 data ID, or `None` if there is no such record.
625 """
626 raise NotImplementedError()
628 @property
629 def region(self) -> Region | None:
630 """Spatial region associated with this data ID.
632 (`lsst.sphgeom.Region` or `None`).
634 This is `None` if and only if ``self.dimensions.spatial`` is empty.
636 Accessing this attribute if `hasRecords` returns `False` is a logic
637 error that may or may not raise an exception, depending on the
638 implementation and whether assertions are enabled.
639 """
640 assert self.hasRecords(), "region may only be accessed if hasRecords() returns True."
641 regions = []
642 for family in self.dimensions.spatial:
643 element = family.choose(self.dimensions)
644 record = self._record(element.name)
645 if record is None or record.region is None:
646 return None
647 else:
648 regions.append(record.region)
649 return _intersectRegions(*regions)
651 @property
652 def timespan(self) -> Timespan | None:
653 """Temporal interval associated with this data ID.
655 (`Timespan` or `None`).
657 This is `None` if and only if ``self.dimensions.temporal`` is empty.
659 Accessing this attribute if `hasRecords` returns `False` is a logic
660 error that may or may not raise an exception, depending on the
661 implementation and whether assertions are enabled.
662 """
663 assert self.hasRecords(), "timespan may only be accessed if hasRecords() returns True."
664 timespans = []
665 for family in self.dimensions.temporal:
666 element = family.choose(self.dimensions)
667 record = self._record(element.name)
668 # DimensionRecord subclasses for temporal elements always have
669 # .timespan, but they're dynamic so this can't be type-checked.
670 if record is None or record.timespan is None:
671 return None
672 else:
673 timespans.append(record.timespan)
674 if not timespans:
675 return None
676 elif len(timespans) == 1:
677 return timespans[0]
678 else:
679 return Timespan.intersection(*timespans)
681 def to_simple(self, minimal: bool = False) -> SerializedDataCoordinate:
682 """Convert this class to a simple python type.
684 This is suitable for serialization.
686 Parameters
687 ----------
688 minimal : `bool`, optional
689 Use minimal serialization. If set the records will not be attached.
691 Returns
692 -------
693 simple : `SerializedDataCoordinate`
694 The object converted to simple form.
695 """
696 # Convert to a dict form
697 records: dict[str, SerializedDimensionRecord] | None
698 if not minimal and self.hasRecords():
699 records = {
700 k: v.to_simple() for k in self.dimensions.elements if (v := self.records[k]) is not None
701 }
702 else:
703 records = None
705 return SerializedDataCoordinate(dataId=dict(self.mapping), records=records)
707 @classmethod
708 def from_simple(
709 cls,
710 simple: SerializedDataCoordinate,
711 universe: DimensionUniverse | None = None,
712 registry: Registry | None = None,
713 ) -> DataCoordinate:
714 """Construct a new object from the simplified form.
716 The data is assumed to be of the form returned from the `to_simple`
717 method.
719 Parameters
720 ----------
721 simple : `dict` of [`str`, `Any`]
722 The `dict` returned by `to_simple()`.
723 universe : `DimensionUniverse`
724 Object that manages all known dimensions.
725 registry : `lsst.daf.butler.Registry`, optional
726 Registry from which a universe can be extracted. Can be `None`
727 if universe is provided explicitly.
729 Returns
730 -------
731 dataId : `DataCoordinate`
732 Newly-constructed object.
733 """
734 key = (frozenset(simple.dataId.items()), simple.records is not None)
735 cache = PersistenceContextVars.dataCoordinates.get()
736 if cache is not None and (result := cache.get(key)) is not None:
737 return result
738 if universe is None and registry is None:
739 raise ValueError("One of universe or registry is required to convert a dict to a DataCoordinate")
740 if universe is None and registry is not None:
741 universe = registry.dimensions
742 if universe is None:
743 # this is for mypy
744 raise ValueError("Unable to determine a usable universe")
746 dataId = cls.standardize(simple.dataId, universe=universe)
747 if simple.records:
748 dataId = dataId.expanded(
749 {k: DimensionRecord.from_simple(v, universe=universe) for k, v in simple.records.items()}
750 )
751 if cache is not None:
752 cache[key] = dataId
753 return dataId
755 to_json = to_json_pydantic
756 from_json: ClassVar[Callable[..., Self]] = cast(Callable[..., Self], classmethod(from_json_pydantic))
758 @property
759 def dataId(self) -> Self:
760 """Return this `DataCoordinate` instance, unmodified."""
761 return self
764DataId = DataCoordinate | Mapping[str, Any]
765"""A type-annotation alias for signatures that accept both informal data ID
766dictionaries and validated `DataCoordinate` instances.
767"""
770class _DataCoordinateRecordsView(Mapping[str, DimensionRecord | None]):
771 """View class for `DataCoordinate.records`.
773 Provides the default implementation for
774 `DataCoordinate.records`.
776 Parameters
777 ----------
778 target : `DataCoordinate`
779 The `DataCoordinate` instance this object provides a view of.
780 """
782 def __init__(self, target: DataCoordinate):
783 self._target = target
785 __slots__ = ("_target",)
787 def __repr__(self) -> str:
788 terms = [f"{d}: {self[d]!r}" for d in self._target.dimensions.elements]
789 return "{{{}}}".format(", ".join(terms))
791 def __str__(self) -> str:
792 return "\n".join(str(v) for v in self.values())
794 def __getitem__(self, key: str) -> DimensionRecord | None:
795 return self._target._record(key)
797 def __iter__(self) -> Iterator[str]:
798 return iter(self._target.dimensions.elements)
800 def __len__(self) -> int:
801 return len(self._target.dimensions.elements)
804class _BasicTupleDataCoordinate(DataCoordinate):
805 """Intermediate base class for the standard implementation of
806 `DataCoordinate`.
808 This class should only be accessed outside this module via the
809 `DataCoordinate` interface, and should only be constructed via the static
810 methods there.
812 Parameters
813 ----------
814 dimensions : `DimensionGroup`
815 The dimensions to be identified.
816 values : `tuple` [ `int` or `str` ]
817 Data ID values, ordered to match
818 ``dimensions.data_coordinate_keys``. May include values for just
819 required dimensions (which always come first) or all dimensions
820 (concrete subclasses implementations will care which).
821 """
823 def __init__(self, dimensions: DimensionGroup, values: tuple[DataIdValue, ...]):
824 self._dimensions = dimensions
825 self._values = values
827 __slots__ = ("_dimensions", "_values")
829 @property
830 def dimensions(self) -> DimensionGroup:
831 # Docstring inherited from DataCoordinate.
832 return self._dimensions
834 @property
835 def required(self) -> Mapping[str, DataIdValue]:
836 # Docstring inherited from DataCoordinate.
837 return _DataCoordinateRequiredMappingView(self)
839 def __getitem__(self, key: str) -> DataIdValue:
840 # Docstring inherited from DataCoordinate.
842 index = self._dimensions._data_coordinate_indices[key]
843 try:
844 return self._values[index]
845 except IndexError:
846 # Caller asked for an implied dimension, but this object only has
847 # values for the required ones.
848 raise KeyError(key) from None
850 def hasRecords(self) -> bool:
851 # Docstring inherited from DataCoordinate.
852 return False
854 def _record(self, name: str) -> DimensionRecord | None:
855 # Docstring inherited from DataCoordinate.
856 raise AssertionError()
858 def __getattr__(self, name: str) -> Any:
859 if name in self.dimensions.elements:
860 raise AttributeError(
861 f"Dimension record attribute {name!r} is only available on expanded DataCoordinates."
862 )
863 raise AttributeError(name)
866class _DataCoordinateRequiredMappingView(Mapping[str, DataIdValue]):
867 """A DataCoordinate Mapping view class whose keys are just the required
868 dimensions.
869 """
871 def __init__(self, target: DataCoordinate):
872 self._target = target
874 __slots__ = ("_target",)
876 def __getitem__(self, key: str) -> DataIdValue:
877 if key not in self._target.dimensions.required:
878 raise KeyError(key)
879 return self._target[key]
881 def __len__(self) -> int:
882 return len(self._target.dimensions.required)
884 def __iter__(self) -> Iterator[str]:
885 return iter(self._target.dimensions.required)
887 def __repr__(self) -> str:
888 return f"{{{', '.join(f'{k}: {v!r}' for k, v in self.items())}}}"
891class _DataCoordinateFullMappingView(Mapping[str, DataIdValue]):
892 """A DataCoordinate Mapping view class whose keys are all dimensions."""
894 def __init__(self, target: DataCoordinate):
895 self._target = target
897 __slots__ = ("_target",)
899 def __getitem__(self, key: str) -> DataIdValue:
900 return self._target[key]
902 def __len__(self) -> int:
903 return len(self._target.dimensions)
905 def __iter__(self) -> Iterator[str]:
906 return iter(self._target.dimensions.data_coordinate_keys)
908 def __repr__(self) -> str:
909 return f"{{{', '.join(f'{k}: {v!r}' for k, v in self.items())}}}"
912class _RequiredTupleDataCoordinate(_BasicTupleDataCoordinate):
913 """A `DataCoordinate` implementation that has values for required
914 dimensions only, when implied dimensions already exist.
916 Note that `_FullTupleDataCoordinate` should be used if there are no
917 implied dimensions.
919 This class should only be accessed outside this module via the
920 `DataCoordinate` interface, and should only be constructed via calls to
921 `DataCoordinate.from_full_values`.
922 """
924 __slots__ = ()
926 @property
927 def mapping(self) -> Mapping[str, DataIdValue]:
928 # Docstring inherited from DataCoordinate.
929 return _DataCoordinateRequiredMappingView(self)
931 @property
932 def required_values(self) -> tuple[DataIdValue, ...]:
933 # Docstring inherited from DataCoordinate.
934 return self._values
936 def subset(self, dimensions: DimensionGroup | Iterable[str]) -> DataCoordinate:
937 # Docstring inherited from DataCoordinate.
938 dimensions = self.universe.conform(dimensions)
939 if self._dimensions == dimensions:
940 return self
941 elif self._dimensions.required >= dimensions.names:
942 return DataCoordinate.from_full_values(
943 dimensions,
944 tuple(self[k] for k in dimensions.data_coordinate_keys),
945 )
946 else:
947 return DataCoordinate.from_required_values(
948 dimensions, tuple(self[k] for k in dimensions.required)
949 )
951 def union(self, other: DataCoordinate) -> DataCoordinate:
952 # Docstring inherited from DataCoordinate.
953 dimensions = self.dimensions.union(other.dimensions)
954 # See if the other one is already what we want to return. We don't
955 # shortcut-return 'self' because `other` might have full values or
956 # even records, and we want to return the more complete data ID.
957 if other.dimensions == dimensions:
958 return other
959 # General case with actual merging of dictionaries.
960 values = dict(self.mapping)
961 values.update(other.mapping)
962 return DataCoordinate.standardize(values, dimensions=dimensions)
964 def expanded(self, records: Mapping[str, DimensionRecord | None]) -> DataCoordinate:
965 # Docstring inherited from DataCoordinate
966 # Extract a complete values tuple from the attributes of the given
967 # records. It's possible for these to be inconsistent with
968 # self._values (which is a serious problem, of course), but we've
969 # documented this as a no-checking API.
970 values = self._values + tuple(
971 getattr(records[d], self.universe.dimensions[d].primaryKey.name) for d in self._dimensions.implied
972 )
973 return _ExpandedTupleDataCoordinate(self._dimensions, values, records)
975 def hasFull(self) -> bool:
976 # Docstring inherited from DataCoordinate.
977 return False
979 def __reduce__(self) -> tuple[Any, ...]:
980 return (_RequiredTupleDataCoordinate, (self._dimensions, self._values))
983class _FullTupleDataCoordinate(_BasicTupleDataCoordinate):
984 """A `DataCoordinate` implementation that has values for all dimensions.
986 This class should only be accessed outside this module via the
987 `DataCoordinate` interface, and should only be constructed via calls to
988 `DataCoordinate.from_full_values`.
989 """
991 __slots__ = ()
993 @property
994 def mapping(self) -> Mapping[str, DataIdValue]:
995 # Docstring inherited from DataCoordinate.
996 return _DataCoordinateFullMappingView(self)
998 @property
999 def required_values(self) -> tuple[DataIdValue, ...]:
1000 # Docstring inherited from DataCoordinate.
1001 return self._values[: len(self._dimensions.required)]
1003 @property
1004 def full_values(self) -> tuple[DataIdValue, ...]:
1005 # Docstring inherited from DataCoordinate.
1006 return self._values
1008 def subset(self, dimensions: DimensionGroup | Iterable[str]) -> DataCoordinate:
1009 # Docstring inherited from DataCoordinate.
1010 dimensions = self.universe.conform(dimensions)
1011 if self._dimensions == dimensions:
1012 return self
1014 try:
1015 values = tuple(self[k] for k in dimensions.data_coordinate_keys)
1016 except KeyError as e:
1017 raise DimensionNameError(f"Data ID is missing value for dimension {str(e)}.")
1018 return DataCoordinate.from_full_values(dimensions, values)
1020 def union(self, other: DataCoordinate) -> DataCoordinate:
1021 # Docstring inherited from DataCoordinate.
1022 dimensions = self.dimensions.union(other.dimensions)
1023 # See if one or both input data IDs is already what we want to return;
1024 # if so, return the most complete one we have.
1025 if other.dimensions == dimensions and other.hasRecords():
1026 return other
1027 elif self.dimensions == dimensions and not other.hasRecords():
1028 return self
1029 # General case with actual merging of dictionaries.
1030 values = dict(self.mapping)
1031 values.update(other.mapping)
1032 return DataCoordinate.standardize(values, dimensions=dimensions)
1034 def expanded(self, records: Mapping[str, DimensionRecord | None]) -> DataCoordinate:
1035 # Docstring inherited from DataCoordinate
1036 return _ExpandedTupleDataCoordinate(self._dimensions, self._values, records)
1038 def hasFull(self) -> bool:
1039 # Docstring inherited from DataCoordinate.
1040 return True
1042 def __reduce__(self) -> tuple[Any, ...]:
1043 return (_FullTupleDataCoordinate, (self._dimensions, self._values))
1046class _ExpandedTupleDataCoordinate(_FullTupleDataCoordinate):
1047 """A `DataCoordinate` implementation that directly holds `DimensionRecord`
1048 objects relevant to it.
1050 This class should only be accessed outside this module via the
1051 `DataCoordinate` interface, and should only be constructed via calls to
1052 `DataCoordinate.expanded`.
1054 Parameters
1055 ----------
1056 dimensions : `DimensionGroup`
1057 The dimensions to be identified.
1058 values : `tuple` [ `int` or `str` ]
1059 Data ID values, ordered to match
1060 ``dimensions._data_coordinate_indices``. Just include values for all
1061 dimensions.
1062 records : `~collections.abc.Mapping` [ `str`, `DimensionRecord` or `None` ]
1063 A `~collections.abc.Mapping` with `str` (dimension element name) keys
1064 and `DimensionRecord` values. Keys must cover all elements in
1065 ``self.dimensions.elements``. Values may be `None`, but only to
1066 reflect actual NULL values in the database, not just records that have
1067 not been fetched.
1068 """
1070 def __init__(
1071 self,
1072 dimensions: DimensionGroup,
1073 values: tuple[DataIdValue, ...],
1074 records: Mapping[str, DimensionRecord | None],
1075 ):
1076 super().__init__(dimensions, values)
1077 assert super().hasFull(), "This implementation requires full dimension records."
1078 self._records = records
1080 __slots__ = ("_records",)
1082 def subset(self, dimensions: DimensionGroup | Iterable[str]) -> DataCoordinate:
1083 # Docstring inherited from DataCoordinate.
1084 return super().subset(dimensions).expanded(self._records)
1086 def expanded(self, records: Mapping[str, DimensionRecord | None]) -> DataCoordinate:
1087 # Docstring inherited from DataCoordinate.
1088 return self
1090 def union(self, other: DataCoordinate) -> DataCoordinate:
1091 # Docstring inherited from DataCoordinate.
1092 result = super().union(other)
1093 if not result.hasRecords() and other.hasRecords():
1094 records = {e: self._record(e) for e in self.dimensions.elements} | {
1095 e: other._record(e) for e in other.dimensions.elements
1096 }
1097 if records.keys() >= result.dimensions.elements:
1098 return result.expanded(records)
1099 return result
1101 def hasRecords(self) -> bool:
1102 # Docstring inherited from DataCoordinate.
1103 return True
1105 def _record(self, name: str) -> DimensionRecord | None:
1106 # Docstring inherited from DataCoordinate.
1107 return self._records[name]
1109 def __reduce__(self) -> tuple[Any, ...]:
1110 return (_ExpandedTupleDataCoordinate, (self._dimensions, self._values, self._records))
1112 def __getattr__(self, name: str) -> Any:
1113 try:
1114 return self._record(name)
1115 except KeyError:
1116 raise AttributeError(name) from None
1118 def __dir__(self) -> list[str]:
1119 result = list(super().__dir__())
1120 result.extend(self.dimensions.elements)
1121 return result