Coverage for python / lsst / daf / butler / dimensions / _coordinate.py: 34%
389 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-07 01:11 -0800
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-07 01:11 -0800
1# This file is part of daf_butler.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (http://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This software is dual licensed under the GNU General Public License and also
10# under a 3-clause BSD license. Recipients may choose which of these licenses
11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt,
12# respectively. If you choose the GPL option then the following text applies
13# (but note that there is still no warranty even if you opt for BSD instead):
14#
15# This program is free software: you can redistribute it and/or modify
16# it under the terms of the GNU General Public License as published by
17# the Free Software Foundation, either version 3 of the License, or
18# (at your option) any later version.
19#
20# This program is distributed in the hope that it will be useful,
21# but WITHOUT ANY WARRANTY; without even the implied warranty of
22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23# GNU General Public License for more details.
24#
25# You should have received a copy of the GNU General Public License
26# along with this program. If not, see <http://www.gnu.org/licenses/>.
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 "SerializedDataCoordinate",
39 "SerializedDataId",
40)
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
47import pydantic
49from lsst.sphgeom import IntersectionRegion, Region
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
58if TYPE_CHECKING: # Imports needed only for type annotations; may be circular.
59 from ..registry import Registry
60 from ._universe import DimensionUniverse
62SerializedDataId: TypeAlias = dict[str, DataIdValue]
63"""Simplified model for serializing the ``mapping`` property of
64`DataCoordinate`.
65"""
68class SerializedDataCoordinate(pydantic.BaseModel):
69 """Simplified model for serializing a `DataCoordinate`."""
71 dataId: SerializedDataId
72 records: dict[str, SerializedDimensionRecord] | None = None
74 @classmethod
75 def direct(cls, *, dataId: SerializedDataId, records: dict[str, dict] | None) -> SerializedDataCoordinate:
76 """Construct a `SerializedDataCoordinate` directly without validators.
78 Parameters
79 ----------
80 dataId : `SerializedDataId`
81 The data ID.
82 records : `dict` or `None`
83 The dimension records.
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.
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
98 if records is None:
99 serialized_records = None
100 else:
101 serialized_records = {k: SerializedDimensionRecord.direct(**v) for k, v in records.items()}
103 node = cls.model_construct(dataId=dataId, records=serialized_records)
105 if cache is not None:
106 cache[key] = node
107 return node
110def _intersectRegions(*args: Region) -> Region | None:
111 """Return the intersection of several regions.
113 For internal use by `ExpandedDataCoordinate` only.
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
126class DataCoordinate:
127 """A validated data ID.
129 DataCoordinate guarantees that its key-value pairs identify at least all
130 required dimensions in a `DimensionGroup`.
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.
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).
147 See also :ref:`lsst.daf.butler-dimensions_data_ids`
148 """
150 __slots__ = ()
152 _serializedType: ClassVar[type[pydantic.BaseModel]] = SerializedDataCoordinate
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.
165 Adapts an arbitrary mapping and/or additional arguments into a true
166 `DataCoordinate`, or augment an existing one.
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``.
190 Returns
191 -------
192 coordinate : `DataCoordinate`
193 A validated `DataCoordinate` instance.
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)
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()
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()
271 @property
272 @abstractmethod
273 def required_values(self) -> tuple[DataIdValue, ...]:
274 """The required values (only) of this data ID as a tuple.
276 Element order is consistent with `required`.
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()
284 @property
285 def full_values(self) -> tuple[DataIdValue, ...]:
286 """The full values (only) of this data ID as a tuple.
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.")
293 @staticmethod
294 def makeEmpty(universe: DimensionUniverse) -> DataCoordinate:
295 """Return an empty `DataCoordinate`.
297 It identifies the null set of dimensions.
299 Parameters
300 ----------
301 universe : `DimensionUniverse`
302 Universe to which this null dimension set belongs.
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)
313 @staticmethod
314 def make_empty(universe: DimensionUniverse) -> DataCoordinate:
315 """Return an empty `DataCoordinate`.
317 It identifies the null set of dimensions.
319 Parameters
320 ----------
321 universe : `DimensionUniverse`
322 Universe to which this null dimension set belongs.
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, (), {})
333 @staticmethod
334 def from_required_values(dimensions: DimensionGroup, values: tuple[DataIdValue, ...]) -> DataCoordinate:
335 """Construct a `DataCoordinate` from required dimension values.
337 This is a low-level interface with at most assertion-level checking of
338 inputs. Most callers should use `standardize` instead.
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.
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)
365 @staticmethod
366 def from_full_values(dimensions: DimensionGroup, values: tuple[DataIdValue, ...]) -> DataCoordinate:
367 """Construct a `DataCoordinate` from all dimension values.
369 This is a low-level interface with at most assertion-level checking of
370 inputs. Most callers should use `standardize` instead.
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.
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)
397 def __bool__(self) -> bool:
398 return bool(self.dimensions)
400 def __hash__(self) -> int:
401 return hash((self.dimensions,) + self.required_values)
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
408 @abstractmethod
409 def __getitem__(self, key: str) -> DataIdValue:
410 raise NotImplementedError()
412 def __contains__(self, key: str) -> bool:
413 try:
414 self.__getitem__(key)
415 return True
416 except KeyError:
417 return False
419 @overload
420 def get(self, key: str) -> DataIdValue | None: ... 420 ↛ exitline 420 didn't return from function 'get' because
422 @overload
423 def get(self, key: str, default: int) -> int: ... 423 ↛ exitline 423 didn't return from function 'get' because
425 @overload
426 def get(self, key: str, default: str) -> str: ... 426 ↛ exitline 426 didn't return from function 'get' because
428 @overload
429 def get(self, key: str, default: DataIdValue | None) -> DataIdValue | None: ... 429 ↛ exitline 429 didn't return from function 'get' because
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
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)
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
449 @abstractmethod
450 def subset(self, dimensions: DimensionGroup | Iterable[str]) -> DataCoordinate:
451 """Return a `DataCoordinate` whose diensions are a subset of
452 ``self.dimensions``.
454 Parameters
455 ----------
456 dimensions : `DimensionGroup` or `~collections.abc.Iterable` [ `str` ]
457 The dimensions identified by the returned `DataCoordinate`.
459 Returns
460 -------
461 coordinate : `DataCoordinate`
462 A `DataCoordinate` instance that identifies only the given
463 dimensions. May be ``self`` if ``dimensions == self.dimensions``.
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`.
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()
485 @abstractmethod
486 def union(self, other: DataCoordinate) -> DataCoordinate:
487 """Combine two data IDs.
489 Yields a new one that identifies all dimensions that either of them
490 identify.
492 Parameters
493 ----------
494 other : `DataCoordinate`
495 Data ID to combine with ``self``.
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.
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()
512 @abstractmethod
513 def expanded(self, records: Mapping[str, DimensionRecord | None]) -> DataCoordinate:
514 """Return a `DataCoordinate` that holds the given records.
516 Guarantees that `hasRecords` returns `True`.
518 This is a low-level interface with at most assertion-level checking of
519 inputs. Most callers should use `Registry.expandDataId` instead.
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()
533 @property
534 def universe(self) -> DimensionUniverse:
535 """Universe that defines all known compatible dimensions.
537 The universe will be compatible with this coordinate
538 (`DimensionUniverse`).
539 """
540 return self.dimensions.universe
542 @property
543 @abstractmethod
544 def dimensions(self) -> DimensionGroup:
545 """Dimensions identified by this data ID (`DimensionGroup`).
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()
553 @abstractmethod
554 def hasFull(self) -> bool:
555 """Whether this data ID contains implied and required values.
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()
569 @abstractmethod
570 def hasRecords(self) -> bool:
571 """Whether this data ID contains records.
573 These are the records for all of the dimension elements it identifies.
575 Returns
576 -------
577 state : `bool`
578 If `True`, the following attributes may be accessed:
580 - `records`
581 - `region`
582 - `timespan`
583 - `pack`
585 If `False`, accessing any of these is considered a logic error.
586 """
587 raise NotImplementedError()
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.
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).
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)
608 @abstractmethod
609 def _record(self, name: str) -> DimensionRecord | None:
610 """Protected implementation hook that backs the ``records`` attribute.
612 Parameters
613 ----------
614 name : `str`
615 The name of a `DimensionElement`, guaranteed to be in
616 ``self.dimensions.elements``.
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()
626 @property
627 def region(self) -> Region | None:
628 """Spatial region associated with this data ID.
630 (`lsst.sphgeom.Region` or `None`).
632 This is `None` if and only if ``self.dimensions.spatial`` is empty.
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)
649 @property
650 def timespan(self) -> Timespan | None:
651 """Temporal interval associated with this data ID.
653 (`Timespan` or `None`).
655 This is `None` if and only if ``self.dimensions.temporal`` is empty.
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)
679 def to_simple(self, minimal: bool = False) -> SerializedDataCoordinate:
680 """Convert this class to a simple python type.
682 This is suitable for serialization.
684 Parameters
685 ----------
686 minimal : `bool`, optional
687 Use minimal serialization. If set the records will not be attached.
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
703 return SerializedDataCoordinate(dataId=dict(self.mapping), records=records)
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.
714 The data is assumed to be of the form returned from the `to_simple`
715 method.
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.
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")
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
753 to_json = to_json_pydantic
754 from_json: ClassVar[Callable[..., Self]] = cast(Callable[..., Self], classmethod(from_json_pydantic))
756 @property
757 def dataId(self) -> Self:
758 """Return this `DataCoordinate` instance, unmodified."""
759 return self
762DataId = DataCoordinate | Mapping[str, Any]
763"""A type-annotation alias for signatures that accept both informal data ID
764dictionaries and validated `DataCoordinate` instances.
765"""
768class _DataCoordinateRecordsView(Mapping[str, DimensionRecord | None]):
769 """View class for `DataCoordinate.records`.
771 Provides the default implementation for
772 `DataCoordinate.records`.
774 Parameters
775 ----------
776 target : `DataCoordinate`
777 The `DataCoordinate` instance this object provides a view of.
778 """
780 def __init__(self, target: DataCoordinate):
781 self._target = target
783 __slots__ = ("_target",)
785 def __repr__(self) -> str:
786 terms = [f"{d}: {self[d]!r}" for d in self._target.dimensions.elements]
787 return "{{{}}}".format(", ".join(terms))
789 def __str__(self) -> str:
790 return "\n".join(str(v) for v in self.values())
792 def __getitem__(self, key: str) -> DimensionRecord | None:
793 return self._target._record(key)
795 def __iter__(self) -> Iterator[str]:
796 return iter(self._target.dimensions.elements)
798 def __len__(self) -> int:
799 return len(self._target.dimensions.elements)
802class _BasicTupleDataCoordinate(DataCoordinate):
803 """Intermediate base class for the standard implementation of
804 `DataCoordinate`.
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.
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 """
821 def __init__(self, dimensions: DimensionGroup, values: tuple[DataIdValue, ...]):
822 self._dimensions = dimensions
823 self._values = values
825 __slots__ = ("_dimensions", "_values")
827 @property
828 def dimensions(self) -> DimensionGroup:
829 # Docstring inherited from DataCoordinate.
830 return self._dimensions
832 @property
833 def required(self) -> Mapping[str, DataIdValue]:
834 # Docstring inherited from DataCoordinate.
835 return _DataCoordinateRequiredMappingView(self)
837 def __getitem__(self, key: str) -> DataIdValue:
838 # Docstring inherited from DataCoordinate.
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
848 def hasRecords(self) -> bool:
849 # Docstring inherited from DataCoordinate.
850 return False
852 def _record(self, name: str) -> DimensionRecord | None:
853 # Docstring inherited from DataCoordinate.
854 raise AssertionError()
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)
864class _DataCoordinateRequiredMappingView(Mapping[str, DataIdValue]):
865 """A DataCoordinate Mapping view class whose keys are just the required
866 dimensions.
867 """
869 def __init__(self, target: DataCoordinate):
870 self._target = target
872 __slots__ = ("_target",)
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]
879 def __len__(self) -> int:
880 return len(self._target.dimensions.required)
882 def __iter__(self) -> Iterator[str]:
883 return iter(self._target.dimensions.required)
885 def __repr__(self) -> str:
886 return f"{{{', '.join(f'{k}: {v!r}' for k, v in self.items())}}}"
889class _DataCoordinateFullMappingView(Mapping[str, DataIdValue]):
890 """A DataCoordinate Mapping view class whose keys are all dimensions."""
892 def __init__(self, target: DataCoordinate):
893 self._target = target
895 __slots__ = ("_target",)
897 def __getitem__(self, key: str) -> DataIdValue:
898 return self._target[key]
900 def __len__(self) -> int:
901 return len(self._target.dimensions)
903 def __iter__(self) -> Iterator[str]:
904 return iter(self._target.dimensions.data_coordinate_keys)
906 def __repr__(self) -> str:
907 return f"{{{', '.join(f'{k}: {v!r}' for k, v in self.items())}}}"
910class _RequiredTupleDataCoordinate(_BasicTupleDataCoordinate):
911 """A `DataCoordinate` implementation that has values for required
912 dimensions only, when implied dimensions already exist.
914 Note that `_FullTupleDataCoordinate` should be used if there are no
915 implied dimensions.
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 """
922 __slots__ = ()
924 @property
925 def mapping(self) -> Mapping[str, DataIdValue]:
926 # Docstring inherited from DataCoordinate.
927 return _DataCoordinateRequiredMappingView(self)
929 @property
930 def required_values(self) -> tuple[DataIdValue, ...]:
931 # Docstring inherited from DataCoordinate.
932 return self._values
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 )
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)
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)
973 def hasFull(self) -> bool:
974 # Docstring inherited from DataCoordinate.
975 return False
977 def __reduce__(self) -> tuple[Any, ...]:
978 return (_RequiredTupleDataCoordinate, (self._dimensions, self._values))
981class _FullTupleDataCoordinate(_BasicTupleDataCoordinate):
982 """A `DataCoordinate` implementation that has values for all dimensions.
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 """
989 __slots__ = ()
991 @property
992 def mapping(self) -> Mapping[str, DataIdValue]:
993 # Docstring inherited from DataCoordinate.
994 return _DataCoordinateFullMappingView(self)
996 @property
997 def required_values(self) -> tuple[DataIdValue, ...]:
998 # Docstring inherited from DataCoordinate.
999 return self._values[: len(self._dimensions.required)]
1001 @property
1002 def full_values(self) -> tuple[DataIdValue, ...]:
1003 # Docstring inherited from DataCoordinate.
1004 return self._values
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
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)
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)
1032 def expanded(self, records: Mapping[str, DimensionRecord | None]) -> DataCoordinate:
1033 # Docstring inherited from DataCoordinate
1034 return _ExpandedTupleDataCoordinate(self._dimensions, self._values, records)
1036 def hasFull(self) -> bool:
1037 # Docstring inherited from DataCoordinate.
1038 return True
1040 def __reduce__(self) -> tuple[Any, ...]:
1041 return (_FullTupleDataCoordinate, (self._dimensions, self._values))
1044class _ExpandedTupleDataCoordinate(_FullTupleDataCoordinate):
1045 """A `DataCoordinate` implementation that directly holds `DimensionRecord`
1046 objects relevant to it.
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`.
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 """
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
1078 __slots__ = ("_records",)
1080 def subset(self, dimensions: DimensionGroup | Iterable[str]) -> DataCoordinate:
1081 # Docstring inherited from DataCoordinate.
1082 return super().subset(dimensions).expanded(self._records)
1084 def expanded(self, records: Mapping[str, DimensionRecord | None]) -> DataCoordinate:
1085 # Docstring inherited from DataCoordinate.
1086 return self
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
1099 def hasRecords(self) -> bool:
1100 # Docstring inherited from DataCoordinate.
1101 return True
1103 def _record(self, name: str) -> DimensionRecord | None:
1104 # Docstring inherited from DataCoordinate.
1105 return self._records[name]
1107 def __reduce__(self) -> tuple[Any, ...]:
1108 return (_ExpandedTupleDataCoordinate, (self._dimensions, self._values, self._records))
1110 def __getattr__(self, name: str) -> Any:
1111 try:
1112 return self._record(name)
1113 except KeyError:
1114 raise AttributeError(name) from None
1116 def __dir__(self) -> list[str]:
1117 result = list(super().__dir__())
1118 result.extend(self.dimensions.elements)
1119 return result