lsst-felis 26.2024.400__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of lsst-felis might be problematic. Click here for more details.

felis/datamodel.py ADDED
@@ -0,0 +1,409 @@
1
+ # This file is part of felis.
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
+ # 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 <https://www.gnu.org/licenses/>.
21
+
22
+ import logging
23
+ from collections.abc import Mapping
24
+ from enum import Enum
25
+ from typing import Any, Literal
26
+
27
+ from astropy import units as units # type: ignore
28
+ from astropy.io.votable import ucd # type: ignore
29
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
30
+
31
+ logger = logging.getLogger(__name__)
32
+ # logger.setLevel(logging.DEBUG)
33
+
34
+ __all__ = (
35
+ "BaseObject",
36
+ "Column",
37
+ "Constraint",
38
+ "CheckConstraint",
39
+ "UniqueConstraint",
40
+ "Index",
41
+ "ForeignKeyConstraint",
42
+ "Table",
43
+ "SchemaVersion",
44
+ "Schema",
45
+ )
46
+
47
+
48
+ class BaseObject(BaseModel):
49
+ """Base class for all Felis objects."""
50
+
51
+ model_config = ConfigDict(populate_by_name=True, extra="forbid", use_enum_values=True)
52
+ """Configuration for the `BaseModel` class.
53
+
54
+ Allow attributes to be populated by name and forbid extra attributes.
55
+ """
56
+
57
+ name: str
58
+ """The name of the database object.
59
+
60
+ All Felis database objects must have a name.
61
+ """
62
+
63
+ id: str = Field(alias="@id")
64
+ """The unique identifier of the database object.
65
+
66
+ All Felis database objects must have a unique identifier.
67
+ """
68
+
69
+ description: str | None = None
70
+ """A description of the database object.
71
+
72
+ The description is optional.
73
+ """
74
+
75
+
76
+ class DataType(Enum):
77
+ """`Enum` representing the data types supported by Felis."""
78
+
79
+ BOOLEAN = "boolean"
80
+ BYTE = "byte"
81
+ SHORT = "short"
82
+ INT = "int"
83
+ LONG = "long"
84
+ FLOAT = "float"
85
+ DOUBLE = "double"
86
+ CHAR = "char"
87
+ STRING = "string"
88
+ UNICODE = "unicode"
89
+ TEXT = "text"
90
+ BINARY = "binary"
91
+ TIMESTAMP = "timestamp"
92
+
93
+
94
+ class Column(BaseObject):
95
+ """A column in a table."""
96
+
97
+ datatype: DataType
98
+ """The datatype of the column."""
99
+
100
+ length: int | None = None
101
+ """The length of the column."""
102
+
103
+ nullable: bool = True
104
+ """Whether the column can be `NULL`."""
105
+
106
+ value: Any = None
107
+ """The default value of the column."""
108
+
109
+ autoincrement: bool | None = None
110
+ """Whether the column is autoincremented."""
111
+
112
+ mysql_datatype: str | None = Field(None, alias="mysql:datatype")
113
+ """The MySQL datatype of the column."""
114
+
115
+ ivoa_ucd: str | None = Field(None, alias="ivoa:ucd")
116
+ """The IVOA UCD of the column."""
117
+
118
+ fits_tunit: str | None = Field(None, alias="fits:tunit")
119
+ """The FITS TUNIT of the column."""
120
+
121
+ ivoa_unit: str | None = Field(None, alias="ivoa:unit")
122
+ """The IVOA unit of the column."""
123
+
124
+ tap_column_index: int | None = Field(None, alias="tap:column_index")
125
+ """The TAP_SCHEMA column index of the column."""
126
+
127
+ tap_principal: int | None = Field(0, alias="tap:principal", ge=0, le=1)
128
+ """Whether this is a TAP_SCHEMA principal column; can be either 0 or 1.
129
+
130
+ This could be a boolean instead of 0 or 1.
131
+ """
132
+
133
+ votable_arraysize: int | Literal["*"] | None = Field(None, alias="votable:arraysize")
134
+ """The VOTable arraysize of the column."""
135
+
136
+ tap_std: int | None = Field(0, alias="tap:std", ge=0, le=1)
137
+ """TAP_SCHEMA indication that this column is defined by an IVOA standard.
138
+ """
139
+
140
+ votable_utype: str | None = Field(None, alias="votable:utype")
141
+ """The VOTable utype (usage-specific or unique type) of the column."""
142
+
143
+ votable_xtype: str | None = Field(None, alias="votable:xtype")
144
+ """The VOTable xtype (extended type) of the column."""
145
+
146
+ @field_validator("ivoa_ucd")
147
+ @classmethod
148
+ def check_ivoa_ucd(cls, ivoa_ucd: str) -> str:
149
+ """Check that IVOA UCD values are valid."""
150
+ if ivoa_ucd is not None:
151
+ try:
152
+ ucd.parse_ucd(ivoa_ucd, check_controlled_vocabulary=True, has_colon=";" in ivoa_ucd)
153
+ except ValueError as e:
154
+ raise ValueError(f"Invalid IVOA UCD: {e}")
155
+ return ivoa_ucd
156
+
157
+ @model_validator(mode="before")
158
+ @classmethod
159
+ def check_units(cls, values: dict[str, Any]) -> dict[str, Any]:
160
+ """Check that units are valid."""
161
+ fits_unit = values.get("fits:tunit")
162
+ ivoa_unit = values.get("ivoa:unit")
163
+
164
+ if fits_unit and ivoa_unit:
165
+ raise ValueError("Column cannot have both FITS and IVOA units")
166
+ unit = fits_unit or ivoa_unit
167
+
168
+ if unit is not None:
169
+ try:
170
+ units.Unit(unit)
171
+ except ValueError as e:
172
+ raise ValueError(f"Invalid unit: {e}")
173
+
174
+ return values
175
+
176
+
177
+ class Constraint(BaseObject):
178
+ """A database table constraint."""
179
+
180
+ deferrable: bool = False
181
+ """If `True` then this constraint will be declared as deferrable."""
182
+
183
+ initially: str | None = None
184
+ """Value for ``INITIALLY`` clause, only used if ``deferrable`` is True."""
185
+
186
+ annotations: Mapping[str, Any] = Field(default_factory=dict)
187
+ """Additional annotations for this constraint."""
188
+
189
+ type: str | None = Field(None, alias="@type")
190
+ """The type of the constraint."""
191
+
192
+
193
+ class CheckConstraint(Constraint):
194
+ """A check constraint on a table."""
195
+
196
+ expression: str
197
+ """The expression for the check constraint."""
198
+
199
+
200
+ class UniqueConstraint(Constraint):
201
+ """A unique constraint on a table."""
202
+
203
+ columns: list[str]
204
+ """The columns in the unique constraint."""
205
+
206
+
207
+ class Index(BaseObject):
208
+ """A database table index.
209
+
210
+ An index can be defined on either columns or expressions, but not both.
211
+ """
212
+
213
+ columns: list[str] | None = None
214
+ """The columns in the index."""
215
+
216
+ expressions: list[str] | None = None
217
+ """The expressions in the index."""
218
+
219
+ @model_validator(mode="before")
220
+ @classmethod
221
+ def check_columns_or_expressions(cls, values: dict[str, Any]) -> dict[str, Any]:
222
+ """Check that columns or expressions are specified, but not both."""
223
+ if "columns" in values and "expressions" in values:
224
+ raise ValueError("Defining columns and expressions is not valid")
225
+ elif "columns" not in values and "expressions" not in values:
226
+ raise ValueError("Must define columns or expressions")
227
+ return values
228
+
229
+
230
+ class ForeignKeyConstraint(Constraint):
231
+ """A foreign key constraint on a table.
232
+
233
+ These will be reflected in the TAP_SCHEMA keys and key_columns data.
234
+ """
235
+
236
+ columns: list[str]
237
+ """The columns comprising the foreign key."""
238
+
239
+ referenced_columns: list[str] = Field(alias="referencedColumns")
240
+ """The columns referenced by the foreign key."""
241
+
242
+
243
+ class Table(BaseObject):
244
+ """A database table."""
245
+
246
+ columns: list[Column]
247
+ """The columns in the table."""
248
+
249
+ constraints: list[Constraint] = Field(default_factory=list)
250
+ """The constraints on the table."""
251
+
252
+ indexes: list[Index] = Field(default_factory=list)
253
+ """The indexes on the table."""
254
+
255
+ primaryKey: str | list[str] | None = None
256
+ """The primary key of the table."""
257
+
258
+ tap_table_index: int | None = Field(None, alias="tap:table_index")
259
+ """The IVOA TAP_SCHEMA table index of the table."""
260
+
261
+ mysql_engine: str | None = Field(None, alias="mysql:engine")
262
+ """The mysql engine to use for the table.
263
+
264
+ For now this is a freeform string but it could be constrained to a list of
265
+ known engines in the future.
266
+ """
267
+
268
+ mysql_charset: str | None = Field(None, alias="mysql:charset")
269
+ """The mysql charset to use for the table.
270
+
271
+ For now this is a freeform string but it could be constrained to a list of
272
+ known charsets in the future.
273
+ """
274
+
275
+ @model_validator(mode="before")
276
+ @classmethod
277
+ def create_constraints(cls, values: dict[str, Any]) -> dict[str, Any]:
278
+ """Create constraints from the ``constraints`` field."""
279
+ if "constraints" in values:
280
+ new_constraints: list[Constraint] = []
281
+ for item in values["constraints"]:
282
+ if item["@type"] == "ForeignKey":
283
+ new_constraints.append(ForeignKeyConstraint(**item))
284
+ elif item["@type"] == "Unique":
285
+ new_constraints.append(UniqueConstraint(**item))
286
+ elif item["@type"] == "Check":
287
+ new_constraints.append(CheckConstraint(**item))
288
+ else:
289
+ raise ValueError(f"Unknown constraint type: {item['@type']}")
290
+ values["constraints"] = new_constraints
291
+ return values
292
+
293
+ @field_validator("columns", mode="after")
294
+ @classmethod
295
+ def check_unique_column_names(cls, columns: list[Column]) -> list[Column]:
296
+ """Check that column names are unique."""
297
+ if len(columns) != len(set(column.name for column in columns)):
298
+ raise ValueError("Column names must be unique")
299
+ return columns
300
+
301
+
302
+ class SchemaVersion(BaseModel):
303
+ """The version of the schema."""
304
+
305
+ current: str
306
+ """The current version of the schema."""
307
+
308
+ compatible: list[str] | None = None
309
+ """The compatible versions of the schema."""
310
+
311
+ read_compatible: list[str] | None = None
312
+ """The read compatible versions of the schema."""
313
+
314
+
315
+ class SchemaVisitor:
316
+ """Visitor to build a Schema object's map of IDs to objects.
317
+
318
+ Duplicates are added to a set when they are encountered, which can be
319
+ accessed via the `duplicates` attribute. The presence of duplicates will
320
+ not throw an error. Only the first object with a given ID will be added to
321
+ the map, but this should not matter, since a ValidationError will be thrown
322
+ by the `model_validator` method if any duplicates are found in the schema.
323
+
324
+ This class is intended for internal use only.
325
+ """
326
+
327
+ def __init__(self) -> None:
328
+ """Create a new SchemaVisitor."""
329
+ self.schema: "Schema" | None = None
330
+ self.duplicates: set[str] = set()
331
+
332
+ def add(self, obj: BaseObject) -> None:
333
+ """Add an object to the ID map."""
334
+ if hasattr(obj, "id"):
335
+ obj_id = getattr(obj, "id")
336
+ if self.schema is not None:
337
+ if obj_id in self.schema.id_map:
338
+ self.duplicates.add(obj_id)
339
+ else:
340
+ self.schema.id_map[obj_id] = obj
341
+
342
+ def visit_schema(self, schema: "Schema") -> None:
343
+ """Visit the schema object that was added during initialization.
344
+
345
+ This will set an internal variable pointing to the schema object.
346
+ """
347
+ self.schema = schema
348
+ self.duplicates.clear()
349
+ self.add(self.schema)
350
+ for table in self.schema.tables:
351
+ self.visit_table(table)
352
+
353
+ def visit_table(self, table: Table) -> None:
354
+ """Visit a table object."""
355
+ self.add(table)
356
+ for column in table.columns:
357
+ self.visit_column(column)
358
+ for constraint in table.constraints:
359
+ self.visit_constraint(constraint)
360
+
361
+ def visit_column(self, column: Column) -> None:
362
+ """Visit a column object."""
363
+ self.add(column)
364
+
365
+ def visit_constraint(self, constraint: Constraint) -> None:
366
+ """Visit a constraint object."""
367
+ self.add(constraint)
368
+
369
+
370
+ class Schema(BaseObject):
371
+ """The database schema."""
372
+
373
+ version: SchemaVersion | str | None = None
374
+ """The version of the schema."""
375
+
376
+ tables: list[Table]
377
+ """The tables in the schema."""
378
+
379
+ id_map: dict[str, Any] = Field(default_factory=dict, exclude=True)
380
+ """Map of IDs to objects."""
381
+
382
+ @field_validator("tables", mode="after")
383
+ @classmethod
384
+ def check_unique_table_names(cls, tables: list[Table]) -> list[Table]:
385
+ """Check that table names are unique."""
386
+ if len(tables) != len(set(table.name for table in tables)):
387
+ raise ValueError("Table names must be unique")
388
+ return tables
389
+
390
+ @model_validator(mode="after")
391
+ def create_id_map(self) -> "Schema":
392
+ """Create a map of IDs to objects."""
393
+ visitor: SchemaVisitor = SchemaVisitor()
394
+ visitor.visit_schema(self)
395
+ logger.debug(f"ID map contains {len(self.id_map.keys())} objects")
396
+ if len(visitor.duplicates):
397
+ raise ValueError(
398
+ "Duplicate IDs found in schema:\n " + "\n ".join(visitor.duplicates) + "\n"
399
+ )
400
+ return self
401
+
402
+ def get_object_by_id(self, id: str) -> BaseObject:
403
+ """Get an object by its unique "@id" field value.
404
+
405
+ An error will be thrown if the object is not found.
406
+ """
407
+ if id not in self.id_map:
408
+ raise ValueError(f"Object with ID {id} not found in schema")
409
+ return self.id_map[id]
felis/db/__init__.py ADDED
File without changes
felis/db/sqltypes.py ADDED
@@ -0,0 +1,209 @@
1
+ # This file is part of felis.
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
+ # 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 <https://www.gnu.org/licenses/>.
21
+
22
+ import builtins
23
+ from collections.abc import Mapping
24
+ from typing import Any
25
+
26
+ from sqlalchemy import Float, SmallInteger, types
27
+ from sqlalchemy.dialects import mysql, oracle, postgresql
28
+ from sqlalchemy.ext.compiler import compiles
29
+
30
+ MYSQL = "mysql"
31
+ ORACLE = "oracle"
32
+ POSTGRES = "postgresql"
33
+ SQLITE = "sqlite"
34
+
35
+
36
+ class TINYINT(SmallInteger):
37
+ """The non-standard TINYINT type."""
38
+
39
+ __visit_name__ = "TINYINT"
40
+
41
+
42
+ class DOUBLE(Float):
43
+ """The non-standard DOUBLE type."""
44
+
45
+ __visit_name__ = "DOUBLE"
46
+
47
+
48
+ @compiles(TINYINT)
49
+ def compile_tinyint(type_: Any, compiler: Any, **kw: Any) -> str:
50
+ """Return type name for TINYINT."""
51
+ return "TINYINT"
52
+
53
+
54
+ @compiles(DOUBLE)
55
+ def compile_double(type_: Any, compiler: Any, **kw: Any) -> str:
56
+ """Return type name for double precision type."""
57
+ return "DOUBLE"
58
+
59
+
60
+ _TypeMap = Mapping[str, types.TypeEngine | type[types.TypeEngine]]
61
+
62
+ boolean_map: _TypeMap = {MYSQL: mysql.BIT(1), ORACLE: oracle.NUMBER(1), POSTGRES: postgresql.BOOLEAN()}
63
+
64
+ byte_map: _TypeMap = {
65
+ MYSQL: mysql.TINYINT(),
66
+ ORACLE: oracle.NUMBER(3),
67
+ POSTGRES: postgresql.SMALLINT(),
68
+ }
69
+
70
+ short_map: _TypeMap = {
71
+ MYSQL: mysql.SMALLINT(),
72
+ ORACLE: oracle.NUMBER(5),
73
+ POSTGRES: postgresql.SMALLINT(),
74
+ }
75
+
76
+ # Skip Oracle
77
+ int_map: _TypeMap = {
78
+ MYSQL: mysql.INTEGER(),
79
+ POSTGRES: postgresql.INTEGER(),
80
+ }
81
+
82
+ long_map: _TypeMap = {
83
+ MYSQL: mysql.BIGINT(),
84
+ ORACLE: oracle.NUMBER(38, 0),
85
+ POSTGRES: postgresql.BIGINT(),
86
+ }
87
+
88
+ float_map: _TypeMap = {
89
+ MYSQL: mysql.FLOAT(),
90
+ ORACLE: oracle.BINARY_FLOAT(),
91
+ POSTGRES: postgresql.FLOAT(),
92
+ }
93
+
94
+ double_map: _TypeMap = {
95
+ MYSQL: mysql.DOUBLE(),
96
+ ORACLE: oracle.BINARY_DOUBLE(),
97
+ POSTGRES: postgresql.DOUBLE_PRECISION(),
98
+ }
99
+
100
+ char_map: _TypeMap = {
101
+ MYSQL: mysql.CHAR,
102
+ ORACLE: oracle.CHAR,
103
+ POSTGRES: postgresql.CHAR,
104
+ }
105
+
106
+ string_map: _TypeMap = {
107
+ MYSQL: mysql.VARCHAR,
108
+ ORACLE: oracle.VARCHAR2,
109
+ POSTGRES: postgresql.VARCHAR,
110
+ }
111
+
112
+ unicode_map: _TypeMap = {
113
+ MYSQL: mysql.NVARCHAR,
114
+ ORACLE: oracle.NVARCHAR2,
115
+ POSTGRES: postgresql.VARCHAR,
116
+ }
117
+
118
+ text_map: _TypeMap = {
119
+ MYSQL: mysql.LONGTEXT,
120
+ ORACLE: oracle.CLOB,
121
+ POSTGRES: postgresql.TEXT,
122
+ }
123
+
124
+ binary_map: _TypeMap = {
125
+ MYSQL: mysql.LONGBLOB,
126
+ ORACLE: oracle.BLOB,
127
+ POSTGRES: postgresql.BYTEA,
128
+ }
129
+
130
+
131
+ def boolean(**kwargs: Any) -> types.TypeEngine:
132
+ """Return SQLAlchemy type for boolean."""
133
+ return _vary(types.BOOLEAN(), boolean_map, kwargs)
134
+
135
+
136
+ def byte(**kwargs: Any) -> types.TypeEngine:
137
+ """Return SQLAlchemy type for byte."""
138
+ return _vary(TINYINT(), byte_map, kwargs)
139
+
140
+
141
+ def short(**kwargs: Any) -> types.TypeEngine:
142
+ """Return SQLAlchemy type for short integer."""
143
+ return _vary(types.SMALLINT(), short_map, kwargs)
144
+
145
+
146
+ def int(**kwargs: Any) -> types.TypeEngine:
147
+ """Return SQLAlchemy type for integer."""
148
+ return _vary(types.INTEGER(), int_map, kwargs)
149
+
150
+
151
+ def long(**kwargs: Any) -> types.TypeEngine:
152
+ """Return SQLAlchemy type for long integer."""
153
+ return _vary(types.BIGINT(), long_map, kwargs)
154
+
155
+
156
+ def float(**kwargs: Any) -> types.TypeEngine:
157
+ """Return SQLAlchemy type for single precision float."""
158
+ return _vary(types.FLOAT(), float_map, kwargs)
159
+
160
+
161
+ def double(**kwargs: Any) -> types.TypeEngine:
162
+ """Return SQLAlchemy type for double precision float."""
163
+ return _vary(DOUBLE(), double_map, kwargs)
164
+
165
+
166
+ def char(length: builtins.int, **kwargs: Any) -> types.TypeEngine:
167
+ """Return SQLAlchemy type for character."""
168
+ return _vary(types.CHAR(length), char_map, kwargs, length)
169
+
170
+
171
+ def string(length: builtins.int, **kwargs: Any) -> types.TypeEngine:
172
+ """Return SQLAlchemy type for string."""
173
+ return _vary(types.VARCHAR(length), string_map, kwargs, length)
174
+
175
+
176
+ def unicode(length: builtins.int, **kwargs: Any) -> types.TypeEngine:
177
+ """Return SQLAlchemy type for unicode string."""
178
+ return _vary(types.NVARCHAR(length), unicode_map, kwargs, length)
179
+
180
+
181
+ def text(length: builtins.int, **kwargs: Any) -> types.TypeEngine:
182
+ """Return SQLAlchemy type for text."""
183
+ return _vary(types.CLOB(length), text_map, kwargs, length)
184
+
185
+
186
+ def binary(length: builtins.int, **kwargs: Any) -> types.TypeEngine:
187
+ """Return SQLAlchemy type for binary."""
188
+ return _vary(types.BLOB(length), binary_map, kwargs, length)
189
+
190
+
191
+ def timestamp(**kwargs: Any) -> types.TypeEngine:
192
+ """Return SQLAlchemy type for timestamp."""
193
+ return types.TIMESTAMP()
194
+
195
+
196
+ def _vary(
197
+ type_: types.TypeEngine,
198
+ variant_map: _TypeMap,
199
+ overrides: _TypeMap,
200
+ *args: Any,
201
+ ) -> types.TypeEngine:
202
+ variants: dict[str, types.TypeEngine | type[types.TypeEngine]] = dict(variant_map)
203
+ variants.update(overrides)
204
+ for dialect, variant in variants.items():
205
+ # If this is a class and not an instance, instantiate
206
+ if isinstance(variant, type):
207
+ variant = variant(*args)
208
+ type_ = type_.with_variant(variant, dialect)
209
+ return type_
felis/py.typed ADDED
File without changes