lsst-felis 28.2024.4800__tar.gz → 28.2025.500__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.2024.4800/python/lsst_felis.egg-info → lsst_felis-28.2025.500}/PKG-INFO +12 -8
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/README.rst +0 -2
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/pyproject.toml +10 -6
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/__init__.py +4 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/cli.py +95 -167
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/datamodel.py +131 -6
- lsst_felis-28.2025.500/python/felis/db/schema.py +62 -0
- lsst_felis-28.2025.500/python/felis/diff.py +229 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/metadata.py +5 -8
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/tap_schema.py +11 -5
- lsst_felis-28.2025.500/python/felis/version.py +2 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500/python/lsst_felis.egg-info}/PKG-INFO +12 -8
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/lsst_felis.egg-info/SOURCES.txt +4 -3
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/lsst_felis.egg-info/requires.txt +9 -6
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/tests/test_cli.py +74 -35
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/tests/test_datamodel.py +79 -8
- lsst_felis-28.2025.500/tests/test_db.py +79 -0
- lsst_felis-28.2025.500/tests/test_diff.py +275 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/tests/test_metadata.py +10 -1
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/tests/test_tap_schema.py +23 -52
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/tests/test_tap_schema_postgres.py +9 -5
- lsst_felis-28.2024.4800/python/felis/tap.py +0 -597
- lsst_felis-28.2024.4800/python/felis/tests/utils.py +0 -122
- lsst_felis-28.2024.4800/python/felis/version.py +0 -2
- lsst_felis-28.2024.4800/tests/test_tap.py +0 -66
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/COPYRIGHT +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/LICENSE +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/db/__init__.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/db/dialects.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/db/sqltypes.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/db/utils.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/db/variants.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/py.typed +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/schemas/tap_schema_std.yaml +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/tests/__init__.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/tests/postgresql.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/felis/types.py +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/lsst_felis.egg-info/dependency_links.txt +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/lsst_felis.egg-info/entry_points.txt +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/lsst_felis.egg-info/top_level.txt +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/python/lsst_felis.egg-info/zip-safe +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/setup.cfg +0 -0
- {lsst_felis-28.2024.4800 → lsst_felis-28.2025.500}/tests/test_postgres.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
2
|
Name: lsst-felis
|
|
3
|
-
Version: 28.
|
|
3
|
+
Version: 28.2025.500
|
|
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+)
|
|
@@ -13,18 +13,22 @@ Classifier: Operating System :: OS Independent
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.11
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
17
|
Classifier: Topic :: Scientific/Engineering :: Astronomy
|
|
17
18
|
Requires-Python: >=3.11.0
|
|
18
19
|
Description-Content-Type: text/markdown
|
|
19
20
|
License-File: COPYRIGHT
|
|
20
21
|
License-File: LICENSE
|
|
21
|
-
Requires-Dist:
|
|
22
|
-
Requires-Dist:
|
|
23
|
-
Requires-Dist: click
|
|
24
|
-
Requires-Dist:
|
|
25
|
-
Requires-Dist: pydantic<3,>=2
|
|
26
|
-
Requires-Dist: lsst-utils
|
|
22
|
+
Requires-Dist: alembic
|
|
23
|
+
Requires-Dist: astropy
|
|
24
|
+
Requires-Dist: click
|
|
25
|
+
Requires-Dist: deepdiff
|
|
27
26
|
Requires-Dist: lsst-resources
|
|
27
|
+
Requires-Dist: lsst-utils
|
|
28
|
+
Requires-Dist: numpy
|
|
29
|
+
Requires-Dist: pydantic<3,>=2
|
|
30
|
+
Requires-Dist: pyyaml
|
|
31
|
+
Requires-Dist: sqlalchemy
|
|
28
32
|
Provides-Extra: test
|
|
29
33
|
Requires-Dist: pytest>=3.2; extra == "test"
|
|
30
34
|
Provides-Extra: dev
|
|
@@ -17,17 +17,21 @@ classifiers = [
|
|
|
17
17
|
"Programming Language :: Python :: 3",
|
|
18
18
|
"Programming Language :: Python :: 3.11",
|
|
19
19
|
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
20
21
|
"Topic :: Scientific/Engineering :: Astronomy"
|
|
21
22
|
]
|
|
22
23
|
keywords = ["lsst"]
|
|
23
24
|
dependencies = [
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"click
|
|
27
|
-
"
|
|
28
|
-
"
|
|
25
|
+
"alembic",
|
|
26
|
+
"astropy",
|
|
27
|
+
"click",
|
|
28
|
+
"deepdiff",
|
|
29
|
+
"lsst-resources",
|
|
29
30
|
"lsst-utils",
|
|
30
|
-
"
|
|
31
|
+
"numpy",
|
|
32
|
+
"pydantic >=2,<3",
|
|
33
|
+
"pyyaml",
|
|
34
|
+
"sqlalchemy"
|
|
31
35
|
]
|
|
32
36
|
requires-python = ">=3.11.0"
|
|
33
37
|
dynamic = ["version"]
|
|
@@ -19,4 +19,8 @@
|
|
|
19
19
|
# You should have received a copy of the GNU General Public License
|
|
20
20
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
21
21
|
|
|
22
|
+
from .datamodel import Schema
|
|
23
|
+
from .db.schema import create_database
|
|
24
|
+
from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff
|
|
25
|
+
from .metadata import MetaDataBuilder
|
|
22
26
|
from .version import *
|
|
@@ -34,9 +34,10 @@ from sqlalchemy.engine.mock import MockConnection, create_mock_engine
|
|
|
34
34
|
|
|
35
35
|
from . import __version__
|
|
36
36
|
from .datamodel import Schema
|
|
37
|
+
from .db.schema import create_database
|
|
37
38
|
from .db.utils import DatabaseContext, is_mock_url
|
|
39
|
+
from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff
|
|
38
40
|
from .metadata import MetaDataBuilder
|
|
39
|
-
from .tap import Tap11Base, TapLoadingVisitor, init_tables
|
|
40
41
|
from .tap_schema import DataLoader, TableManager
|
|
41
42
|
|
|
42
43
|
__all__ = ["cli"]
|
|
@@ -177,174 +178,9 @@ def create(
|
|
|
177
178
|
raise click.ClickException(str(e))
|
|
178
179
|
|
|
179
180
|
|
|
180
|
-
@cli.command("init-tap", help="Initialize TAP_SCHEMA objects in the database")
|
|
181
|
-
@click.option("--tap-schema-name", help="Alternate database schema name for 'TAP_SCHEMA'")
|
|
182
|
-
@click.option("--tap-schemas-table", help="Alternate table name for 'schemas'")
|
|
183
|
-
@click.option("--tap-tables-table", help="Alternate table name for 'tables'")
|
|
184
|
-
@click.option("--tap-columns-table", help="Alternate table name for 'columns'")
|
|
185
|
-
@click.option("--tap-keys-table", help="Alternate table name for 'keys'")
|
|
186
|
-
@click.option("--tap-key-columns-table", help="Alternate table name for 'key_columns'")
|
|
187
|
-
@click.argument("engine-url")
|
|
188
|
-
def init_tap(
|
|
189
|
-
engine_url: str,
|
|
190
|
-
tap_schema_name: str,
|
|
191
|
-
tap_schemas_table: str,
|
|
192
|
-
tap_tables_table: str,
|
|
193
|
-
tap_columns_table: str,
|
|
194
|
-
tap_keys_table: str,
|
|
195
|
-
tap_key_columns_table: str,
|
|
196
|
-
) -> None:
|
|
197
|
-
"""Initialize TAP_SCHEMA objects in the database.
|
|
198
|
-
|
|
199
|
-
Parameters
|
|
200
|
-
----------
|
|
201
|
-
engine_url
|
|
202
|
-
SQLAlchemy Engine URL. The target PostgreSQL schema or MySQL database
|
|
203
|
-
must already exist and be referenced in the URL.
|
|
204
|
-
tap_schema_name
|
|
205
|
-
Alterate name for the database schema ``TAP_SCHEMA``.
|
|
206
|
-
tap_schemas_table
|
|
207
|
-
Alterate table name for ``schemas``.
|
|
208
|
-
tap_tables_table
|
|
209
|
-
Alterate table name for ``tables``.
|
|
210
|
-
tap_columns_table
|
|
211
|
-
Alterate table name for ``columns``.
|
|
212
|
-
tap_keys_table
|
|
213
|
-
Alterate table name for ``keys``.
|
|
214
|
-
tap_key_columns_table
|
|
215
|
-
Alterate table name for ``key_columns``.
|
|
216
|
-
|
|
217
|
-
Notes
|
|
218
|
-
-----
|
|
219
|
-
The supported version of TAP_SCHEMA in the SQLAlchemy metadata is 1.1. The
|
|
220
|
-
tables are created in the database schema specified by the engine URL,
|
|
221
|
-
which must be a PostgreSQL schema or MySQL database that already exists.
|
|
222
|
-
"""
|
|
223
|
-
engine = create_engine(engine_url)
|
|
224
|
-
init_tables(
|
|
225
|
-
tap_schema_name,
|
|
226
|
-
tap_schemas_table,
|
|
227
|
-
tap_tables_table,
|
|
228
|
-
tap_columns_table,
|
|
229
|
-
tap_keys_table,
|
|
230
|
-
tap_key_columns_table,
|
|
231
|
-
)
|
|
232
|
-
Tap11Base.metadata.create_all(engine)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
@cli.command("load-tap", help="Load metadata from a Felis file into a TAP_SCHEMA database")
|
|
236
|
-
@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
|
|
237
|
-
@click.option("--schema-name", help="Alternate Schema Name for Felis file")
|
|
238
|
-
@click.option("--catalog-name", help="Catalog Name for Schema")
|
|
239
|
-
@click.option("--dry-run", is_flag=True, help="Dry Run Only. Prints out the DDL that would be executed")
|
|
240
|
-
@click.option("--tap-schema-name", help="Alternate schema name for 'TAP_SCHEMA'")
|
|
241
|
-
@click.option("--tap-tables-postfix", help="Postfix for TAP_SCHEMA table names")
|
|
242
|
-
@click.option("--tap-schemas-table", help="Alternate table name for 'schemas'")
|
|
243
|
-
@click.option("--tap-tables-table", help="Alternate table name for 'tables'")
|
|
244
|
-
@click.option("--tap-columns-table", help="Alternate table name for 'columns'")
|
|
245
|
-
@click.option("--tap-keys-table", help="Alternate table name for 'keys'")
|
|
246
|
-
@click.option("--tap-key-columns-table", help="Alternate table name for 'key_columns'")
|
|
247
|
-
@click.option("--tap-schema-index", type=int, help="TAP_SCHEMA index of the schema in this environment")
|
|
248
|
-
@click.argument("file", type=click.File())
|
|
249
|
-
def load_tap(
|
|
250
|
-
engine_url: str,
|
|
251
|
-
schema_name: str,
|
|
252
|
-
catalog_name: str,
|
|
253
|
-
dry_run: bool,
|
|
254
|
-
tap_schema_name: str,
|
|
255
|
-
tap_tables_postfix: str,
|
|
256
|
-
tap_schemas_table: str,
|
|
257
|
-
tap_tables_table: str,
|
|
258
|
-
tap_columns_table: str,
|
|
259
|
-
tap_keys_table: str,
|
|
260
|
-
tap_key_columns_table: str,
|
|
261
|
-
tap_schema_index: int,
|
|
262
|
-
file: IO[str],
|
|
263
|
-
) -> None:
|
|
264
|
-
"""Load TAP metadata from a Felis file.
|
|
265
|
-
|
|
266
|
-
This command loads the associated TAP metadata from a Felis YAML file
|
|
267
|
-
into the TAP_SCHEMA tables.
|
|
268
|
-
|
|
269
|
-
Parameters
|
|
270
|
-
----------
|
|
271
|
-
engine_url
|
|
272
|
-
SQLAlchemy Engine URL to catalog.
|
|
273
|
-
schema_name
|
|
274
|
-
Alternate schema name. This overrides the schema name in the
|
|
275
|
-
``catalog`` field of the Felis file.
|
|
276
|
-
catalog_name
|
|
277
|
-
Catalog name for the schema. This possibly duplicates the
|
|
278
|
-
``tap_schema_name`` argument (DM-44870).
|
|
279
|
-
dry_run
|
|
280
|
-
Dry run only to print out commands instead of executing.
|
|
281
|
-
tap_schema_name
|
|
282
|
-
Alternate name for the schema of TAP_SCHEMA in the database.
|
|
283
|
-
tap_tables_postfix
|
|
284
|
-
Postfix for TAP table names that will be automatically appended.
|
|
285
|
-
tap_schemas_table
|
|
286
|
-
Alternate table name for ``schemas``.
|
|
287
|
-
tap_tables_table
|
|
288
|
-
Alternate table name for ``tables``.
|
|
289
|
-
tap_columns_table
|
|
290
|
-
Alternate table name for ``columns``.
|
|
291
|
-
tap_keys_table
|
|
292
|
-
Alternate table name for ``keys``.
|
|
293
|
-
tap_key_columns_table
|
|
294
|
-
Alternate table name for ``key_columns``.
|
|
295
|
-
tap_schema_index
|
|
296
|
-
TAP_SCHEMA index of the schema in this TAP environment.
|
|
297
|
-
file
|
|
298
|
-
Felis file to read.
|
|
299
|
-
|
|
300
|
-
Notes
|
|
301
|
-
-----
|
|
302
|
-
The data will be loaded into the TAP_SCHEMA from the engine URL. The
|
|
303
|
-
tables must have already been initialized or an error will occur.
|
|
304
|
-
"""
|
|
305
|
-
schema = Schema.from_stream(file)
|
|
306
|
-
|
|
307
|
-
tap_tables = init_tables(
|
|
308
|
-
tap_schema_name,
|
|
309
|
-
tap_tables_postfix,
|
|
310
|
-
tap_schemas_table,
|
|
311
|
-
tap_tables_table,
|
|
312
|
-
tap_columns_table,
|
|
313
|
-
tap_keys_table,
|
|
314
|
-
tap_key_columns_table,
|
|
315
|
-
)
|
|
316
|
-
|
|
317
|
-
if not dry_run:
|
|
318
|
-
engine = create_engine(engine_url)
|
|
319
|
-
|
|
320
|
-
if engine_url == "sqlite://" and not dry_run:
|
|
321
|
-
# In Memory SQLite - Mostly used to test
|
|
322
|
-
Tap11Base.metadata.create_all(engine)
|
|
323
|
-
|
|
324
|
-
tap_visitor = TapLoadingVisitor(
|
|
325
|
-
engine,
|
|
326
|
-
catalog_name=catalog_name,
|
|
327
|
-
schema_name=schema_name,
|
|
328
|
-
tap_tables=tap_tables,
|
|
329
|
-
tap_schema_index=tap_schema_index,
|
|
330
|
-
)
|
|
331
|
-
tap_visitor.visit_schema(schema)
|
|
332
|
-
else:
|
|
333
|
-
conn = DatabaseContext.create_mock_engine(engine_url)
|
|
334
|
-
|
|
335
|
-
tap_visitor = TapLoadingVisitor.from_mock_connection(
|
|
336
|
-
conn,
|
|
337
|
-
catalog_name=catalog_name,
|
|
338
|
-
schema_name=schema_name,
|
|
339
|
-
tap_tables=tap_tables,
|
|
340
|
-
tap_schema_index=tap_schema_index,
|
|
341
|
-
)
|
|
342
|
-
tap_visitor.visit_schema(schema)
|
|
343
|
-
|
|
344
|
-
|
|
345
181
|
@cli.command("load-tap-schema", help="Load metadata from a Felis file into a TAP_SCHEMA database")
|
|
346
182
|
@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
|
|
347
|
-
@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)")
|
|
348
184
|
@click.option(
|
|
349
185
|
"--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
|
|
350
186
|
)
|
|
@@ -415,6 +251,40 @@ def load_tap_schema(
|
|
|
415
251
|
).load()
|
|
416
252
|
|
|
417
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
|
+
|
|
418
288
|
@cli.command("validate", help="Validate one or more Felis YAML files")
|
|
419
289
|
@click.option(
|
|
420
290
|
"--check-description", is_flag=True, help="Check that all objects have a description", default=False
|
|
@@ -493,5 +363,63 @@ def validate(
|
|
|
493
363
|
raise click.exceptions.Exit(rc)
|
|
494
364
|
|
|
495
365
|
|
|
366
|
+
@cli.command(
|
|
367
|
+
"diff",
|
|
368
|
+
help="""
|
|
369
|
+
Compare two schemas or a schema and a database for changes
|
|
370
|
+
|
|
371
|
+
Examples:
|
|
372
|
+
|
|
373
|
+
felis diff schema1.yaml schema2.yaml
|
|
374
|
+
|
|
375
|
+
felis diff -c alembic schema1.yaml schema2.yaml
|
|
376
|
+
|
|
377
|
+
felis diff --engine-url sqlite:///test.db schema.yaml
|
|
378
|
+
""",
|
|
379
|
+
)
|
|
380
|
+
@click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
|
|
381
|
+
@click.option(
|
|
382
|
+
"-c",
|
|
383
|
+
"--comparator",
|
|
384
|
+
type=click.Choice(["alembic", "deepdiff"], case_sensitive=False),
|
|
385
|
+
help="Comparator to use for schema comparison",
|
|
386
|
+
default="deepdiff",
|
|
387
|
+
)
|
|
388
|
+
@click.option("-E", "--error-on-change", is_flag=True, help="Exit with error code if schemas are different")
|
|
389
|
+
@click.argument("files", nargs=-1, type=click.File())
|
|
390
|
+
@click.pass_context
|
|
391
|
+
def diff(
|
|
392
|
+
ctx: click.Context,
|
|
393
|
+
engine_url: str | None,
|
|
394
|
+
comparator: str,
|
|
395
|
+
error_on_change: bool,
|
|
396
|
+
files: Iterable[IO[str]],
|
|
397
|
+
) -> None:
|
|
398
|
+
schemas = [
|
|
399
|
+
Schema.from_stream(file, context={"id_generation": ctx.obj["id_generation"]}) for file in files
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
diff: SchemaDiff
|
|
403
|
+
if len(schemas) == 2 and engine_url is None:
|
|
404
|
+
if comparator == "alembic":
|
|
405
|
+
db_context = create_database(schemas[0])
|
|
406
|
+
assert isinstance(db_context.engine, Engine)
|
|
407
|
+
diff = DatabaseDiff(schemas[1], db_context.engine)
|
|
408
|
+
else:
|
|
409
|
+
diff = FormattedSchemaDiff(schemas[0], schemas[1])
|
|
410
|
+
elif len(schemas) == 1 and engine_url is not None:
|
|
411
|
+
engine = create_engine(engine_url)
|
|
412
|
+
diff = DatabaseDiff(schemas[0], engine)
|
|
413
|
+
else:
|
|
414
|
+
raise click.ClickException(
|
|
415
|
+
"Invalid arguments - provide two schemas or a schema and a database engine URL"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
diff.print()
|
|
419
|
+
|
|
420
|
+
if diff.has_changes and error_on_change:
|
|
421
|
+
raise click.ClickException("Schema was changed")
|
|
422
|
+
|
|
423
|
+
|
|
496
424
|
if __name__ == "__main__":
|
|
497
425
|
cli()
|
|
@@ -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."""
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Database utilities for Felis schemas."""
|
|
2
|
+
|
|
3
|
+
# This file is part of felis.
|
|
4
|
+
#
|
|
5
|
+
# Developed for the LSST Data Management System.
|
|
6
|
+
# This product includes software developed by the LSST Project
|
|
7
|
+
# (https://www.lsst.org).
|
|
8
|
+
# See the COPYRIGHT file at the top-level directory of this distribution
|
|
9
|
+
# for details of code ownership.
|
|
10
|
+
#
|
|
11
|
+
# This program is free software: you can redistribute it and/or modify
|
|
12
|
+
# it under the terms of the GNU General Public License as published by
|
|
13
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
14
|
+
# (at your option) any later version.
|
|
15
|
+
#
|
|
16
|
+
# This program is distributed in the hope that it will be useful,
|
|
17
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
18
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
19
|
+
# GNU General Public License for more details.
|
|
20
|
+
#
|
|
21
|
+
# You should have received a copy of the GNU General Public License
|
|
22
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
23
|
+
|
|
24
|
+
from sqlalchemy import Engine, create_engine
|
|
25
|
+
|
|
26
|
+
from ..datamodel import Schema
|
|
27
|
+
from ..metadata import MetaDataBuilder
|
|
28
|
+
from .utils import DatabaseContext
|
|
29
|
+
|
|
30
|
+
__all__ = ["create_database"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_database(schema: Schema, engine_or_url_str: Engine | str | None = None) -> DatabaseContext:
|
|
34
|
+
"""
|
|
35
|
+
Create a database from the specified `Schema`.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
schema
|
|
40
|
+
The schema to create.
|
|
41
|
+
engine_or_url_str
|
|
42
|
+
The SQLAlchemy engine or URL to use for database creation.
|
|
43
|
+
If None, an in-memory SQLite database will be created.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
`DatabaseContext`
|
|
48
|
+
The database context object.
|
|
49
|
+
"""
|
|
50
|
+
if engine_or_url_str is not None:
|
|
51
|
+
engine = (
|
|
52
|
+
engine_or_url_str if isinstance(engine_or_url_str, Engine) else create_engine(engine_or_url_str)
|
|
53
|
+
)
|
|
54
|
+
else:
|
|
55
|
+
engine = create_engine("sqlite:///:memory:")
|
|
56
|
+
metadata = MetaDataBuilder(
|
|
57
|
+
schema, apply_schema_to_metadata=False if engine.url.drivername == "sqlite" else True
|
|
58
|
+
).build()
|
|
59
|
+
ctx = DatabaseContext(metadata, engine)
|
|
60
|
+
ctx.initialize()
|
|
61
|
+
ctx.create_all()
|
|
62
|
+
return ctx
|