Coverage for python / lsst / images / serialization / _tables.py: 59%
71 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-22 09:12 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-22 09:12 +0000
1# This file is part of lsst-images.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
12from __future__ import annotations
14__all__ = (
15 "ColumnDefinitionModel",
16 "TableCellReferenceModel",
17 "TableReferenceModel",
18 "UnsupportedTableError",
19)
21import operator
22from typing import TYPE_CHECKING, ClassVar, Literal
24import astropy.units
25import numpy as np
26import numpy.typing as npt
27import pydantic
29from ._asdf_utils import Unit
30from ._common import ArchiveReadError
31from ._dtypes import NumberType
33if TYPE_CHECKING:
34 import astropy.table
37class UnsupportedTableError(NotImplementedError):
38 """Exception raised if a table object has column types or structure that
39 are not supported by this library.
40 """
43class ColumnDefinitionModel(pydantic.BaseModel):
44 """A model that describes a column in a table."""
46 name: str
47 """Name of the column."""
49 datatype: NumberType
50 """Type of the column."""
52 unit: Unit | None = pydantic.Field(default=None, exclude_if=operator.not_)
53 """Units of the column."""
55 description: str = pydantic.Field(default="", exclude_if=operator.not_)
56 """Extended description of the column."""
58 shape: tuple[int, ...] = pydantic.Field(default=(), exclude_if=operator.not_)
59 """Shape of a single cell in of this column.
61 An empty `tuple` is used to represent a scalar column.
62 """
64 is_variable_length: bool = pydantic.Field(default=False, exclude_if=operator.not_)
65 """Whether this column is a variable-length array."""
67 @classmethod
68 def from_record_dtype(cls, dtype: npt.DTypeLike) -> list[ColumnDefinitionModel]:
69 """Extract a list of column definitions from a structured numpy dtype.
71 Parameters
72 ----------
73 dtype
74 Object convertible to `numpy.dtype`.
75 """
76 dtype = np.dtype(dtype)
77 result: list[ColumnDefinitionModel] = []
78 if dtype.fields is None:
79 raise TypeError(f"{dtype} is not a structured dtype.")
80 for name, (field_dtype, *_) in dtype.fields.items():
81 # TODO: support string and variable-length array columns here.
82 try:
83 datatype, shape = NumberType.from_numpy_with_shape(field_dtype)
84 except TypeError:
85 raise UnsupportedTableError(f"Column type {field_dtype} is not supported.") from None
86 result.append(ColumnDefinitionModel.model_construct(name=name, datatype=datatype, shape=shape))
87 return result
89 @classmethod
90 def from_table(cls, table: astropy.table.Table) -> list[ColumnDefinitionModel]:
91 """Extract column definitions from an Astropy table."""
92 return [cls.from_column(c) for c in table.columns.values()]
94 @classmethod
95 def from_column(cls, column: astropy.table.Column) -> ColumnDefinitionModel:
96 """Extract a column definition from an Astropy column."""
97 # TODO: support string and variable-length array columns here.
98 try:
99 datatype = NumberType.from_numpy(column.dtype)
100 except TypeError:
101 raise UnsupportedTableError(f"Column type {column.dtype} is not supported.") from None
102 return ColumnDefinitionModel(
103 name=column.name,
104 datatype=datatype,
105 shape=column.shape[1:],
106 unit=astropy.units.Unit(column.unit) if column.unit is not None else None,
107 description=column.description or "",
108 )
110 def update_table(self, table: astropy.table.Table) -> None:
111 """Update the unit and description of an astropy column from this
112 object.
113 """
114 astropy_column: astropy.table.Column = table.columns[self.name]
115 astropy_column.unit = self.unit
116 astropy_column.description = self.description
117 if (datatype := NumberType.from_numpy(astropy_column.dtype)) != self.datatype:
118 raise ArchiveReadError(f"Table column {self.name} has type {datatype}; expected {self.datatype}.")
119 if (shape := astropy_column.shape[1:]) != self.shape:
120 raise ArchiveReadError(f"Table column {self.name} has shape {shape}; expected {self.shape}.")
123class TableReferenceModel(pydantic.BaseModel):
124 """Placeholder for an ASDF-like model for referencing binary tabular
125 data.
126 """
128 source: str | int
129 """Reference to the table data.
131 This is analogous to the ASDF ``ndarray`` field of the same name, i.e
132 for a FITS binary table, use "fits:EXTNAME[,EXTVER]" or "fits:INDEX"
133 (zero-indexed) to identify the HDU.
134 """
136 columns: list[ColumnDefinitionModel] = pydantic.Field(default_factory=list)
137 """Definitions of all columns."""
139 source_is_table: ClassVar[Literal[True]] = True
142class TableCellReferenceModel(pydantic.BaseModel):
143 """A model that acts as a pointer to data in a table cell."""
145 model_config = pydantic.ConfigDict(frozen=True)
147 source: str | int
148 """Identifier for the table as a whole.
150 This is analogous to the ASDF ``ndarray`` field of the same name, i.e
151 for a FITS binary table, use "fits:EXTNAME[,EXTVER]" or "fits:INDEX"
152 (zero-indexed) to identify the HDU.
153 """
155 column: str
156 """Name of the column."""
158 row: int
159 """Row of the cell (zero-indexed)."""
161 source_is_table: ClassVar[Literal[True]] = True