lsst-felis 24.1.6rc1__py3-none-any.whl

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

Potentially problematic release.


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

felis/__init__.py ADDED
@@ -0,0 +1,22 @@
1
+ # This file is part of felis.
2
+ #
3
+ # Developed for the LSST Data Management System.
4
+ # This product includes software developed by the LSST Project
5
+ # (https://www.lsst.org).
6
+ # See the COPYRIGHT file at the top-level directory of this distribution
7
+ # for details of code ownership.
8
+ #
9
+ # This program is free software: you can redistribute it and/or modify
10
+ # it under the terms of the GNU General Public License as published by
11
+ # the Free Software Foundation, either version 3 of the License, or
12
+ # (at your option) any later version.
13
+ #
14
+ # This program is distributed in the hope that it will be useful,
15
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
+ # GNU General Public License for more details.
18
+ #
19
+ # You should have received a copy of the GNU General Public License
20
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
21
+
22
+ from .version import *
felis/cli.py ADDED
@@ -0,0 +1,497 @@
1
+ """Click command line interface."""
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 __future__ import annotations
25
+
26
+ import logging
27
+ from collections.abc import Iterable
28
+ from typing import IO
29
+
30
+ import click
31
+ from pydantic import ValidationError
32
+ from sqlalchemy.engine import Engine, create_engine, make_url
33
+ from sqlalchemy.engine.mock import MockConnection, create_mock_engine
34
+
35
+ from . import __version__
36
+ from .datamodel import Schema
37
+ from .db.utils import DatabaseContext, is_mock_url
38
+ from .metadata import MetaDataBuilder
39
+ from .tap import Tap11Base, TapLoadingVisitor, init_tables
40
+ from .tap_schema import DataLoader, TableManager
41
+
42
+ __all__ = ["cli"]
43
+
44
+ logger = logging.getLogger("felis")
45
+
46
+ loglevel_choices = ["CRITICAL", "FATAL", "ERROR", "WARNING", "INFO", "DEBUG"]
47
+
48
+
49
+ @click.group()
50
+ @click.version_option(__version__)
51
+ @click.option(
52
+ "--log-level",
53
+ type=click.Choice(loglevel_choices),
54
+ envvar="FELIS_LOGLEVEL",
55
+ help="Felis log level",
56
+ default=logging.getLevelName(logging.INFO),
57
+ )
58
+ @click.option(
59
+ "--log-file",
60
+ type=click.Path(),
61
+ envvar="FELIS_LOGFILE",
62
+ help="Felis log file path",
63
+ )
64
+ @click.option(
65
+ "--id-generation", is_flag=True, help="Generate IDs for all objects that do not have them", default=False
66
+ )
67
+ @click.pass_context
68
+ def cli(ctx: click.Context, log_level: str, log_file: str | None, id_generation: bool) -> None:
69
+ """Felis command line tools"""
70
+ ctx.ensure_object(dict)
71
+ ctx.obj["id_generation"] = id_generation
72
+ if ctx.obj["id_generation"]:
73
+ logger.info("ID generation is enabled")
74
+ if log_file:
75
+ logging.basicConfig(filename=log_file, level=log_level)
76
+ else:
77
+ logging.basicConfig(level=log_level)
78
+
79
+
80
+ @cli.command("create", help="Create database objects from the Felis file")
81
+ @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL", default="sqlite://")
82
+ @click.option("--schema-name", help="Alternate schema name to override Felis file")
83
+ @click.option(
84
+ "--initialize",
85
+ is_flag=True,
86
+ help="Create the schema in the database if it does not exist (error if already exists)",
87
+ )
88
+ @click.option(
89
+ "--drop", is_flag=True, help="Drop schema if it already exists in the database (implies --initialize)"
90
+ )
91
+ @click.option("--echo", is_flag=True, help="Echo database commands as they are executed")
92
+ @click.option("--dry-run", is_flag=True, help="Dry run only to print out commands instead of executing")
93
+ @click.option(
94
+ "--output-file", "-o", type=click.File(mode="w"), help="Write SQL commands to a file instead of executing"
95
+ )
96
+ @click.option("--ignore-constraints", is_flag=True, help="Ignore constraints when creating tables")
97
+ @click.argument("file", type=click.File())
98
+ @click.pass_context
99
+ def create(
100
+ ctx: click.Context,
101
+ engine_url: str,
102
+ schema_name: str | None,
103
+ initialize: bool,
104
+ drop: bool,
105
+ echo: bool,
106
+ dry_run: bool,
107
+ output_file: IO[str] | None,
108
+ ignore_constraints: bool,
109
+ file: IO[str],
110
+ ) -> None:
111
+ """Create database objects from the Felis file.
112
+
113
+ Parameters
114
+ ----------
115
+ engine_url
116
+ SQLAlchemy Engine URL.
117
+ schema_name
118
+ Alternate schema name to override Felis file.
119
+ initialize
120
+ Create the schema in the database if it does not exist.
121
+ drop
122
+ Drop schema if it already exists in the database.
123
+ echo
124
+ Echo database commands as they are executed.
125
+ dry_run
126
+ Dry run only to print out commands instead of executing.
127
+ output_file
128
+ Write SQL commands to a file instead of executing.
129
+ ignore_constraints
130
+ Ignore constraints when creating tables.
131
+ file
132
+ Felis file to read.
133
+ """
134
+ try:
135
+ schema = Schema.from_stream(file, context={"id_generation": ctx.obj["id_generation"]})
136
+ url = make_url(engine_url)
137
+ if schema_name:
138
+ logger.info(f"Overriding schema name with: {schema_name}")
139
+ schema.name = schema_name
140
+ elif url.drivername == "sqlite":
141
+ logger.info("Overriding schema name for sqlite with: main")
142
+ schema.name = "main"
143
+ if not url.host and not url.drivername == "sqlite":
144
+ dry_run = True
145
+ logger.info("Forcing dry run for non-sqlite engine URL with no host")
146
+
147
+ metadata = MetaDataBuilder(schema, ignore_constraints=ignore_constraints).build()
148
+ logger.debug(f"Created metadata with schema name: {metadata.schema}")
149
+
150
+ engine: Engine | MockConnection
151
+ if not dry_run and not output_file:
152
+ engine = create_engine(url, echo=echo)
153
+ else:
154
+ if dry_run:
155
+ logger.info("Dry run will be executed")
156
+ engine = DatabaseContext.create_mock_engine(url, output_file)
157
+ if output_file:
158
+ logger.info("Writing SQL output to: " + output_file.name)
159
+
160
+ context = DatabaseContext(metadata, engine)
161
+
162
+ if drop and initialize:
163
+ raise ValueError("Cannot drop and initialize schema at the same time")
164
+
165
+ if drop:
166
+ logger.debug("Dropping schema if it exists")
167
+ context.drop()
168
+ initialize = True # If schema is dropped, it needs to be recreated.
169
+
170
+ if initialize:
171
+ logger.debug("Creating schema if not exists")
172
+ context.initialize()
173
+
174
+ context.create_all()
175
+ except Exception as e:
176
+ logger.exception(e)
177
+ raise click.ClickException(str(e))
178
+
179
+
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
+ @cli.command("load-tap-schema", help="Load metadata from a Felis file into a TAP_SCHEMA database")
346
+ @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")
348
+ @click.option(
349
+ "--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
350
+ )
351
+ @click.option("--tap-schema-index", type=int, help="TAP_SCHEMA index of the schema in this environment")
352
+ @click.option("--dry-run", is_flag=True, help="Execute dry run only. Does not insert any data.")
353
+ @click.option("--echo", is_flag=True, help="Print out the generated insert statements to stdout")
354
+ @click.option("--output-file", type=click.Path(), help="Write SQL commands to a file")
355
+ @click.argument("file", type=click.File())
356
+ @click.pass_context
357
+ def load_tap_schema(
358
+ ctx: click.Context,
359
+ engine_url: str,
360
+ tap_schema_name: str,
361
+ tap_tables_postfix: str,
362
+ tap_schema_index: int,
363
+ dry_run: bool,
364
+ echo: bool,
365
+ output_file: str | None,
366
+ file: IO[str],
367
+ ) -> None:
368
+ """Load TAP metadata from a Felis file.
369
+
370
+ Parameters
371
+ ----------
372
+ engine_url
373
+ SQLAlchemy Engine URL.
374
+ tap_tables_postfix
375
+ Postfix which is applied to standard TAP_SCHEMA table names.
376
+ tap_schema_index
377
+ TAP_SCHEMA index of the schema in this environment.
378
+ dry_run
379
+ Execute dry run only. Does not insert any data.
380
+ echo
381
+ Print out the generated insert statements to stdout.
382
+ output_file
383
+ Output file for writing generated SQL.
384
+ file
385
+ Felis file to read.
386
+
387
+ Notes
388
+ -----
389
+ The TAP_SCHEMA database must already exist or the command will fail. This
390
+ command will not initialize the TAP_SCHEMA tables.
391
+ """
392
+ url = make_url(engine_url)
393
+ engine: Engine | MockConnection
394
+ if dry_run or is_mock_url(url):
395
+ engine = create_mock_engine(url, executor=None)
396
+ else:
397
+ engine = create_engine(engine_url)
398
+ mgr = TableManager(
399
+ engine=engine,
400
+ apply_schema_to_metadata=False if engine.dialect.name == "sqlite" else True,
401
+ schema_name=tap_schema_name,
402
+ table_name_postfix=tap_tables_postfix,
403
+ )
404
+
405
+ schema = Schema.from_stream(file, context={"id_generation": ctx.obj["id_generation"]})
406
+
407
+ DataLoader(
408
+ schema,
409
+ mgr,
410
+ engine,
411
+ tap_schema_index=tap_schema_index,
412
+ dry_run=dry_run,
413
+ print_sql=echo,
414
+ output_path=output_file,
415
+ ).load()
416
+
417
+
418
+ @cli.command("validate", help="Validate one or more Felis YAML files")
419
+ @click.option(
420
+ "--check-description", is_flag=True, help="Check that all objects have a description", default=False
421
+ )
422
+ @click.option(
423
+ "--check-redundant-datatypes", is_flag=True, help="Check for redundant datatype overrides", default=False
424
+ )
425
+ @click.option(
426
+ "--check-tap-table-indexes",
427
+ is_flag=True,
428
+ help="Check that every table has a unique TAP table index",
429
+ default=False,
430
+ )
431
+ @click.option(
432
+ "--check-tap-principal",
433
+ is_flag=True,
434
+ help="Check that at least one column per table is flagged as TAP principal",
435
+ default=False,
436
+ )
437
+ @click.argument("files", nargs=-1, type=click.File())
438
+ @click.pass_context
439
+ def validate(
440
+ ctx: click.Context,
441
+ check_description: bool,
442
+ check_redundant_datatypes: bool,
443
+ check_tap_table_indexes: bool,
444
+ check_tap_principal: bool,
445
+ files: Iterable[IO[str]],
446
+ ) -> None:
447
+ """Validate one or more felis YAML files.
448
+
449
+ Parameters
450
+ ----------
451
+ check_description
452
+ Check that all objects have a valid description.
453
+ check_redundant_datatypes
454
+ Check for redundant type overrides.
455
+ check_tap_table_indexes
456
+ Check that every table has a unique TAP table index.
457
+ check_tap_principal
458
+ Check that at least one column per table is flagged as TAP principal.
459
+ files
460
+ The Felis YAML files to validate.
461
+
462
+ Raises
463
+ ------
464
+ click.exceptions.Exit
465
+ Raised if any validation errors are found. The ``ValidationError``
466
+ which is thrown when a schema fails to validate will be logged as an
467
+ error message.
468
+
469
+ Notes
470
+ -----
471
+ All of the ``check`` flags are turned off by default and represent
472
+ optional validations controlled by the Pydantic context.
473
+ """
474
+ rc = 0
475
+ for file in files:
476
+ file_name = getattr(file, "name", None)
477
+ logger.info(f"Validating {file_name}")
478
+ try:
479
+ Schema.from_stream(
480
+ file,
481
+ context={
482
+ "check_description": check_description,
483
+ "check_redundant_datatypes": check_redundant_datatypes,
484
+ "check_tap_table_indexes": check_tap_table_indexes,
485
+ "check_tap_principal": check_tap_principal,
486
+ "id_generation": ctx.obj["id_generation"],
487
+ },
488
+ )
489
+ except ValidationError as e:
490
+ logger.error(e)
491
+ rc = 1
492
+ if rc:
493
+ raise click.exceptions.Exit(rc)
494
+
495
+
496
+ if __name__ == "__main__":
497
+ cli()