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.

Files changed (39) hide show
  1. {lsst-felis-26.2024.1500/python/lsst_felis.egg-info → lsst_felis-26.2024.1700}/PKG-INFO +5 -2
  2. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/pyproject.toml +5 -6
  3. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/cli.py +42 -45
  4. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/datamodel.py +152 -71
  5. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/db/_variants.py +5 -5
  6. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/db/sqltypes.py +14 -19
  7. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/metadata.py +3 -9
  8. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/tap.py +67 -80
  9. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/types.py +1 -1
  10. lsst_felis-26.2024.1700/python/felis/version.py +2 -0
  11. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700/python/lsst_felis.egg-info}/PKG-INFO +5 -2
  12. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/lsst_felis.egg-info/SOURCES.txt +1 -0
  13. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/lsst_felis.egg-info/requires.txt +3 -0
  14. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/tests/test_cli.py +0 -4
  15. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/tests/test_datamodel.py +61 -31
  16. lsst_felis-26.2024.1700/tests/test_datatypes.py +116 -0
  17. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/tests/test_tap.py +5 -10
  18. lsst-felis-26.2024.1500/python/felis/version.py +0 -2
  19. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/COPYRIGHT +0 -0
  20. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/LICENSE +0 -0
  21. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/README.rst +0 -0
  22. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/__init__.py +0 -0
  23. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/check.py +0 -0
  24. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/db/__init__.py +0 -0
  25. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/py.typed +0 -0
  26. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/simple.py +0 -0
  27. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/utils.py +0 -0
  28. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/validation.py +0 -0
  29. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/felis/visitor.py +0 -0
  30. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/lsst_felis.egg-info/dependency_links.txt +0 -0
  31. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/lsst_felis.egg-info/entry_points.txt +0 -0
  32. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/lsst_felis.egg-info/top_level.txt +0 -0
  33. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/python/lsst_felis.egg-info/zip-safe +0 -0
  34. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/setup.cfg +0 -0
  35. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/tests/test_check.py +0 -0
  36. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/tests/test_metadata.py +0 -0
  37. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/tests/test_simple.py +0 -0
  38. {lsst-felis-26.2024.1500 → lsst_felis-26.2024.1700}/tests/test_utils.py +0 -0
  39. {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.1500
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://github.com/lsst/felis
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
- "Homepage" = "https://github.com/lsst/felis"
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
- top_level_object = yaml.load(file, Loader=yaml.SafeLoader)
207
- schema_obj: dict
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
- for schema in normalized["@graph"]:
247
- tap_visitor = TapLoadingVisitor(
248
- engine,
249
- catalog_name=catalog_name,
250
- schema_name=schema_name,
251
- tap_tables=tap_tables,
252
- )
253
- tap_visitor.visit_schema(schema)
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
- for schema in normalized["@graph"]:
261
- tap_visitor = TapLoadingVisitor.from_mock_connection(
262
- conn,
263
- catalog_name=catalog_name,
264
- schema_name=schema_name,
265
- tap_tables=tap_tables,
266
- )
267
- tap_visitor.visit_schema(schema)
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("-d", "--require-description", is_flag=True, help="Require description for all objects")
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(schema_name: str, require_description: bool, files: Iterable[io.TextIOBase]) -> None:
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
- logger.info(f"Using schema '{schema_class.__name__}'")
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
- schema_class.model_validate(yaml.load(file, Loader=yaml.SafeLoader))
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 Enum
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
- By default, the description is optional but will be required if
89
- `BaseObject.Config.require_description` is set to `True` by the user.
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="before")
93
- @classmethod
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
- if Schema.is_description_required():
97
- if "description" not in values or not values["description"]:
98
- raise ValueError("Description is required and must be non-empty")
99
- if len(values["description"].strip()) < DESCR_MIN_LENGTH:
100
- raise ValueError(f"Description must be at least {DESCR_MIN_LENGTH} characters long")
101
- return values
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(Enum):
112
+ class DataType(StrEnum):
105
113
  """`Enum` representing the data types supported by Felis."""
106
114
 
107
- BOOLEAN = "boolean"
108
- BYTE = "byte"
109
- SHORT = "short"
110
- INT = "int"
111
- LONG = "long"
112
- FLOAT = "float"
113
- DOUBLE = "double"
114
- CHAR = "char"
115
- STRING = "string"
116
- UNICODE = "unicode"
117
- TEXT = "text"
118
- BINARY = "binary"
119
- TIMESTAMP = "timestamp"
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 | None = None
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
- @model_validator(mode="after")
434
- def create_id_map(self: Schema) -> Schema:
435
- """Create a map of IDs to objects."""
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 contains {len(self.id_map.keys())} objects")
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
- "mysql:datatype": "mysql",
44
- "oracle:datatype": "oracle",
45
- "postgresql:datatype": "postgresql",
46
- "sqlite:datatype": "sqlite",
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 Float, SmallInteger, types
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.BIT(1), ORACLE: oracle.NUMBER(1), POSTGRES: postgresql.BOOLEAN()}
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(length: builtins.int, **kwargs: Any) -> types.TypeEngine:
169
+ def text(**kwargs: Any) -> types.TypeEngine:
182
170
  """Return SQLAlchemy type for text."""
183
- return _vary(types.CLOB(length), text_map, kwargs, length)
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 isinstance(variant, type):
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
- # Handle variant overrides for the column (e.g., "mysql:datatype").
269
+ # Get datatype, handling variant overrides such as "mysql:datatype".
270
270
  datatype = get_datatype_with_variants(column_obj)
271
271
 
272
- # Set default value of nullable based on column type and then whether
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
  )