Coverage for python/lsst/daf/butler/core/dimensions/_records.py : 20%

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/>.
22from __future__ import annotations
24__all__ = ("DimensionRecord",)
26from typing import (
27 Any,
28 ClassVar,
29 Dict,
30 Optional,
31 TYPE_CHECKING,
32 Type,
33)
35import lsst.sphgeom
37from .._topology import SpatialRegionDatabaseRepresentation
38from ..timespan import Timespan, TimespanDatabaseRepresentation
39from ..utils import immutable
40from ._elements import Dimension, DimensionElement
41from ..json import from_json_generic, to_json_generic
43if TYPE_CHECKING: # Imports needed only for type annotations; may be circular. 43 ↛ 44line 43 didn't jump to line 44, because the condition on line 43 was never true
44 from ._coordinate import DataCoordinate
45 from ._schema import DimensionElementFields
46 from ._graph import DimensionUniverse
47 from ...registry import Registry
50def _reconstructDimensionRecord(definition: DimensionElement, mapping: Dict[str, Any]) -> DimensionRecord:
51 """Unpickle implementation for `DimensionRecord` subclasses.
53 For internal use by `DimensionRecord`.
54 """
55 return definition.RecordClass(**mapping)
58def _subclassDimensionRecord(definition: DimensionElement) -> Type[DimensionRecord]:
59 """Create a dynamic subclass of `DimensionRecord` for the given
60 `DimensionElement`.
62 For internal use by `DimensionRecord`.
63 """
64 from ._schema import DimensionElementFields
65 fields = DimensionElementFields(definition)
66 slots = list(fields.standard.names)
67 if definition.spatial:
68 slots.append(SpatialRegionDatabaseRepresentation.NAME)
69 if definition.temporal:
70 slots.append(TimespanDatabaseRepresentation.NAME)
71 d = {
72 "definition": definition,
73 "__slots__": tuple(slots),
74 "fields": fields
75 }
76 return type(definition.name + ".RecordClass", (DimensionRecord,), d)
79@immutable
80class DimensionRecord:
81 """Base class for the Python representation of database records for
82 a `DimensionElement`.
84 Parameters
85 ----------
86 **kwargs
87 Field values for this record. Unrecognized keys are ignored. If this
88 is the record for a `Dimension`, its primary key value may be provided
89 with the actual name of the field (e.g. "id" or "name"), the name of
90 the `Dimension`, or both. If this record class has a "timespan"
91 attribute, "datetime_begin" and "datetime_end" keyword arguments may
92 be provided instead of a single "timespan" keyword argument (but are
93 ignored if a "timespan" argument is provided).
95 Notes
96 -----
97 `DimensionRecord` subclasses are created dynamically for each
98 `DimensionElement` in a `DimensionUniverse`, and are accessible via the
99 `DimensionElement.RecordClass` attribute. The `DimensionRecord` base class
100 itself is pure abstract, but does not use the `abc` module to indicate this
101 because it does not have overridable methods.
103 Record classes have attributes that correspond exactly to the
104 `~DimensionElementFields.standard` fields in the related database table,
105 plus "region" and "timespan" attributes for spatial and/or temporal
106 elements (respectively).
108 Instances are usually obtained from a `Registry`, but can be constructed
109 directly from Python as well.
111 `DimensionRecord` instances are immutable.
112 """
114 # Derived classes are required to define __slots__ as well, and it's those
115 # derived-class slots that other methods on the base class expect to see
116 # when they access self.__slots__.
117 __slots__ = ("dataId",)
119 def __init__(self, **kwargs: Any):
120 # Accept either the dimension name or the actual name of its primary
121 # key field; ensure both are present in the dict for convenience below.
122 if isinstance(self.definition, Dimension):
123 v = kwargs.get(self.definition.primaryKey.name)
124 if v is None:
125 v = kwargs.get(self.definition.name)
126 if v is None:
127 raise ValueError(
128 f"No value provided for {self.definition.name}.{self.definition.primaryKey.name}."
129 )
130 kwargs[self.definition.primaryKey.name] = v
131 else:
132 v2 = kwargs.setdefault(self.definition.name, v)
133 if v != v2:
134 raise ValueError(
135 f"Multiple inconsistent values for "
136 f"{self.definition.name}.{self.definition.primaryKey.name}: {v!r} != {v2!r}."
137 )
138 for name in self.__slots__:
139 object.__setattr__(self, name, kwargs.get(name))
140 if self.definition.temporal is not None:
141 if self.timespan is None:
142 object.__setattr__(
143 self,
144 "timespan",
145 Timespan(
146 kwargs.get("datetime_begin"),
147 kwargs.get("datetime_end"),
148 )
149 )
151 from ._coordinate import DataCoordinate
152 object.__setattr__(
153 self,
154 "dataId",
155 DataCoordinate.fromRequiredValues(
156 self.definition.graph,
157 tuple(kwargs[dimension] for dimension in self.definition.required.names)
158 )
159 )
161 def __eq__(self, other: Any) -> bool:
162 if type(other) != type(self):
163 return False
164 return self.dataId == other.dataId
166 def __hash__(self) -> int:
167 return hash(self.dataId)
169 def __str__(self) -> str:
170 lines = [f"{self.definition.name}:"]
171 lines.extend(f" {name}: {getattr(self, name)!r}" for name in self.__slots__)
172 return "\n".join(lines)
174 def __repr__(self) -> str:
175 return "{}.RecordClass({})".format(
176 self.definition.name,
177 ", ".join(f"{name}={getattr(self, name)!r}" for name in self.__slots__)
178 )
180 def __reduce__(self) -> tuple:
181 mapping = {name: getattr(self, name) for name in self.__slots__}
182 return (_reconstructDimensionRecord, (self.definition, mapping))
184 def to_simple(self, minimal: bool = False) -> Dict[str, Any]:
185 """Convert this class to a simple python type suitable for
186 serialization.
188 Parameters
189 ----------
190 minimal : `bool`, optional
191 Use minimal serialization. Has no effect on for this class.
193 Returns
194 -------
195 names : `list`
196 The names of the dimensions.
197 """
198 if minimal:
199 # The DataId is sufficient if you are willing to do a deferred
200 # query. This may not be overly useful since to reconstruct
201 # a collection of records will require repeated registry queries.
202 simple = self.dataId.to_simple()
203 # Need some means of indicating this is not a full record
204 simple["element"] = self.definition.name
205 return simple
207 mapping = {name: getattr(self, name) for name in self.__slots__}
208 # If the item in mapping supports simplification update it
209 for k, v in mapping.items():
210 try:
211 mapping[k] = v.to_simple(minimal=minimal)
212 except AttributeError:
213 if isinstance(v, lsst.sphgeom.Region):
214 # YAML serialization specifies the class when it
215 # doesn't have to. This is partly for explicitness
216 # and also history. Here use a different approach.
217 # This code needs to be migrated to sphgeom
218 mapping[k] = {"encoded_region": v.encode().hex()}
219 definition = self.definition.to_simple(minimal=minimal)
221 return {"definition": definition,
222 "record": mapping}
224 @classmethod
225 def from_simple(cls, simple: Dict[str, Any],
226 universe: Optional[DimensionUniverse] = None,
227 registry: Optional[Registry] = None) -> DimensionRecord:
228 """Construct a new object from the data returned from the `to_simple`
229 method.
231 Parameters
232 ----------
233 simple : `dict` of `str`
234 Value return from `to_simple`.
235 universe : `DimensionUniverse`
236 The special graph of all known dimensions of which this graph will
237 be a subset. Can be `None` if `Registry` is provided.
238 registry : `lsst.daf.butler.Registry`, optional
239 Registry from which a universe can be extracted. Can be `None`
240 if universe is provided explicitly.
242 Returns
243 -------
244 graph : `DimensionGraph`
245 Newly-constructed object.
246 """
247 # Minimal representation requires a registry
248 if "element" in simple:
249 if registry is None:
250 raise ValueError("Registry is required to decode minimalist form of dimensions record")
251 element = simple.pop("element")
252 records = list(registry.queryDimensionRecords(element, dataId=simple))
253 if (n := len(records)) != 1:
254 raise RuntimeError(f"Unexpectedly got {n} records for element {element} dataId {simple}")
255 return records[0]
257 if universe is None and registry is None:
258 raise ValueError("One of universe or registry is required to convert names to a DimensionGraph")
259 if universe is None and registry is not None:
260 universe = registry.dimensions
261 if universe is None:
262 # this is for mypy
263 raise ValueError("Unable to determine a usable universe")
265 definition = DimensionElement.from_simple(simple["definition"], universe=universe)
267 # Timespan and region have to be converted to native form
268 # for now assume that those keys are special
269 rec = simple["record"]
270 if (ts := "timespan") in rec:
271 rec[ts] = Timespan.from_simple(rec[ts], universe=universe, registry=registry)
272 if (reg := "region") in rec:
273 encoded = bytes.fromhex(rec[reg]["encoded_region"])
274 rec[reg] = lsst.sphgeom.Region.decode(encoded)
276 return _reconstructDimensionRecord(definition, simple["record"])
278 to_json = to_json_generic
279 from_json = classmethod(from_json_generic)
281 def toDict(self, splitTimespan: bool = False) -> Dict[str, Any]:
282 """Return a vanilla `dict` representation of this record.
284 Parameters
285 ----------
286 splitTimespan : `bool`, optional
287 If `True` (`False` is default) transform any "timespan" key value
288 from a `Timespan` instance into a pair of regular
289 ("datetime_begin", "datetime_end") fields.
290 """
291 results = {name: getattr(self, name) for name in self.__slots__}
292 if splitTimespan:
293 timespan = results.pop("timespan", None)
294 if timespan is not None:
295 results["datetime_begin"] = timespan.begin
296 results["datetime_end"] = timespan.end
297 return results
299 # DimensionRecord subclasses are dynamically created, so static type
300 # checkers can't know about them or their attributes. To avoid having to
301 # put "type: ignore", everywhere, add a dummy __getattr__ that tells type
302 # checkers not to worry about missing attributes.
303 def __getattr__(self, name: str) -> Any:
304 raise AttributeError(name)
306 # Class attributes below are shadowed by instance attributes, and are
307 # present just to hold the docstrings for those instance attributes.
309 dataId: DataCoordinate
310 """A dict-like identifier for this record's primary keys
311 (`DataCoordinate`).
312 """
314 definition: ClassVar[DimensionElement]
315 """The `DimensionElement` whose records this class represents
316 (`DimensionElement`).
317 """
319 fields: ClassVar[DimensionElementFields]
320 """A categorized view of the fields in this class
321 (`DimensionElementFields`).
322 """