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