lsst-felis 28.2025.402__tar.gz → 28.2025.600__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-28.2025.402/python/lsst_felis.egg-info → lsst_felis-28.2025.600}/PKG-INFO +1 -1
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/cli.py +35 -167
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/datamodel.py +131 -6
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/metadata.py +5 -1
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/tap_schema.py +13 -5
- lsst_felis-28.2025.600/python/felis/version.py +2 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600/python/lsst_felis.egg-info}/PKG-INFO +1 -1
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/lsst_felis.egg-info/SOURCES.txt +0 -2
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/tests/test_cli.py +17 -35
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/tests/test_datamodel.py +71 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/tests/test_metadata.py +9 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/tests/test_tap_schema.py +6 -44
- lsst_felis-28.2025.402/python/felis/tap.py +0 -597
- lsst_felis-28.2025.402/python/felis/version.py +0 -2
- lsst_felis-28.2025.402/tests/test_tap.py +0 -66
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/COPYRIGHT +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/LICENSE +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/README.rst +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/pyproject.toml +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/__init__.py +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/db/__init__.py +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/db/dialects.py +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/db/schema.py +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/db/sqltypes.py +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/db/utils.py +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/db/variants.py +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/diff.py +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/py.typed +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/schemas/tap_schema_std.yaml +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/tests/__init__.py +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/tests/postgresql.py +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/felis/types.py +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/lsst_felis.egg-info/dependency_links.txt +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/lsst_felis.egg-info/entry_points.txt +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/lsst_felis.egg-info/requires.txt +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/lsst_felis.egg-info/top_level.txt +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/python/lsst_felis.egg-info/zip-safe +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/setup.cfg +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/tests/test_db.py +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/tests/test_diff.py +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/tests/test_postgres.py +0 -0
- {lsst_felis-28.2025.402 → lsst_felis-28.2025.600}/tests/test_tap_schema_postgres.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: lsst-felis
|
|
3
|
-
Version: 28.2025.
|
|
3
|
+
Version: 28.2025.600
|
|
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+)
|
|
@@ -38,7 +38,6 @@ from .db.schema import create_database
|
|
|
38
38
|
from .db.utils import DatabaseContext, is_mock_url
|
|
39
39
|
from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff
|
|
40
40
|
from .metadata import MetaDataBuilder
|
|
41
|
-
from .tap import Tap11Base, TapLoadingVisitor, init_tables
|
|
42
41
|
from .tap_schema import DataLoader, TableManager
|
|
43
42
|
|
|
44
43
|
__all__ = ["cli"]
|
|
@@ -179,174 +178,9 @@ def create(
|
|
|
179
178
|
raise click.ClickException(str(e))
|
|
180
179
|
|
|
181
180
|
|
|
182
|
-
@cli.command("init-tap", help="Initialize TAP_SCHEMA objects in the database")
|
|
183
|
-
@click.option("--tap-schema-name", help="Alternate database schema name for 'TAP_SCHEMA'")
|
|
184
|
-
@click.option("--tap-schemas-table", help="Alternate table name for 'schemas'")
|
|
185
|
-
@click.option("--tap-tables-table", help="Alternate table name for 'tables'")
|
|
186
|
-
@click.option("--tap-columns-table", help="Alternate table name for 'columns'")
|
|
187
|
-
@click.option("--tap-keys-table", help="Alternate table name for 'keys'")
|
|
188
|
-
@click.option("--tap-key-columns-table", help="Alternate table name for 'key_columns'")
|
|
189
|
-
@click.argument("engine-url")
|
|
190
|
-
def init_tap(
|
|
191
|
-
engine_url: str,
|
|
192
|
-
tap_schema_name: str,
|
|
193
|
-
tap_schemas_table: str,
|
|
194
|
-
tap_tables_table: str,
|
|
195
|
-
tap_columns_table: str,
|
|
196
|
-
tap_keys_table: str,
|
|
197
|
-
tap_key_columns_table: str,
|
|
198
|
-
) -> None:
|
|
199
|
-
"""Initialize TAP_SCHEMA objects in the database.
|
|
200
|
-
|
|
201
|
-
Parameters
|
|
202
|
-
----------
|
|
203
|
-
engine_url
|
|
204
|
-
SQLAlchemy Engine URL. The target PostgreSQL schema or MySQL database
|
|
205
|
-
must already exist and be referenced in the URL.
|
|
206
|
-
tap_schema_name
|
|
207
|
-
Alterate name for the database schema ``TAP_SCHEMA``.
|
|
208
|
-
tap_schemas_table
|
|
209
|
-
Alterate table name for ``schemas``.
|
|
210
|
-
tap_tables_table
|
|
211
|
-
Alterate table name for ``tables``.
|
|
212
|
-
tap_columns_table
|
|
213
|
-
Alterate table name for ``columns``.
|
|
214
|
-
tap_keys_table
|
|
215
|
-
Alterate table name for ``keys``.
|
|
216
|
-
tap_key_columns_table
|
|
217
|
-
Alterate table name for ``key_columns``.
|
|
218
|
-
|
|
219
|
-
Notes
|
|
220
|
-
-----
|
|
221
|
-
The supported version of TAP_SCHEMA in the SQLAlchemy metadata is 1.1. The
|
|
222
|
-
tables are created in the database schema specified by the engine URL,
|
|
223
|
-
which must be a PostgreSQL schema or MySQL database that already exists.
|
|
224
|
-
"""
|
|
225
|
-
engine = create_engine(engine_url)
|
|
226
|
-
init_tables(
|
|
227
|
-
tap_schema_name,
|
|
228
|
-
tap_schemas_table,
|
|
229
|
-
tap_tables_table,
|
|
230
|
-
tap_columns_table,
|
|
231
|
-
tap_keys_table,
|
|
232
|
-
tap_key_columns_table,
|
|
233
|
-
)
|
|
234
|
-
Tap11Base.metadata.create_all(engine)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
@cli.command("load-tap", help="Load metadata from a Felis file into a TAP_SCHEMA database")
|
|
238
|
-
@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
|
|
239
|
-
@click.option("--schema-name", help="Alternate Schema Name for Felis file")
|
|
240
|
-
@click.option("--catalog-name", help="Catalog Name for Schema")
|
|
241
|
-
@click.option("--dry-run", is_flag=True, help="Dry Run Only. Prints out the DDL that would be executed")
|
|
242
|
-
@click.option("--tap-schema-name", help="Alternate schema name for 'TAP_SCHEMA'")
|
|
243
|
-
@click.option("--tap-tables-postfix", help="Postfix for TAP_SCHEMA table names")
|
|
244
|
-
@click.option("--tap-schemas-table", help="Alternate table name for 'schemas'")
|
|
245
|
-
@click.option("--tap-tables-table", help="Alternate table name for 'tables'")
|
|
246
|
-
@click.option("--tap-columns-table", help="Alternate table name for 'columns'")
|
|
247
|
-
@click.option("--tap-keys-table", help="Alternate table name for 'keys'")
|
|
248
|
-
@click.option("--tap-key-columns-table", help="Alternate table name for 'key_columns'")
|
|
249
|
-
@click.option("--tap-schema-index", type=int, help="TAP_SCHEMA index of the schema in this environment")
|
|
250
|
-
@click.argument("file", type=click.File())
|
|
251
|
-
def load_tap(
|
|
252
|
-
engine_url: str,
|
|
253
|
-
schema_name: str,
|
|
254
|
-
catalog_name: str,
|
|
255
|
-
dry_run: bool,
|
|
256
|
-
tap_schema_name: str,
|
|
257
|
-
tap_tables_postfix: str,
|
|
258
|
-
tap_schemas_table: str,
|
|
259
|
-
tap_tables_table: str,
|
|
260
|
-
tap_columns_table: str,
|
|
261
|
-
tap_keys_table: str,
|
|
262
|
-
tap_key_columns_table: str,
|
|
263
|
-
tap_schema_index: int,
|
|
264
|
-
file: IO[str],
|
|
265
|
-
) -> None:
|
|
266
|
-
"""Load TAP metadata from a Felis file.
|
|
267
|
-
|
|
268
|
-
This command loads the associated TAP metadata from a Felis YAML file
|
|
269
|
-
into the TAP_SCHEMA tables.
|
|
270
|
-
|
|
271
|
-
Parameters
|
|
272
|
-
----------
|
|
273
|
-
engine_url
|
|
274
|
-
SQLAlchemy Engine URL to catalog.
|
|
275
|
-
schema_name
|
|
276
|
-
Alternate schema name. This overrides the schema name in the
|
|
277
|
-
``catalog`` field of the Felis file.
|
|
278
|
-
catalog_name
|
|
279
|
-
Catalog name for the schema. This possibly duplicates the
|
|
280
|
-
``tap_schema_name`` argument (DM-44870).
|
|
281
|
-
dry_run
|
|
282
|
-
Dry run only to print out commands instead of executing.
|
|
283
|
-
tap_schema_name
|
|
284
|
-
Alternate name for the schema of TAP_SCHEMA in the database.
|
|
285
|
-
tap_tables_postfix
|
|
286
|
-
Postfix for TAP table names that will be automatically appended.
|
|
287
|
-
tap_schemas_table
|
|
288
|
-
Alternate table name for ``schemas``.
|
|
289
|
-
tap_tables_table
|
|
290
|
-
Alternate table name for ``tables``.
|
|
291
|
-
tap_columns_table
|
|
292
|
-
Alternate table name for ``columns``.
|
|
293
|
-
tap_keys_table
|
|
294
|
-
Alternate table name for ``keys``.
|
|
295
|
-
tap_key_columns_table
|
|
296
|
-
Alternate table name for ``key_columns``.
|
|
297
|
-
tap_schema_index
|
|
298
|
-
TAP_SCHEMA index of the schema in this TAP environment.
|
|
299
|
-
file
|
|
300
|
-
Felis file to read.
|
|
301
|
-
|
|
302
|
-
Notes
|
|
303
|
-
-----
|
|
304
|
-
The data will be loaded into the TAP_SCHEMA from the engine URL. The
|
|
305
|
-
tables must have already been initialized or an error will occur.
|
|
306
|
-
"""
|
|
307
|
-
schema = Schema.from_stream(file)
|
|
308
|
-
|
|
309
|
-
tap_tables = init_tables(
|
|
310
|
-
tap_schema_name,
|
|
311
|
-
tap_tables_postfix,
|
|
312
|
-
tap_schemas_table,
|
|
313
|
-
tap_tables_table,
|
|
314
|
-
tap_columns_table,
|
|
315
|
-
tap_keys_table,
|
|
316
|
-
tap_key_columns_table,
|
|
317
|
-
)
|
|
318
|
-
|
|
319
|
-
if not dry_run:
|
|
320
|
-
engine = create_engine(engine_url)
|
|
321
|
-
|
|
322
|
-
if engine_url == "sqlite://" and not dry_run:
|
|
323
|
-
# In Memory SQLite - Mostly used to test
|
|
324
|
-
Tap11Base.metadata.create_all(engine)
|
|
325
|
-
|
|
326
|
-
tap_visitor = TapLoadingVisitor(
|
|
327
|
-
engine,
|
|
328
|
-
catalog_name=catalog_name,
|
|
329
|
-
schema_name=schema_name,
|
|
330
|
-
tap_tables=tap_tables,
|
|
331
|
-
tap_schema_index=tap_schema_index,
|
|
332
|
-
)
|
|
333
|
-
tap_visitor.visit_schema(schema)
|
|
334
|
-
else:
|
|
335
|
-
conn = DatabaseContext.create_mock_engine(engine_url)
|
|
336
|
-
|
|
337
|
-
tap_visitor = TapLoadingVisitor.from_mock_connection(
|
|
338
|
-
conn,
|
|
339
|
-
catalog_name=catalog_name,
|
|
340
|
-
schema_name=schema_name,
|
|
341
|
-
tap_tables=tap_tables,
|
|
342
|
-
tap_schema_index=tap_schema_index,
|
|
343
|
-
)
|
|
344
|
-
tap_visitor.visit_schema(schema)
|
|
345
|
-
|
|
346
|
-
|
|
347
181
|
@cli.command("load-tap-schema", help="Load metadata from a Felis file into a TAP_SCHEMA database")
|
|
348
182
|
@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
|
|
349
|
-
@click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database")
|
|
183
|
+
@click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database (default: TAP_SCHEMA)")
|
|
350
184
|
@click.option(
|
|
351
185
|
"--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
|
|
352
186
|
)
|
|
@@ -417,6 +251,40 @@ def load_tap_schema(
|
|
|
417
251
|
).load()
|
|
418
252
|
|
|
419
253
|
|
|
254
|
+
@cli.command("init-tap-schema", help="Initialize a standard TAP_SCHEMA database")
|
|
255
|
+
@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
|
|
256
|
+
@click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database")
|
|
257
|
+
@click.option(
|
|
258
|
+
"--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
|
|
259
|
+
)
|
|
260
|
+
@click.pass_context
|
|
261
|
+
def init_tap_schema(
|
|
262
|
+
ctx: click.Context, engine_url: str, tap_schema_name: str, tap_tables_postfix: str
|
|
263
|
+
) -> None:
|
|
264
|
+
"""Initialize a standard TAP_SCHEMA database.
|
|
265
|
+
|
|
266
|
+
Parameters
|
|
267
|
+
----------
|
|
268
|
+
engine_url
|
|
269
|
+
SQLAlchemy Engine URL.
|
|
270
|
+
tap_schema_name
|
|
271
|
+
Name of the TAP_SCHEMA schema in the database.
|
|
272
|
+
tap_tables_postfix
|
|
273
|
+
Postfix which is applied to standard TAP_SCHEMA table names.
|
|
274
|
+
"""
|
|
275
|
+
url = make_url(engine_url)
|
|
276
|
+
engine: Engine | MockConnection
|
|
277
|
+
if is_mock_url(url):
|
|
278
|
+
raise click.ClickException("Mock engine URL is not supported for this command")
|
|
279
|
+
engine = create_engine(engine_url)
|
|
280
|
+
mgr = TableManager(
|
|
281
|
+
apply_schema_to_metadata=False if engine.dialect.name == "sqlite" else True,
|
|
282
|
+
schema_name=tap_schema_name,
|
|
283
|
+
table_name_postfix=tap_tables_postfix,
|
|
284
|
+
)
|
|
285
|
+
mgr.initialize_database(engine)
|
|
286
|
+
|
|
287
|
+
|
|
420
288
|
@cli.command("validate", help="Validate one or more Felis YAML files")
|
|
421
289
|
@click.option(
|
|
422
290
|
"--check-description", is_flag=True, help="Check that all objects have a description", default=False
|
|
@@ -134,6 +134,32 @@ class DataType(StrEnum):
|
|
|
134
134
|
timestamp = auto()
|
|
135
135
|
|
|
136
136
|
|
|
137
|
+
def validate_ivoa_ucd(ivoa_ucd: str) -> str:
|
|
138
|
+
"""Validate IVOA UCD values.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
ivoa_ucd
|
|
143
|
+
IVOA UCD value to check.
|
|
144
|
+
|
|
145
|
+
Returns
|
|
146
|
+
-------
|
|
147
|
+
`str`
|
|
148
|
+
The IVOA UCD value if it is valid.
|
|
149
|
+
|
|
150
|
+
Raises
|
|
151
|
+
------
|
|
152
|
+
ValueError
|
|
153
|
+
If the IVOA UCD value is invalid.
|
|
154
|
+
"""
|
|
155
|
+
if ivoa_ucd is not None:
|
|
156
|
+
try:
|
|
157
|
+
ucd.parse_ucd(ivoa_ucd, check_controlled_vocabulary=True, has_colon=";" in ivoa_ucd)
|
|
158
|
+
except ValueError as e:
|
|
159
|
+
raise ValueError(f"Invalid IVOA UCD: {e}")
|
|
160
|
+
return ivoa_ucd
|
|
161
|
+
|
|
162
|
+
|
|
137
163
|
class Column(BaseObject):
|
|
138
164
|
"""Column model."""
|
|
139
165
|
|
|
@@ -235,12 +261,7 @@ class Column(BaseObject):
|
|
|
235
261
|
`str`
|
|
236
262
|
The IVOA UCD value if it is valid.
|
|
237
263
|
"""
|
|
238
|
-
|
|
239
|
-
try:
|
|
240
|
-
ucd.parse_ucd(ivoa_ucd, check_controlled_vocabulary=True, has_colon=";" in ivoa_ucd)
|
|
241
|
-
except ValueError as e:
|
|
242
|
-
raise ValueError(f"Invalid IVOA UCD: {e}")
|
|
243
|
-
return ivoa_ucd
|
|
264
|
+
return validate_ivoa_ucd(ivoa_ucd)
|
|
244
265
|
|
|
245
266
|
@model_validator(mode="after")
|
|
246
267
|
def check_units(self) -> Column:
|
|
@@ -551,6 +572,70 @@ _ConstraintType = Annotated[
|
|
|
551
572
|
"""Type alias for a constraint type."""
|
|
552
573
|
|
|
553
574
|
|
|
575
|
+
ColumnRef: TypeAlias = str
|
|
576
|
+
"""Type alias for a column reference."""
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
class ColumnGroup(BaseObject):
|
|
580
|
+
"""Column group model."""
|
|
581
|
+
|
|
582
|
+
columns: list[ColumnRef | Column] = Field(..., min_length=1)
|
|
583
|
+
"""Columns in the group."""
|
|
584
|
+
|
|
585
|
+
ivoa_ucd: str | None = Field(None, alias="ivoa:ucd")
|
|
586
|
+
"""IVOA UCD of the column."""
|
|
587
|
+
|
|
588
|
+
table: Table | None = None
|
|
589
|
+
"""Reference to the parent table."""
|
|
590
|
+
|
|
591
|
+
@field_validator("ivoa_ucd")
|
|
592
|
+
@classmethod
|
|
593
|
+
def check_ivoa_ucd(cls, ivoa_ucd: str) -> str:
|
|
594
|
+
"""Check that IVOA UCD values are valid.
|
|
595
|
+
|
|
596
|
+
Parameters
|
|
597
|
+
----------
|
|
598
|
+
ivoa_ucd
|
|
599
|
+
IVOA UCD value to check.
|
|
600
|
+
|
|
601
|
+
Returns
|
|
602
|
+
-------
|
|
603
|
+
`str`
|
|
604
|
+
The IVOA UCD value if it is valid.
|
|
605
|
+
"""
|
|
606
|
+
return validate_ivoa_ucd(ivoa_ucd)
|
|
607
|
+
|
|
608
|
+
@model_validator(mode="after")
|
|
609
|
+
def check_unique_columns(self) -> ColumnGroup:
|
|
610
|
+
"""Check that the columns list contains unique items.
|
|
611
|
+
|
|
612
|
+
Returns
|
|
613
|
+
-------
|
|
614
|
+
`ColumnGroup`
|
|
615
|
+
The column group being validated.
|
|
616
|
+
"""
|
|
617
|
+
column_ids = [col if isinstance(col, str) else col.id for col in self.columns]
|
|
618
|
+
if len(column_ids) != len(set(column_ids)):
|
|
619
|
+
raise ValueError("Columns in the group must be unique")
|
|
620
|
+
return self
|
|
621
|
+
|
|
622
|
+
def _dereference_columns(self) -> None:
|
|
623
|
+
"""Dereference ColumnRef to Column objects."""
|
|
624
|
+
if self.table is None:
|
|
625
|
+
raise ValueError("ColumnGroup must have a reference to its parent table")
|
|
626
|
+
|
|
627
|
+
dereferenced_columns: list[ColumnRef | Column] = []
|
|
628
|
+
for col in self.columns:
|
|
629
|
+
if isinstance(col, str):
|
|
630
|
+
# Dereference ColumnRef to Column object
|
|
631
|
+
col_obj = self.table._find_column_by_id(col)
|
|
632
|
+
dereferenced_columns.append(col_obj)
|
|
633
|
+
else:
|
|
634
|
+
dereferenced_columns.append(col)
|
|
635
|
+
|
|
636
|
+
self.columns = dereferenced_columns
|
|
637
|
+
|
|
638
|
+
|
|
554
639
|
class Table(BaseObject):
|
|
555
640
|
"""Table model."""
|
|
556
641
|
|
|
@@ -563,6 +648,9 @@ class Table(BaseObject):
|
|
|
563
648
|
indexes: list[Index] = Field(default_factory=list)
|
|
564
649
|
"""Indexes on the table."""
|
|
565
650
|
|
|
651
|
+
column_groups: list[ColumnGroup] = Field(default_factory=list, alias="columnGroups")
|
|
652
|
+
"""Column groups in the table."""
|
|
653
|
+
|
|
566
654
|
primary_key: str | list[str] | None = Field(None, alias="primaryKey")
|
|
567
655
|
"""Primary key of the table."""
|
|
568
656
|
|
|
@@ -653,6 +741,43 @@ class Table(BaseObject):
|
|
|
653
741
|
return self
|
|
654
742
|
raise ValueError(f"Table '{self.name}' is missing at least one column designated as 'tap:principal'")
|
|
655
743
|
|
|
744
|
+
def _find_column_by_id(self, id: str) -> Column:
|
|
745
|
+
"""Find a column by ID.
|
|
746
|
+
|
|
747
|
+
Parameters
|
|
748
|
+
----------
|
|
749
|
+
id
|
|
750
|
+
The ID of the column to find.
|
|
751
|
+
|
|
752
|
+
Returns
|
|
753
|
+
-------
|
|
754
|
+
`Column`
|
|
755
|
+
The column with the given ID.
|
|
756
|
+
|
|
757
|
+
Raises
|
|
758
|
+
------
|
|
759
|
+
ValueError
|
|
760
|
+
Raised if the column is not found.
|
|
761
|
+
"""
|
|
762
|
+
for column in self.columns:
|
|
763
|
+
if column.id == id:
|
|
764
|
+
return column
|
|
765
|
+
raise ValueError(f"Column '{id}' not found in table '{self.name}'")
|
|
766
|
+
|
|
767
|
+
@model_validator(mode="after")
|
|
768
|
+
def dereference_column_groups(self: Table) -> Table:
|
|
769
|
+
"""Dereference columns in column groups.
|
|
770
|
+
|
|
771
|
+
Returns
|
|
772
|
+
-------
|
|
773
|
+
`Table`
|
|
774
|
+
The table with dereferenced column groups.
|
|
775
|
+
"""
|
|
776
|
+
for group in self.column_groups:
|
|
777
|
+
group.table = self
|
|
778
|
+
group._dereference_columns()
|
|
779
|
+
return self
|
|
780
|
+
|
|
656
781
|
|
|
657
782
|
class SchemaVersion(BaseModel):
|
|
658
783
|
"""Schema version model."""
|
|
@@ -127,6 +127,8 @@ class MetaDataBuilder:
|
|
|
127
127
|
Whether to apply the schema name to the metadata object.
|
|
128
128
|
ignore_constraints
|
|
129
129
|
Whether to ignore constraints when building the metadata.
|
|
130
|
+
table_name_postfix
|
|
131
|
+
A string to append to the table names when building the metadata.
|
|
130
132
|
"""
|
|
131
133
|
|
|
132
134
|
def __init__(
|
|
@@ -134,6 +136,7 @@ class MetaDataBuilder:
|
|
|
134
136
|
schema: Schema,
|
|
135
137
|
apply_schema_to_metadata: bool = True,
|
|
136
138
|
ignore_constraints: bool = False,
|
|
139
|
+
table_name_postfix: str = "",
|
|
137
140
|
) -> None:
|
|
138
141
|
"""Initialize the metadata builder."""
|
|
139
142
|
self.schema = schema
|
|
@@ -142,6 +145,7 @@ class MetaDataBuilder:
|
|
|
142
145
|
self.metadata = MetaData(schema=schema.name if apply_schema_to_metadata else None)
|
|
143
146
|
self._objects: dict[str, Any] = {}
|
|
144
147
|
self.ignore_constraints = ignore_constraints
|
|
148
|
+
self.table_name_postfix = table_name_postfix
|
|
145
149
|
|
|
146
150
|
def build(self) -> MetaData:
|
|
147
151
|
"""Build the SQLAlchemy tables and constraints from the schema.
|
|
@@ -225,7 +229,7 @@ class MetaDataBuilder:
|
|
|
225
229
|
description = table_obj.description
|
|
226
230
|
columns = [self.build_column(column) for column in table_obj.columns]
|
|
227
231
|
table = Table(
|
|
228
|
-
name,
|
|
232
|
+
name + self.table_name_postfix,
|
|
229
233
|
self.metadata,
|
|
230
234
|
*columns,
|
|
231
235
|
comment=description,
|
|
@@ -91,9 +91,15 @@ class TableManager:
|
|
|
91
91
|
self.table_name_postfix = table_name_postfix
|
|
92
92
|
self.apply_schema_to_metadata = apply_schema_to_metadata
|
|
93
93
|
self.schema_name = schema_name or TableManager._SCHEMA_NAME_STD
|
|
94
|
+
self.table_name_postfix = table_name_postfix
|
|
94
95
|
|
|
95
96
|
if is_valid_engine(engine):
|
|
96
97
|
assert isinstance(engine, Engine)
|
|
98
|
+
if table_name_postfix != "":
|
|
99
|
+
logger.warning(
|
|
100
|
+
"Table name postfix '%s' will be ignored when reflecting TAP_SCHEMA database",
|
|
101
|
+
table_name_postfix,
|
|
102
|
+
)
|
|
97
103
|
logger.debug(
|
|
98
104
|
"Reflecting TAP_SCHEMA database from existing database at %s",
|
|
99
105
|
engine.url._replace(password="***"),
|
|
@@ -131,7 +137,9 @@ class TableManager:
|
|
|
131
137
|
self.schema_name = self.schema.name
|
|
132
138
|
|
|
133
139
|
self._metadata = MetaDataBuilder(
|
|
134
|
-
self.schema,
|
|
140
|
+
self.schema,
|
|
141
|
+
apply_schema_to_metadata=self.apply_schema_to_metadata,
|
|
142
|
+
table_name_postfix=self.table_name_postfix,
|
|
135
143
|
).build()
|
|
136
144
|
|
|
137
145
|
logger.debug("Loaded TAP_SCHEMA '%s' from YAML resource", self.schema_name)
|
|
@@ -353,7 +361,7 @@ class TableManager:
|
|
|
353
361
|
engine
|
|
354
362
|
The SQLAlchemy engine to use to create the tables.
|
|
355
363
|
"""
|
|
356
|
-
logger.info("Creating TAP_SCHEMA database '%s'", self.
|
|
364
|
+
logger.info("Creating TAP_SCHEMA database '%s'", self.schema_name)
|
|
357
365
|
self._create_schema(engine)
|
|
358
366
|
self.metadata.create_all(engine)
|
|
359
367
|
|
|
@@ -424,7 +432,7 @@ class DataLoader:
|
|
|
424
432
|
# Execute the inserts if not in dry run mode.
|
|
425
433
|
self._execute_inserts()
|
|
426
434
|
else:
|
|
427
|
-
logger.info("Dry run
|
|
435
|
+
logger.info("Dry run - not loading data into database")
|
|
428
436
|
|
|
429
437
|
def _insert_schemas(self) -> None:
|
|
430
438
|
"""Insert the schema data into the schemas table."""
|
|
@@ -565,7 +573,7 @@ class DataLoader:
|
|
|
565
573
|
def _print_sql(self) -> None:
|
|
566
574
|
"""Print the generated inserts to stdout."""
|
|
567
575
|
for insert_str in self._compiled_inserts():
|
|
568
|
-
print(insert_str)
|
|
576
|
+
print(insert_str + ";")
|
|
569
577
|
|
|
570
578
|
def _write_sql_to_file(self) -> None:
|
|
571
579
|
"""Write the generated insert statements to a file."""
|
|
@@ -573,7 +581,7 @@ class DataLoader:
|
|
|
573
581
|
raise ValueError("No output path specified")
|
|
574
582
|
with open(self.output_path, "w") as outfile:
|
|
575
583
|
for insert_str in self._compiled_inserts():
|
|
576
|
-
outfile.write(insert_str + "\n")
|
|
584
|
+
outfile.write(insert_str + ";" + "\n")
|
|
577
585
|
|
|
578
586
|
def _insert(self, table_name: str, record: list[Any] | dict[str, Any]) -> None:
|
|
579
587
|
"""Generate an insert statement for a record.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.2
|
|
2
2
|
Name: lsst-felis
|
|
3
|
-
Version: 28.2025.
|
|
3
|
+
Version: 28.2025.600
|
|
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+)
|
|
@@ -9,7 +9,6 @@ python/felis/datamodel.py
|
|
|
9
9
|
python/felis/diff.py
|
|
10
10
|
python/felis/metadata.py
|
|
11
11
|
python/felis/py.typed
|
|
12
|
-
python/felis/tap.py
|
|
13
12
|
python/felis/tap_schema.py
|
|
14
13
|
python/felis/types.py
|
|
15
14
|
python/felis/version.py
|
|
@@ -35,6 +34,5 @@ tests/test_db.py
|
|
|
35
34
|
tests/test_diff.py
|
|
36
35
|
tests/test_metadata.py
|
|
37
36
|
tests/test_postgres.py
|
|
38
|
-
tests/test_tap.py
|
|
39
37
|
tests/test_tap_schema.py
|
|
40
38
|
tests/test_tap_schema_postgres.py
|
|
@@ -30,7 +30,6 @@ from sqlalchemy import create_engine
|
|
|
30
30
|
import felis.tap_schema as tap_schema
|
|
31
31
|
from felis.cli import cli
|
|
32
32
|
from felis.datamodel import Schema
|
|
33
|
-
from felis.db.dialects import get_supported_dialects
|
|
34
33
|
from felis.metadata import MetaDataBuilder
|
|
35
34
|
|
|
36
35
|
TESTDIR = os.path.abspath(os.path.dirname(__file__))
|
|
@@ -91,38 +90,6 @@ class CliTestCase(unittest.TestCase):
|
|
|
91
90
|
)
|
|
92
91
|
self.assertEqual(result.exit_code, 0)
|
|
93
92
|
|
|
94
|
-
def test_init_tap(self) -> None:
|
|
95
|
-
"""Test for ``init-tap`` command."""
|
|
96
|
-
url = f"sqlite:///{self.tmpdir}/tap.sqlite3"
|
|
97
|
-
runner = CliRunner()
|
|
98
|
-
result = runner.invoke(cli, ["init-tap", url], catch_exceptions=False)
|
|
99
|
-
self.assertEqual(result.exit_code, 0)
|
|
100
|
-
|
|
101
|
-
def test_load_tap(self) -> None:
|
|
102
|
-
"""Test for ``load-tap`` command."""
|
|
103
|
-
# Cannot use the same url for both init-tap and load-tap in the same
|
|
104
|
-
# process.
|
|
105
|
-
url = f"sqlite:///{self.tmpdir}/tap.sqlite3"
|
|
106
|
-
|
|
107
|
-
# Need to run init-tap first.
|
|
108
|
-
runner = CliRunner()
|
|
109
|
-
result = runner.invoke(cli, ["init-tap", url])
|
|
110
|
-
self.assertEqual(result.exit_code, 0)
|
|
111
|
-
|
|
112
|
-
result = runner.invoke(cli, ["load-tap", f"--engine-url={url}", TEST_YAML], catch_exceptions=False)
|
|
113
|
-
self.assertEqual(result.exit_code, 0)
|
|
114
|
-
|
|
115
|
-
def test_load_tap_mock(self) -> None:
|
|
116
|
-
"""Test ``load-tap --dry-run`` command on supported dialects."""
|
|
117
|
-
urls = [f"{dialect_name}://" for dialect_name in get_supported_dialects().keys()]
|
|
118
|
-
|
|
119
|
-
for url in urls:
|
|
120
|
-
runner = CliRunner()
|
|
121
|
-
result = runner.invoke(
|
|
122
|
-
cli, ["load-tap", f"--engine-url={url}", "--dry-run", TEST_YAML], catch_exceptions=False
|
|
123
|
-
)
|
|
124
|
-
self.assertEqual(result.exit_code, 0)
|
|
125
|
-
|
|
126
93
|
def test_validate_default(self) -> None:
|
|
127
94
|
"""Test validate command."""
|
|
128
95
|
runner = CliRunner()
|
|
@@ -172,9 +139,9 @@ class CliTestCase(unittest.TestCase):
|
|
|
172
139
|
self.assertTrue(result.exit_code != 0)
|
|
173
140
|
|
|
174
141
|
def test_load_tap_schema(self) -> None:
|
|
175
|
-
"""Test
|
|
142
|
+
"""Test load-tap-schema command."""
|
|
176
143
|
# Create the TAP_SCHEMA database.
|
|
177
|
-
url = f"sqlite:///{self.tmpdir}/
|
|
144
|
+
url = f"sqlite:///{self.tmpdir}/load_tap_schema.sqlite3"
|
|
178
145
|
runner = CliRunner()
|
|
179
146
|
tap_schema_path = tap_schema.TableManager.get_tap_schema_std_path()
|
|
180
147
|
result = runner.invoke(
|
|
@@ -191,6 +158,21 @@ class CliTestCase(unittest.TestCase):
|
|
|
191
158
|
)
|
|
192
159
|
self.assertEqual(result.exit_code, 0)
|
|
193
160
|
|
|
161
|
+
def test_init_tap_schema(self) -> None:
|
|
162
|
+
"""Test init-tap-schema command."""
|
|
163
|
+
url = f"sqlite:///{self.tmpdir}/init_tap_schema.sqlite3"
|
|
164
|
+
runner = CliRunner()
|
|
165
|
+
result = runner.invoke(cli, ["init-tap-schema", f"--engine-url={url}"], catch_exceptions=False)
|
|
166
|
+
self.assertEqual(result.exit_code, 0)
|
|
167
|
+
|
|
168
|
+
def test_init_tap_schema_mock(self) -> None:
|
|
169
|
+
"""Test init-tap-schema command with a mock URL, which should throw
|
|
170
|
+
an error, as this is not supported.
|
|
171
|
+
"""
|
|
172
|
+
runner = CliRunner()
|
|
173
|
+
result = runner.invoke(cli, ["init-tap-schema", "sqlite://"], catch_exceptions=False)
|
|
174
|
+
self.assertNotEqual(result.exit_code, 0)
|
|
175
|
+
|
|
194
176
|
def test_diff(self) -> None:
|
|
195
177
|
"""Test for ``diff`` command."""
|
|
196
178
|
test_diff1 = os.path.join(TESTDIR, "data", "test_diff1.yaml")
|