lsst-felis 26.2024.1500__tar.gz → 26.2024.1700__tar.gz
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.
- {lsst-felis-26.2024.1500/python/lsst_felis.egg-info → lsst_felis-26.2024.1700}/PKG-INFO +5 -2
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/pyproject.toml +5 -6
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/cli.py +42 -45
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/datamodel.py +152 -71
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/db/_variants.py +5 -5
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/db/sqltypes.py +14 -19
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/metadata.py +3 -9
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/tap.py +67 -80
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/types.py +1 -1
- lsst_felis-26.2024.1700/python/felis/version.py +2 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700/python/lsst_felis.egg-info}/PKG-INFO +5 -2
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/lsst_felis.egg-info/SOURCES.txt +1 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/lsst_felis.egg-info/requires.txt +3 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/tests/test_cli.py +0 -4
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/tests/test_datamodel.py +61 -31
- lsst_felis-26.2024.1700/tests/test_datatypes.py +116 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/tests/test_tap.py +5 -10
- lsst-felis-26.2024.1500/python/felis/version.py +0 -2
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/COPYRIGHT +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/LICENSE +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/README.rst +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/__init__.py +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/check.py +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/db/__init__.py +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/py.typed +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/simple.py +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/utils.py +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/validation.py +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/visitor.py +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/lsst_felis.egg-info/dependency_links.txt +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/lsst_felis.egg-info/entry_points.txt +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/lsst_felis.egg-info/top_level.txt +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/lsst_felis.egg-info/zip-safe +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/setup.cfg +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/tests/test_check.py +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/tests/test_metadata.py +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/tests/test_simple.py +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/tests/test_utils.py +0 -0
- {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/tests/test_validation.py +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: lsst-felis
|
|
3
|
-
Version: 26.2024.
|
|
3
|
+
Version: 26.2024.1700
|
|
4
4
|
Summary: A vocabulary for describing catalogs and acting on those descriptions
|
|
5
5
|
Author-email: Rubin Observatory Data Management <dm-admin@lists.lsst.org>
|
|
6
6
|
License: GNU General Public License v3 or later (GPLv3+)
|
|
7
|
-
Project-URL: Homepage, https://
|
|
7
|
+
Project-URL: Homepage, https://felis.lsst.io
|
|
8
|
+
Project-URL: Source, https://github.com/lsst/felis
|
|
8
9
|
Keywords: lsst
|
|
9
10
|
Classifier: Intended Audience :: Science/Research
|
|
10
11
|
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
@@ -26,3 +27,5 @@ Requires-Dist: pydantic<3,>=2
|
|
|
26
27
|
Requires-Dist: lsst-utils
|
|
27
28
|
Provides-Extra: test
|
|
28
29
|
Requires-Dist: pytest>=3.2; extra == "test"
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: documenteer[guide]; extra == "dev"
|
|
@@ -33,12 +33,16 @@ requires-python = ">=3.11.0"
|
|
|
33
33
|
dynamic = ["version"]
|
|
34
34
|
|
|
35
35
|
[project.urls]
|
|
36
|
-
|
|
36
|
+
Homepage = "https://felis.lsst.io"
|
|
37
|
+
Source = "https://github.com/lsst/felis"
|
|
37
38
|
|
|
38
39
|
[project.optional-dependencies]
|
|
39
40
|
test = [
|
|
40
41
|
"pytest >= 3.2"
|
|
41
42
|
]
|
|
43
|
+
dev = [
|
|
44
|
+
"documenteer[guide]"
|
|
45
|
+
]
|
|
42
46
|
|
|
43
47
|
[tool.pytest.ini_options]
|
|
44
48
|
|
|
@@ -143,11 +147,6 @@ select = [
|
|
|
143
147
|
"D", # pydocstyle
|
|
144
148
|
]
|
|
145
149
|
target-version = "py311"
|
|
146
|
-
# Commented out to suppress "unused noqa" in jenkins which has older ruff not
|
|
147
|
-
# generating E721.
|
|
148
|
-
extend-select = [
|
|
149
|
-
"RUF100", # Warn about unused noqa
|
|
150
|
-
]
|
|
151
150
|
|
|
152
151
|
[tool.pydeps]
|
|
153
152
|
max_bacon = 2
|
|
@@ -183,6 +183,7 @@ def init_tap(
|
|
|
183
183
|
@click.option("--tap-columns-table", help="Alt Table Name for TAP_SCHEMA.columns")
|
|
184
184
|
@click.option("--tap-keys-table", help="Alt Table Name for TAP_SCHEMA.keys")
|
|
185
185
|
@click.option("--tap-key-columns-table", help="Alt Table Name for TAP_SCHEMA.key_columns")
|
|
186
|
+
@click.option("--tap-schema-index", type=int, help="TAP_SCHEMA index of the schema")
|
|
186
187
|
@click.argument("file", type=click.File())
|
|
187
188
|
def load_tap(
|
|
188
189
|
engine_url: str,
|
|
@@ -196,6 +197,7 @@ def load_tap(
|
|
|
196
197
|
tap_columns_table: str,
|
|
197
198
|
tap_keys_table: str,
|
|
198
199
|
tap_key_columns_table: str,
|
|
200
|
+
tap_schema_index: int,
|
|
199
201
|
file: io.TextIOBase,
|
|
200
202
|
) -> None:
|
|
201
203
|
"""Load TAP metadata from a Felis FILE.
|
|
@@ -203,28 +205,8 @@ def load_tap(
|
|
|
203
205
|
This command loads the associated TAP metadata from a Felis FILE
|
|
204
206
|
to the TAP_SCHEMA tables.
|
|
205
207
|
"""
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
if isinstance(top_level_object, dict):
|
|
209
|
-
schema_obj = top_level_object
|
|
210
|
-
if "@graph" not in schema_obj:
|
|
211
|
-
schema_obj["@type"] = "felis:Schema"
|
|
212
|
-
schema_obj["@context"] = DEFAULT_CONTEXT
|
|
213
|
-
elif isinstance(top_level_object, list):
|
|
214
|
-
schema_obj = {"@context": DEFAULT_CONTEXT, "@graph": top_level_object}
|
|
215
|
-
else:
|
|
216
|
-
logger.error("Schema object not of recognizable type")
|
|
217
|
-
raise click.exceptions.Exit(1)
|
|
218
|
-
|
|
219
|
-
normalized = _normalize(schema_obj, embed="@always")
|
|
220
|
-
if len(normalized["@graph"]) > 1 and (schema_name or catalog_name):
|
|
221
|
-
logger.error("--schema-name and --catalog-name incompatible with multiple schemas")
|
|
222
|
-
raise click.exceptions.Exit(1)
|
|
223
|
-
|
|
224
|
-
# Force normalized["@graph"] to a list, which is what happens when there's
|
|
225
|
-
# multiple schemas
|
|
226
|
-
if isinstance(normalized["@graph"], dict):
|
|
227
|
-
normalized["@graph"] = [normalized["@graph"]]
|
|
208
|
+
yaml_data = yaml.load(file, Loader=yaml.SafeLoader)
|
|
209
|
+
schema = Schema.model_validate(yaml_data)
|
|
228
210
|
|
|
229
211
|
tap_tables = init_tables(
|
|
230
212
|
tap_schema_name,
|
|
@@ -243,28 +225,28 @@ def load_tap(
|
|
|
243
225
|
# In Memory SQLite - Mostly used to test
|
|
244
226
|
Tap11Base.metadata.create_all(engine)
|
|
245
227
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
228
|
+
tap_visitor = TapLoadingVisitor(
|
|
229
|
+
engine,
|
|
230
|
+
catalog_name=catalog_name,
|
|
231
|
+
schema_name=schema_name,
|
|
232
|
+
tap_tables=tap_tables,
|
|
233
|
+
tap_schema_index=tap_schema_index,
|
|
234
|
+
)
|
|
235
|
+
tap_visitor.visit_schema(schema)
|
|
254
236
|
else:
|
|
255
237
|
_insert_dump = InsertDump()
|
|
256
238
|
conn = create_mock_engine(make_url(engine_url), executor=_insert_dump.dump, paramstyle="pyformat")
|
|
257
239
|
# After the engine is created, update the executor with the dialect
|
|
258
240
|
_insert_dump.dialect = conn.dialect
|
|
259
241
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
242
|
+
tap_visitor = TapLoadingVisitor.from_mock_connection(
|
|
243
|
+
conn,
|
|
244
|
+
catalog_name=catalog_name,
|
|
245
|
+
schema_name=schema_name,
|
|
246
|
+
tap_tables=tap_tables,
|
|
247
|
+
tap_schema_index=tap_schema_index,
|
|
248
|
+
)
|
|
249
|
+
tap_visitor.visit_schema(schema)
|
|
268
250
|
|
|
269
251
|
|
|
270
252
|
@cli.command("modify-tap")
|
|
@@ -373,22 +355,37 @@ def merge(files: Iterable[io.TextIOBase]) -> None:
|
|
|
373
355
|
type=click.Choice(["RSP", "default"]),
|
|
374
356
|
default="default",
|
|
375
357
|
)
|
|
376
|
-
@click.option(
|
|
358
|
+
@click.option(
|
|
359
|
+
"-d", "--require-description", is_flag=True, help="Require description for all objects", default=False
|
|
360
|
+
)
|
|
361
|
+
@click.option(
|
|
362
|
+
"-t", "--check-redundant-datatypes", is_flag=True, help="Check for redundant datatypes", default=False
|
|
363
|
+
)
|
|
377
364
|
@click.argument("files", nargs=-1, type=click.File())
|
|
378
|
-
def validate(
|
|
365
|
+
def validate(
|
|
366
|
+
schema_name: str,
|
|
367
|
+
require_description: bool,
|
|
368
|
+
check_redundant_datatypes: bool,
|
|
369
|
+
files: Iterable[io.TextIOBase],
|
|
370
|
+
) -> None:
|
|
379
371
|
"""Validate one or more felis YAML files."""
|
|
380
372
|
schema_class = get_schema(schema_name)
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
if require_description:
|
|
384
|
-
Schema.require_description(True)
|
|
373
|
+
if schema_name != "default":
|
|
374
|
+
logger.info(f"Using schema '{schema_class.__name__}'")
|
|
385
375
|
|
|
386
376
|
rc = 0
|
|
387
377
|
for file in files:
|
|
388
378
|
file_name = getattr(file, "name", None)
|
|
389
379
|
logger.info(f"Validating {file_name}")
|
|
390
380
|
try:
|
|
391
|
-
|
|
381
|
+
data = yaml.load(file, Loader=yaml.SafeLoader)
|
|
382
|
+
schema_class.model_validate(
|
|
383
|
+
data,
|
|
384
|
+
context={
|
|
385
|
+
"check_redundant_datatypes": check_redundant_datatypes,
|
|
386
|
+
"require_description": require_description,
|
|
387
|
+
},
|
|
388
|
+
)
|
|
392
389
|
except ValidationError as e:
|
|
393
390
|
logger.error(e)
|
|
394
391
|
rc = 1
|
|
@@ -22,13 +22,22 @@
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
24
|
import logging
|
|
25
|
+
import re
|
|
25
26
|
from collections.abc import Mapping, Sequence
|
|
26
|
-
from enum import
|
|
27
|
+
from enum import StrEnum, auto
|
|
27
28
|
from typing import Annotated, Any, Literal, TypeAlias
|
|
28
29
|
|
|
29
30
|
from astropy import units as units # type: ignore
|
|
30
31
|
from astropy.io.votable import ucd # type: ignore
|
|
31
|
-
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
32
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator
|
|
33
|
+
from sqlalchemy import dialects
|
|
34
|
+
from sqlalchemy import types as sqa_types
|
|
35
|
+
from sqlalchemy.engine import create_mock_engine
|
|
36
|
+
from sqlalchemy.engine.interfaces import Dialect
|
|
37
|
+
from sqlalchemy.types import TypeEngine
|
|
38
|
+
|
|
39
|
+
from .db.sqltypes import get_type_func
|
|
40
|
+
from .types import FelisType
|
|
32
41
|
|
|
33
42
|
logger = logging.getLogger(__name__)
|
|
34
43
|
|
|
@@ -49,7 +58,6 @@ __all__ = (
|
|
|
49
58
|
CONFIG = ConfigDict(
|
|
50
59
|
populate_by_name=True, # Populate attributes by name.
|
|
51
60
|
extra="forbid", # Do not allow extra fields.
|
|
52
|
-
validate_assignment=True, # Validate assignments after model is created.
|
|
53
61
|
str_strip_whitespace=True, # Strip whitespace from string fields.
|
|
54
62
|
)
|
|
55
63
|
"""Pydantic model configuration as described in:
|
|
@@ -83,40 +91,85 @@ class BaseObject(BaseModel):
|
|
|
83
91
|
"""
|
|
84
92
|
|
|
85
93
|
description: DescriptionStr | None = None
|
|
86
|
-
"""A description of the database object.
|
|
94
|
+
"""A description of the database object."""
|
|
87
95
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
"""
|
|
96
|
+
votable_utype: str | None = Field(None, alias="votable:utype")
|
|
97
|
+
"""The VOTable utype (usage-specific or unique type) of the object."""
|
|
91
98
|
|
|
92
|
-
@model_validator(mode="
|
|
93
|
-
|
|
94
|
-
def check_description(cls, values: dict[str, Any]) -> dict[str, Any]:
|
|
99
|
+
@model_validator(mode="after")
|
|
100
|
+
def check_description(self, info: ValidationInfo) -> BaseObject:
|
|
95
101
|
"""Check that the description is present if required."""
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
context = info.context
|
|
103
|
+
if not context or not context.get("require_description", False):
|
|
104
|
+
return self
|
|
105
|
+
if self.description is None or self.description == "":
|
|
106
|
+
raise ValueError("Description is required and must be non-empty")
|
|
107
|
+
if len(self.description) < DESCR_MIN_LENGTH:
|
|
108
|
+
raise ValueError(f"Description must be at least {DESCR_MIN_LENGTH} characters long")
|
|
109
|
+
return self
|
|
102
110
|
|
|
103
111
|
|
|
104
|
-
class DataType(
|
|
112
|
+
class DataType(StrEnum):
|
|
105
113
|
"""`Enum` representing the data types supported by Felis."""
|
|
106
114
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
boolean = auto()
|
|
116
|
+
byte = auto()
|
|
117
|
+
short = auto()
|
|
118
|
+
int = auto()
|
|
119
|
+
long = auto()
|
|
120
|
+
float = auto()
|
|
121
|
+
double = auto()
|
|
122
|
+
char = auto()
|
|
123
|
+
string = auto()
|
|
124
|
+
unicode = auto()
|
|
125
|
+
text = auto()
|
|
126
|
+
binary = auto()
|
|
127
|
+
timestamp = auto()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_DIALECTS = {
|
|
131
|
+
"mysql": create_mock_engine("mysql://", executor=None).dialect,
|
|
132
|
+
"postgresql": create_mock_engine("postgresql://", executor=None).dialect,
|
|
133
|
+
}
|
|
134
|
+
"""Dictionary of dialect names to SQLAlchemy dialects."""
|
|
135
|
+
|
|
136
|
+
_DIALECT_MODULES = {"mysql": getattr(dialects, "mysql"), "postgresql": getattr(dialects, "postgresql")}
|
|
137
|
+
"""Dictionary of dialect names to SQLAlchemy dialect modules."""
|
|
138
|
+
|
|
139
|
+
_DATATYPE_REGEXP = re.compile(r"(\w+)(\((.*)\))?")
|
|
140
|
+
"""Regular expression to match data types in the form "type(length)"""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def string_to_typeengine(
|
|
144
|
+
type_string: str, dialect: Dialect | None = None, length: int | None = None
|
|
145
|
+
) -> TypeEngine:
|
|
146
|
+
match = _DATATYPE_REGEXP.search(type_string)
|
|
147
|
+
if not match:
|
|
148
|
+
raise ValueError(f"Invalid type string: {type_string}")
|
|
149
|
+
|
|
150
|
+
type_name, _, params = match.groups()
|
|
151
|
+
if dialect is None:
|
|
152
|
+
type_class = getattr(sqa_types, type_name.upper(), None)
|
|
153
|
+
else:
|
|
154
|
+
try:
|
|
155
|
+
dialect_module = _DIALECT_MODULES[dialect.name]
|
|
156
|
+
except KeyError:
|
|
157
|
+
raise ValueError(f"Unsupported dialect: {dialect}")
|
|
158
|
+
type_class = getattr(dialect_module, type_name.upper(), None)
|
|
159
|
+
|
|
160
|
+
if not type_class:
|
|
161
|
+
raise ValueError(f"Unsupported type: {type_class}")
|
|
162
|
+
|
|
163
|
+
if params:
|
|
164
|
+
params = [int(param) if param.isdigit() else param for param in params.split(",")]
|
|
165
|
+
type_obj = type_class(*params)
|
|
166
|
+
else:
|
|
167
|
+
type_obj = type_class()
|
|
168
|
+
|
|
169
|
+
if hasattr(type_obj, "length") and getattr(type_obj, "length") is None and length is not None:
|
|
170
|
+
type_obj.length = length
|
|
171
|
+
|
|
172
|
+
return type_obj
|
|
120
173
|
|
|
121
174
|
|
|
122
175
|
class Column(BaseObject):
|
|
@@ -128,13 +181,8 @@ class Column(BaseObject):
|
|
|
128
181
|
length: int | None = None
|
|
129
182
|
"""The length of the column."""
|
|
130
183
|
|
|
131
|
-
nullable: bool
|
|
132
|
-
"""Whether the column can be ``NULL``.
|
|
133
|
-
|
|
134
|
-
If `None`, this value was not set explicitly in the YAML data. In this
|
|
135
|
-
case, it will be set to `False` for columns with numeric types and `True`
|
|
136
|
-
otherwise.
|
|
137
|
-
"""
|
|
184
|
+
nullable: bool = True
|
|
185
|
+
"""Whether the column can be ``NULL``."""
|
|
138
186
|
|
|
139
187
|
value: Any = None
|
|
140
188
|
"""The default value of the column."""
|
|
@@ -171,12 +219,12 @@ class Column(BaseObject):
|
|
|
171
219
|
"""TAP_SCHEMA indication that this column is defined by an IVOA standard.
|
|
172
220
|
"""
|
|
173
221
|
|
|
174
|
-
votable_utype: str | None = Field(None, alias="votable:utype")
|
|
175
|
-
"""The VOTable utype (usage-specific or unique type) of the column."""
|
|
176
|
-
|
|
177
222
|
votable_xtype: str | None = Field(None, alias="votable:xtype")
|
|
178
223
|
"""The VOTable xtype (extended type) of the column."""
|
|
179
224
|
|
|
225
|
+
votable_datatype: str | None = Field(None, alias="votable:datatype")
|
|
226
|
+
"""The VOTable datatype of the column."""
|
|
227
|
+
|
|
180
228
|
@field_validator("ivoa_ucd")
|
|
181
229
|
@classmethod
|
|
182
230
|
def check_ivoa_ucd(cls, ivoa_ucd: str) -> str:
|
|
@@ -207,6 +255,57 @@ class Column(BaseObject):
|
|
|
207
255
|
|
|
208
256
|
return values
|
|
209
257
|
|
|
258
|
+
@model_validator(mode="after") # type: ignore[arg-type]
|
|
259
|
+
@classmethod
|
|
260
|
+
def validate_datatypes(cls, col: Column, info: ValidationInfo) -> Column:
|
|
261
|
+
"""Check for redundant datatypes on columns."""
|
|
262
|
+
context = info.context
|
|
263
|
+
if not context or not context.get("check_redundant_datatypes", False):
|
|
264
|
+
return col
|
|
265
|
+
if all(getattr(col, f"{dialect}:datatype", None) is not None for dialect in _DIALECTS.keys()):
|
|
266
|
+
return col
|
|
267
|
+
|
|
268
|
+
datatype = col.datatype
|
|
269
|
+
length: int | None = col.length or None
|
|
270
|
+
|
|
271
|
+
datatype_func = get_type_func(datatype)
|
|
272
|
+
felis_type = FelisType.felis_type(datatype)
|
|
273
|
+
if felis_type.is_sized:
|
|
274
|
+
if length is not None:
|
|
275
|
+
datatype_obj = datatype_func(length)
|
|
276
|
+
else:
|
|
277
|
+
raise ValueError(f"Length must be provided for sized type '{datatype}' in column '{col.id}'")
|
|
278
|
+
else:
|
|
279
|
+
datatype_obj = datatype_func()
|
|
280
|
+
|
|
281
|
+
for dialect_name, dialect in _DIALECTS.items():
|
|
282
|
+
db_annotation = f"{dialect_name}_datatype"
|
|
283
|
+
if datatype_string := col.model_dump().get(db_annotation):
|
|
284
|
+
db_datatype_obj = string_to_typeengine(datatype_string, dialect, length)
|
|
285
|
+
if datatype_obj.compile(dialect) == db_datatype_obj.compile(dialect):
|
|
286
|
+
raise ValueError(
|
|
287
|
+
"'{}: {}' is a redundant override of 'datatype: {}' in column '{}'{}".format(
|
|
288
|
+
db_annotation,
|
|
289
|
+
datatype_string,
|
|
290
|
+
col.datatype,
|
|
291
|
+
col.id,
|
|
292
|
+
"" if length is None else f" with length {length}",
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
else:
|
|
296
|
+
logger.debug(
|
|
297
|
+
"Type override of 'datatype: {}' with '{}: {}' in column '{}' "
|
|
298
|
+
"compiled to '{}' and '{}'".format(
|
|
299
|
+
col.datatype,
|
|
300
|
+
db_annotation,
|
|
301
|
+
datatype_string,
|
|
302
|
+
col.id,
|
|
303
|
+
datatype_obj.compile(dialect),
|
|
304
|
+
db_datatype_obj.compile(dialect),
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
return col
|
|
308
|
+
|
|
210
309
|
|
|
211
310
|
class Constraint(BaseObject):
|
|
212
311
|
"""A database table constraint."""
|
|
@@ -404,15 +503,6 @@ class SchemaIdVisitor:
|
|
|
404
503
|
class Schema(BaseObject):
|
|
405
504
|
"""The database schema containing the tables."""
|
|
406
505
|
|
|
407
|
-
class ValidationConfig:
|
|
408
|
-
"""Validation configuration which is specific to Felis."""
|
|
409
|
-
|
|
410
|
-
_require_description = False
|
|
411
|
-
"""Flag to require a description for all objects.
|
|
412
|
-
|
|
413
|
-
This is set by the `require_description` class method.
|
|
414
|
-
"""
|
|
415
|
-
|
|
416
506
|
version: SchemaVersion | str | None = None
|
|
417
507
|
"""The version of the schema."""
|
|
418
508
|
|
|
@@ -430,21 +520,29 @@ class Schema(BaseObject):
|
|
|
430
520
|
raise ValueError("Table names must be unique")
|
|
431
521
|
return tables
|
|
432
522
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
523
|
+
def _create_id_map(self: Schema) -> Schema:
|
|
524
|
+
"""Create a map of IDs to objects.
|
|
525
|
+
|
|
526
|
+
This method should not be called by users. It is called automatically
|
|
527
|
+
by the ``model_post_init()`` method. If the ID map is already
|
|
528
|
+
populated, this method will return immediately.
|
|
529
|
+
"""
|
|
436
530
|
if len(self.id_map):
|
|
437
|
-
logger.debug("ID map was already populated")
|
|
531
|
+
logger.debug("Ignoring call to create_id_map() - ID map was already populated")
|
|
438
532
|
return self
|
|
439
533
|
visitor: SchemaIdVisitor = SchemaIdVisitor()
|
|
440
534
|
visitor.visit_schema(self)
|
|
441
|
-
logger.debug(f"ID map
|
|
535
|
+
logger.debug(f"Created schema ID map with {len(self.id_map.keys())} objects")
|
|
442
536
|
if len(visitor.duplicates):
|
|
443
537
|
raise ValueError(
|
|
444
538
|
"Duplicate IDs found in schema:\n " + "\n ".join(visitor.duplicates) + "\n"
|
|
445
539
|
)
|
|
446
540
|
return self
|
|
447
541
|
|
|
542
|
+
def model_post_init(self, ctx: Any) -> None:
|
|
543
|
+
"""Post-initialization hook for the model."""
|
|
544
|
+
self._create_id_map()
|
|
545
|
+
|
|
448
546
|
def __getitem__(self, id: str) -> BaseObject:
|
|
449
547
|
"""Get an object by its ID."""
|
|
450
548
|
if id not in self:
|
|
@@ -454,20 +552,3 @@ class Schema(BaseObject):
|
|
|
454
552
|
def __contains__(self, id: str) -> bool:
|
|
455
553
|
"""Check if an object with the given ID is in the schema."""
|
|
456
554
|
return id in self.id_map
|
|
457
|
-
|
|
458
|
-
@classmethod
|
|
459
|
-
def require_description(cls, rd: bool = True) -> None:
|
|
460
|
-
"""Set whether a description is required for all objects.
|
|
461
|
-
|
|
462
|
-
This includes the schema, tables, columns, and constraints.
|
|
463
|
-
|
|
464
|
-
Users should call this method to set the requirement for a description
|
|
465
|
-
when validating schemas, rather than change the flag value directly.
|
|
466
|
-
"""
|
|
467
|
-
logger.debug(f"Setting description requirement to '{rd}'")
|
|
468
|
-
cls.ValidationConfig._require_description = rd
|
|
469
|
-
|
|
470
|
-
@classmethod
|
|
471
|
-
def is_description_required(cls) -> bool:
|
|
472
|
-
"""Return whether a description is required for all objects."""
|
|
473
|
-
return cls.ValidationConfig._require_description
|
|
@@ -40,10 +40,10 @@ TABLE_OPTS = {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
COLUMN_VARIANT_OVERRIDE = {
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
43
|
+
"mysql_datatype": "mysql",
|
|
44
|
+
"oracle_datatype": "oracle",
|
|
45
|
+
"postgresql_datatype": "postgresql",
|
|
46
|
+
"sqlite_datatype": "sqlite",
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
DIALECT_MODULES = {MYSQL: mysql, ORACLE: oracle, SQLITE: sqlite, POSTGRES: postgresql}
|
|
@@ -87,7 +87,7 @@ def make_variant_dict(column_obj: Column) -> dict[str, TypeEngine[Any]]:
|
|
|
87
87
|
"""
|
|
88
88
|
variant_dict = {}
|
|
89
89
|
for field_name, value in iter(column_obj):
|
|
90
|
-
if field_name in COLUMN_VARIANT_OVERRIDE:
|
|
90
|
+
if field_name in COLUMN_VARIANT_OVERRIDE and value is not None:
|
|
91
91
|
dialect = COLUMN_VARIANT_OVERRIDE[field_name]
|
|
92
92
|
variant: TypeEngine = process_variant_override(dialect, value)
|
|
93
93
|
variant_dict[dialect] = variant
|
|
@@ -21,9 +21,9 @@
|
|
|
21
21
|
|
|
22
22
|
import builtins
|
|
23
23
|
from collections.abc import Mapping
|
|
24
|
-
from typing import Any
|
|
24
|
+
from typing import Any, Callable
|
|
25
25
|
|
|
26
|
-
from sqlalchemy import
|
|
26
|
+
from sqlalchemy import SmallInteger, types
|
|
27
27
|
from sqlalchemy.dialects import mysql, oracle, postgresql
|
|
28
28
|
from sqlalchemy.ext.compiler import compiles
|
|
29
29
|
|
|
@@ -39,27 +39,15 @@ class TINYINT(SmallInteger):
|
|
|
39
39
|
__visit_name__ = "TINYINT"
|
|
40
40
|
|
|
41
41
|
|
|
42
|
-
class DOUBLE(Float):
|
|
43
|
-
"""The non-standard DOUBLE type."""
|
|
44
|
-
|
|
45
|
-
__visit_name__ = "DOUBLE"
|
|
46
|
-
|
|
47
|
-
|
|
48
42
|
@compiles(TINYINT)
|
|
49
43
|
def compile_tinyint(type_: Any, compiler: Any, **kw: Any) -> str:
|
|
50
44
|
"""Return type name for TINYINT."""
|
|
51
45
|
return "TINYINT"
|
|
52
46
|
|
|
53
47
|
|
|
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
48
|
_TypeMap = Mapping[str, types.TypeEngine | type[types.TypeEngine]]
|
|
61
49
|
|
|
62
|
-
boolean_map: _TypeMap = {MYSQL: mysql.
|
|
50
|
+
boolean_map: _TypeMap = {MYSQL: mysql.BOOLEAN, ORACLE: oracle.NUMBER(1), POSTGRES: postgresql.BOOLEAN()}
|
|
63
51
|
|
|
64
52
|
byte_map: _TypeMap = {
|
|
65
53
|
MYSQL: mysql.TINYINT(),
|
|
@@ -160,7 +148,7 @@ def float(**kwargs: Any) -> types.TypeEngine:
|
|
|
160
148
|
|
|
161
149
|
def double(**kwargs: Any) -> types.TypeEngine:
|
|
162
150
|
"""Return SQLAlchemy type for double precision float."""
|
|
163
|
-
return _vary(DOUBLE(), double_map, kwargs)
|
|
151
|
+
return _vary(types.DOUBLE(), double_map, kwargs)
|
|
164
152
|
|
|
165
153
|
|
|
166
154
|
def char(length: builtins.int, **kwargs: Any) -> types.TypeEngine:
|
|
@@ -178,9 +166,9 @@ def unicode(length: builtins.int, **kwargs: Any) -> types.TypeEngine:
|
|
|
178
166
|
return _vary(types.NVARCHAR(length), unicode_map, kwargs, length)
|
|
179
167
|
|
|
180
168
|
|
|
181
|
-
def text(
|
|
169
|
+
def text(**kwargs: Any) -> types.TypeEngine:
|
|
182
170
|
"""Return SQLAlchemy type for text."""
|
|
183
|
-
return _vary(types.
|
|
171
|
+
return _vary(types.TEXT(), text_map, kwargs)
|
|
184
172
|
|
|
185
173
|
|
|
186
174
|
def binary(length: builtins.int, **kwargs: Any) -> types.TypeEngine:
|
|
@@ -193,6 +181,13 @@ def timestamp(**kwargs: Any) -> types.TypeEngine:
|
|
|
193
181
|
return types.TIMESTAMP()
|
|
194
182
|
|
|
195
183
|
|
|
184
|
+
def get_type_func(type_name: str) -> Callable:
|
|
185
|
+
"""Return the function for the type with the given name."""
|
|
186
|
+
if type_name not in globals():
|
|
187
|
+
raise ValueError(f"Unknown type: {type_name}")
|
|
188
|
+
return globals()[type_name]
|
|
189
|
+
|
|
190
|
+
|
|
196
191
|
def _vary(
|
|
197
192
|
type_: types.TypeEngine,
|
|
198
193
|
variant_map: _TypeMap,
|
|
@@ -203,7 +198,7 @@ def _vary(
|
|
|
203
198
|
variants.update(overrides)
|
|
204
199
|
for dialect, variant in variants.items():
|
|
205
200
|
# If this is a class and not an instance, instantiate
|
|
206
|
-
if
|
|
201
|
+
if callable(variant):
|
|
207
202
|
variant = variant(*args)
|
|
208
203
|
type_ = type_.with_variant(variant, dialect)
|
|
209
204
|
return type_
|
|
@@ -34,7 +34,6 @@ from sqlalchemy import (
|
|
|
34
34
|
ForeignKeyConstraint,
|
|
35
35
|
Index,
|
|
36
36
|
MetaData,
|
|
37
|
-
Numeric,
|
|
38
37
|
PrimaryKeyConstraint,
|
|
39
38
|
ResultProxy,
|
|
40
39
|
Table,
|
|
@@ -265,17 +264,12 @@ class MetaDataBuilder:
|
|
|
265
264
|
id = column_obj.id
|
|
266
265
|
description = column_obj.description
|
|
267
266
|
default = column_obj.value
|
|
267
|
+
nullable = column_obj.nullable
|
|
268
268
|
|
|
269
|
-
#
|
|
269
|
+
# Get datatype, handling variant overrides such as "mysql:datatype".
|
|
270
270
|
datatype = get_datatype_with_variants(column_obj)
|
|
271
271
|
|
|
272
|
-
# Set
|
|
273
|
-
# it was explicitly provided in the schema data.
|
|
274
|
-
nullable = column_obj.nullable
|
|
275
|
-
if nullable is None:
|
|
276
|
-
nullable = False if isinstance(datatype, Numeric) else True
|
|
277
|
-
|
|
278
|
-
# Set autoincrement depending on if it was provided explicitly.
|
|
272
|
+
# Set autoincrement, depending on if it was provided explicitly.
|
|
279
273
|
autoincrement: Literal["auto"] | bool = (
|
|
280
274
|
column_obj.autoincrement if column_obj.autoincrement is not None else "auto"
|
|
281
275
|
)
|