lsst-felis 28.2024.4500__py3-none-any.whl → 30.0.0rc3__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.
Files changed (33) hide show
  1. felis/__init__.py +9 -1
  2. felis/cli.py +308 -209
  3. felis/config/tap_schema/columns.csv +33 -0
  4. felis/config/tap_schema/key_columns.csv +8 -0
  5. felis/config/tap_schema/keys.csv +8 -0
  6. felis/config/tap_schema/schemas.csv +2 -0
  7. felis/config/tap_schema/tables.csv +6 -0
  8. felis/config/tap_schema/tap_schema_extensions.yaml +73 -0
  9. felis/datamodel.py +599 -59
  10. felis/db/{dialects.py → _dialects.py} +69 -4
  11. felis/db/{variants.py → _variants.py} +1 -1
  12. felis/db/database_context.py +917 -0
  13. felis/diff.py +234 -0
  14. felis/metadata.py +89 -19
  15. felis/tap_schema.py +271 -166
  16. felis/tests/postgresql.py +1 -1
  17. felis/tests/run_cli.py +79 -0
  18. felis/types.py +7 -7
  19. {lsst_felis-28.2024.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/METADATA +20 -16
  20. lsst_felis-30.0.0rc3.dist-info/RECORD +31 -0
  21. {lsst_felis-28.2024.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/WHEEL +1 -1
  22. felis/db/utils.py +0 -409
  23. felis/tap.py +0 -597
  24. felis/tests/utils.py +0 -122
  25. felis/version.py +0 -2
  26. lsst_felis-28.2024.4500.dist-info/RECORD +0 -26
  27. felis/{schemas → config/tap_schema}/tap_schema_std.yaml +0 -0
  28. felis/db/{sqltypes.py → _sqltypes.py} +7 -7
  29. {lsst_felis-28.2024.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/entry_points.txt +0 -0
  30. {lsst_felis-28.2024.4500.dist-info → lsst_felis-30.0.0rc3.dist-info/licenses}/COPYRIGHT +0 -0
  31. {lsst_felis-28.2024.4500.dist-info → lsst_felis-30.0.0rc3.dist-info/licenses}/LICENSE +0 -0
  32. {lsst_felis-28.2024.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/top_level.txt +0 -0
  33. {lsst_felis-28.2024.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/zip-safe +0 -0
felis/__init__.py CHANGED
@@ -19,4 +19,12 @@
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 .version import *
22
+ from .datamodel import *
23
+
24
+ from importlib.metadata import PackageNotFoundError, version
25
+
26
+ try:
27
+ __version__ = version("lsst-felis")
28
+ except PackageNotFoundError:
29
+ # Package not installed or scons not run.
30
+ __version__ = "0.0.0"
felis/cli.py CHANGED
@@ -29,15 +29,13 @@ from typing import IO
29
29
 
30
30
  import click
31
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
32
 
35
33
  from . import __version__
36
34
  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
35
+ from .db.database_context import create_database_context
36
+ from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff
37
+ from .metadata import create_metadata
38
+ from .tap_schema import DataLoader, MetadataInserter, TableManager
41
39
 
42
40
  __all__ = ["cli"]
43
41
 
@@ -62,7 +60,10 @@ loglevel_choices = ["CRITICAL", "FATAL", "ERROR", "WARNING", "INFO", "DEBUG"]
62
60
  help="Felis log file path",
63
61
  )
64
62
  @click.option(
65
- "--id-generation", is_flag=True, help="Generate IDs for all objects that do not have them", default=False
63
+ "--id-generation/--no-id-generation",
64
+ is_flag=True,
65
+ help="Generate IDs for all objects that do not have them",
66
+ default=True,
66
67
  )
67
68
  @click.pass_context
68
69
  def cli(ctx: click.Context, log_level: str, log_file: str | None, id_generation: bool) -> None:
@@ -71,6 +72,8 @@ def cli(ctx: click.Context, log_level: str, log_file: str | None, id_generation:
71
72
  ctx.obj["id_generation"] = id_generation
72
73
  if ctx.obj["id_generation"]:
73
74
  logger.info("ID generation is enabled")
75
+ else:
76
+ logger.info("ID generation is disabled")
74
77
  if log_file:
75
78
  logging.basicConfig(filename=log_file, level=log_level)
76
79
  else:
@@ -94,6 +97,7 @@ def cli(ctx: click.Context, log_level: str, log_file: str | None, id_generation:
94
97
  "--output-file", "-o", type=click.File(mode="w"), help="Write SQL commands to a file instead of executing"
95
98
  )
96
99
  @click.option("--ignore-constraints", is_flag=True, help="Ignore constraints when creating tables")
100
+ @click.option("--skip-indexes", is_flag=True, help="Skip creating indexes when building metadata")
97
101
  @click.argument("file", type=click.File())
98
102
  @click.pass_context
99
103
  def create(
@@ -106,6 +110,7 @@ def create(
106
110
  dry_run: bool,
107
111
  output_file: IO[str] | None,
108
112
  ignore_constraints: bool,
113
+ skip_indexes: bool,
109
114
  file: IO[str],
110
115
  ) -> None:
111
116
  """Create database objects from the Felis file.
@@ -128,230 +133,141 @@ def create(
128
133
  Write SQL commands to a file instead of executing.
129
134
  ignore_constraints
130
135
  Ignore constraints when creating tables.
136
+ skip_indexes
137
+ Skip creating indexes when building metadata.
131
138
  file
132
139
  Felis file to read.
133
140
  """
134
141
  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)
142
+ metadata = create_metadata(
143
+ file,
144
+ id_generation=ctx.obj["id_generation"],
145
+ schema_name=schema_name,
146
+ ignore_constraints=ignore_constraints,
147
+ skip_indexes=skip_indexes,
148
+ engine_url=engine_url,
149
+ )
159
150
 
160
- context = DatabaseContext(metadata, engine)
151
+ with create_database_context(
152
+ engine_url,
153
+ metadata,
154
+ echo=echo,
155
+ dry_run=dry_run,
156
+ output_file=output_file,
157
+ ) as db_ctx:
158
+ if drop and initialize:
159
+ raise ValueError("Cannot drop and initialize schema at the same time")
161
160
 
162
- if drop and initialize:
163
- raise ValueError("Cannot drop and initialize schema at the same time")
161
+ if drop:
162
+ logger.debug("Dropping schema if it exists")
163
+ db_ctx.drop()
164
+ initialize = True # If schema is dropped, it needs to be recreated.
164
165
 
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.
166
+ if initialize:
167
+ logger.debug("Creating schema if not exists")
168
+ db_ctx.initialize()
169
169
 
170
- if initialize:
171
- logger.debug("Creating schema if not exists")
172
- context.initialize()
170
+ db_ctx.create_all()
173
171
 
174
- context.create_all()
175
172
  except Exception as e:
176
173
  logger.exception(e)
177
174
  raise click.ClickException(str(e))
178
175
 
179
176
 
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(
177
+ @cli.command("create-indexes", help="Create database indexes defined in the Felis file")
178
+ @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL", default="sqlite://")
179
+ @click.option("--schema-name", help="Alternate schema name to override Felis file")
180
+ @click.argument("file", type=click.File())
181
+ @click.pass_context
182
+ def create_indexes(
183
+ ctx: click.Context,
189
184
  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,
185
+ schema_name: str | None,
186
+ file: IO[str],
196
187
  ) -> None:
197
- """Initialize TAP_SCHEMA objects in the database.
188
+ """Create indexes from a Felis YAML file in a target database.
198
189
 
199
190
  Parameters
200
191
  ----------
201
192
  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.
193
+ SQLAlchemy Engine URL.
194
+ file
195
+ Felis file to read.
222
196
  """
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)
197
+ try:
198
+ metadata = create_metadata(
199
+ file, id_generation=ctx.obj["id_generation"], schema_name=schema_name, engine_url=engine_url
200
+ )
201
+ with create_database_context(engine_url, metadata) as db_ctx:
202
+ db_ctx.create_indexes()
203
+ except Exception as e:
204
+ logger.exception(e)
205
+ raise click.ClickException("Error creating indexes: " + str(e))
233
206
 
234
207
 
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")
208
+ @cli.command("drop-indexes", help="Drop database indexes defined in the Felis file")
209
+ @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL", default="sqlite://")
210
+ @click.option("--schema-name", help="Alternate schema name to override Felis file")
248
211
  @click.argument("file", type=click.File())
249
- def load_tap(
212
+ @click.pass_context
213
+ def drop_indexes(
214
+ ctx: click.Context,
250
215
  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,
216
+ schema_name: str | None,
262
217
  file: IO[str],
263
218
  ) -> 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.
219
+ """Drop indexes from a Felis YAML file in a target database.
268
220
 
269
221
  Parameters
270
222
  ----------
271
223
  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.
224
+ SQLAlchemy Engine URL.
225
+ schema-name
226
+ Alternate schema name to override Felis file.
297
227
  file
298
228
  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
229
  """
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,
230
+ try:
231
+ metadata = create_metadata(
232
+ file, id_generation=ctx.obj["id_generation"], schema_name=schema_name, engine_url=engine_url
341
233
  )
342
- tap_visitor.visit_schema(schema)
234
+ with create_database_context(engine_url, metadata) as db:
235
+ db.drop_indexes()
236
+ except Exception as e:
237
+ logger.exception(e)
238
+ raise click.ClickException("Error dropping indexes: " + str(e))
343
239
 
344
240
 
345
241
  @cli.command("load-tap-schema", help="Load metadata from a Felis file into a TAP_SCHEMA database")
346
242
  @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
243
  @click.option(
349
- "--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
244
+ "--tap-schema-name", "-n", help="Name of the TAP_SCHEMA schema in the database (default: TAP_SCHEMA)"
245
+ )
246
+ @click.option(
247
+ "--tap-tables-postfix",
248
+ "-p",
249
+ help="Postfix which is applied to standard TAP_SCHEMA table names",
250
+ default="",
251
+ )
252
+ @click.option("--tap-schema-index", "-i", type=int, help="TAP_SCHEMA index of the schema in this environment")
253
+ @click.option("--dry-run", "-D", is_flag=True, help="Execute dry run only. Does not insert any data.")
254
+ @click.option("--echo", "-e", is_flag=True, help="Print out the generated insert statements to stdout")
255
+ @click.option(
256
+ "--output-file", "-o", type=click.File(mode="w"), help="Write SQL commands to a file instead of executing"
257
+ )
258
+ @click.option(
259
+ "--force-unbounded-arraysize",
260
+ is_flag=True,
261
+ help="Use unbounded arraysize by default for all variable length string columns"
262
+ ", e.g., ``votable:arraysize: *`` (workaround for astropy bug #18099)",
263
+ ) # DM-50899: Variable-length bounded strings are not handled correctly in astropy
264
+ @click.option(
265
+ "--unique-keys",
266
+ "-u",
267
+ is_flag=True,
268
+ help="Generate unique key_id values for keys and key_columns tables by prepending the schema name",
269
+ default=False,
350
270
  )
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
271
  @click.argument("file", type=click.File())
356
272
  @click.pass_context
357
273
  def load_tap_schema(
@@ -362,7 +278,9 @@ def load_tap_schema(
362
278
  tap_schema_index: int,
363
279
  dry_run: bool,
364
280
  echo: bool,
365
- output_file: str | None,
281
+ output_file: IO[str] | None,
282
+ force_unbounded_arraysize: bool,
283
+ unique_keys: bool,
366
284
  file: IO[str],
367
285
  ) -> None:
368
286
  """Load TAP metadata from a Felis file.
@@ -389,30 +307,99 @@ def load_tap_schema(
389
307
  The TAP_SCHEMA database must already exist or the command will fail. This
390
308
  command will not initialize the TAP_SCHEMA tables.
391
309
  """
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)
310
+ # Create TableManager with automatic dialect detection
398
311
  mgr = TableManager(
399
- engine=engine,
400
- apply_schema_to_metadata=False if engine.dialect.name == "sqlite" else True,
312
+ engine_url=engine_url,
401
313
  schema_name=tap_schema_name,
402
314
  table_name_postfix=tap_tables_postfix,
403
315
  )
404
316
 
405
- schema = Schema.from_stream(file, context={"id_generation": ctx.obj["id_generation"]})
317
+ # Create DatabaseContext using TableManager's metadata
318
+ with create_database_context(
319
+ engine_url, mgr.metadata, echo=echo, dry_run=dry_run, output_file=output_file
320
+ ) as db_ctx:
321
+ schema = Schema.from_stream(
322
+ file,
323
+ context={
324
+ "id_generation": ctx.obj["id_generation"],
325
+ "force_unbounded_arraysize": force_unbounded_arraysize,
326
+ },
327
+ )
406
328
 
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()
329
+ DataLoader(
330
+ schema,
331
+ mgr,
332
+ db_context=db_ctx,
333
+ tap_schema_index=tap_schema_index,
334
+ dry_run=dry_run,
335
+ print_sql=echo,
336
+ output_file=output_file,
337
+ unique_keys=unique_keys,
338
+ ).load()
339
+
340
+
341
+ @cli.command("init-tap-schema", help="Initialize a standard TAP_SCHEMA database")
342
+ @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL", required=True)
343
+ @click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database")
344
+ @click.option(
345
+ "--extensions",
346
+ type=str,
347
+ default=None,
348
+ help=(
349
+ "Optional path to extensions YAML file (system path or resource:// URI). "
350
+ "If not provided, no extensions will be applied. "
351
+ "Example (default packaged extensions): "
352
+ "--extensions resource://felis/config/tap_schema/tap_schema_extensions.yaml"
353
+ ),
354
+ )
355
+ @click.option(
356
+ "--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
357
+ )
358
+ @click.option(
359
+ "--insert-metadata/--no-insert-metadata",
360
+ is_flag=True,
361
+ help="Insert metadata describing TAP_SCHEMA itself",
362
+ default=True,
363
+ )
364
+ @click.pass_context
365
+ def init_tap_schema(
366
+ ctx: click.Context,
367
+ engine_url: str,
368
+ tap_schema_name: str,
369
+ extensions: str | None,
370
+ tap_tables_postfix: str,
371
+ insert_metadata: bool,
372
+ ) -> None:
373
+ """Initialize a standard TAP_SCHEMA database.
374
+
375
+ Parameters
376
+ ----------
377
+ engine_url
378
+ SQLAlchemy Engine URL.
379
+ tap_schema_name
380
+ Name of the TAP_SCHEMA schema in the database.
381
+ extensions
382
+ Extensions YAML file.
383
+ tap_tables_postfix
384
+ Postfix which is applied to standard TAP_SCHEMA table names.
385
+ insert_metadata
386
+ Insert metadata describing TAP_SCHEMA itself.
387
+ If set to False, only the TAP_SCHEMA tables will be created, but no
388
+ metadata will be inserted.
389
+ """
390
+ # Create TableManager with automatic dialect detection
391
+ mgr = TableManager(
392
+ engine_url=engine_url,
393
+ schema_name=tap_schema_name,
394
+ table_name_postfix=tap_tables_postfix,
395
+ extensions_path=extensions,
396
+ )
397
+
398
+ # Create DatabaseContext using TableManager's metadata
399
+ with create_database_context(engine_url, mgr.metadata) as db_ctx:
400
+ mgr.initialize_database(db_context=db_ctx)
401
+ if insert_metadata:
402
+ MetadataInserter(mgr, db_context=db_ctx).insert_metadata()
416
403
 
417
404
 
418
405
  @cli.command("validate", help="Validate one or more Felis YAML files")
@@ -493,5 +480,117 @@ def validate(
493
480
  raise click.exceptions.Exit(rc)
494
481
 
495
482
 
483
+ @cli.command(
484
+ "diff",
485
+ help="""
486
+ Compare two schemas or a schema and a database for changes
487
+
488
+ Examples:
489
+
490
+ felis diff schema1.yaml schema2.yaml
491
+
492
+ felis diff -c alembic schema1.yaml schema2.yaml
493
+
494
+ felis diff --engine-url sqlite:///test.db schema.yaml
495
+ """,
496
+ )
497
+ @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
498
+ @click.option(
499
+ "-c",
500
+ "--comparator",
501
+ type=click.Choice(["alembic", "deepdiff"], case_sensitive=False),
502
+ help="Comparator to use for schema comparison",
503
+ default="deepdiff",
504
+ )
505
+ @click.option("-E", "--error-on-change", is_flag=True, help="Exit with error code if schemas are different")
506
+ @click.argument("files", nargs=-1, type=click.File())
507
+ @click.pass_context
508
+ def diff(
509
+ ctx: click.Context,
510
+ engine_url: str | None,
511
+ comparator: str,
512
+ error_on_change: bool,
513
+ files: Iterable[IO[str]],
514
+ ) -> None:
515
+ files_list = list(files)
516
+ schemas = [
517
+ Schema.from_stream(file, context={"id_generation": ctx.obj["id_generation"]}) for file in files_list
518
+ ]
519
+ diff: SchemaDiff
520
+ if len(schemas) == 2:
521
+ if comparator == "alembic":
522
+ # Reset file stream to beginning before re-reading
523
+ files_list[0].seek(0)
524
+ metadata = create_metadata(
525
+ files_list[0], id_generation=ctx.obj["id_generation"], engine_url=engine_url
526
+ )
527
+ with create_database_context(
528
+ engine_url if engine_url else "sqlite:///:memory:", metadata
529
+ ) as db_ctx:
530
+ db_ctx.initialize()
531
+ db_ctx.create_all()
532
+ diff = DatabaseDiff(schemas[1], db_ctx.engine)
533
+ else:
534
+ diff = FormattedSchemaDiff(schemas[0], schemas[1])
535
+ elif len(schemas) == 1 and engine_url is not None:
536
+ # Create minimal metadata for the context manager
537
+ from sqlalchemy import MetaData
538
+
539
+ metadata = MetaData()
540
+
541
+ with create_database_context(engine_url, metadata) as db_ctx:
542
+ diff = DatabaseDiff(schemas[0], db_ctx.engine)
543
+ else:
544
+ raise click.ClickException(
545
+ "Invalid arguments - provide two schemas or a single schema and a database engine URL"
546
+ )
547
+
548
+ diff.print()
549
+
550
+ if diff.has_changes and error_on_change:
551
+ raise click.ClickException("Schema was changed")
552
+
553
+
554
+ @cli.command(
555
+ "dump",
556
+ help="""
557
+ Dump a schema file to YAML or JSON format
558
+
559
+ Example:
560
+
561
+ felis dump schema.yaml schema.json
562
+
563
+ felis dump schema.yaml schema_dump.yaml
564
+ """,
565
+ )
566
+ @click.option(
567
+ "--strip-ids/--no-strip-ids",
568
+ is_flag=True,
569
+ help="Strip IDs from the output schema",
570
+ default=False,
571
+ )
572
+ @click.argument("files", nargs=2, type=click.Path())
573
+ @click.pass_context
574
+ def dump(
575
+ ctx: click.Context,
576
+ strip_ids: bool,
577
+ files: list[str],
578
+ ) -> None:
579
+ if strip_ids:
580
+ logger.info("Stripping IDs from the output schema")
581
+ if files[1].endswith(".json"):
582
+ format = "json"
583
+ elif files[1].endswith(".yaml"):
584
+ format = "yaml"
585
+ else:
586
+ raise click.ClickException("Output file must have a .json or .yaml extension")
587
+ schema = Schema.from_uri(files[0], context={"id_generation": ctx.obj["id_generation"]})
588
+ with open(files[1], "w") as f:
589
+ if format == "yaml":
590
+ schema.dump_yaml(f, strip_ids=strip_ids)
591
+ elif format == "json":
592
+ schema.dump_json(f, strip_ids=strip_ids)
593
+
594
+
496
595
  if __name__ == "__main__":
497
596
  cli()
@@ -0,0 +1,33 @@
1
+ table_name,column_name,utype,ucd,unit,description,datatype,arraysize,xtype,size,principal,indexed,std,column_index
2
+ tap_schema.columns,"""size""",\N,\N,\N,deprecated: use arraysize,int,\N,\N,\N,1,0,1,9
3
+ tap_schema.columns,arraysize,\N,\N,\N,lists the size of variable-length columns in the tableset,char,16*,\N,16,1,0,1,8
4
+ tap_schema.columns,column_index,\N,\N,\N,recommended sort order when listing columns of a table,int,\N,\N,\N,1,0,1,13
5
+ tap_schema.columns,column_name,\N,\N,\N,the column name,char,64*,\N,64,1,0,1,2
6
+ tap_schema.columns,datatype,\N,\N,\N,lists the ADQL datatype of columns in the tableset,char,64*,\N,64,1,0,1,7
7
+ tap_schema.columns,description,\N,\N,\N,describes the columns in the tableset,char,512*,\N,512,1,0,1,6
8
+ tap_schema.columns,indexed,\N,\N,\N,"an indexed column; 1 means 1, 0 means 0",int,\N,\N,\N,1,0,1,11
9
+ tap_schema.columns,principal,\N,\N,\N,"a principal column; 1 means 1, 0 means 0",int,\N,\N,\N,1,0,1,10
10
+ tap_schema.columns,std,\N,\N,\N,"a standard column; 1 means 1, 0 means 0",int,\N,\N,\N,1,0,1,12
11
+ tap_schema.columns,table_name,\N,\N,\N,the table this column belongs to,char,64*,\N,64,1,0,1,1
12
+ tap_schema.columns,ucd,\N,\N,\N,lists the UCDs of columns in the tableset,char,64*,\N,64,1,0,1,4
13
+ tap_schema.columns,unit,\N,\N,\N,lists the unit used for column values in the tableset,char,64*,\N,64,1,0,1,5
14
+ tap_schema.columns,utype,\N,\N,\N,lists the utypes of columns in the tableset,char,512*,\N,512,1,0,1,3
15
+ tap_schema.columns,xtype,\N,\N,\N,a DALI or custom extended type annotation,char,64*,\N,64,1,0,1,7
16
+ tap_schema.key_columns,from_column,\N,\N,\N,column in the from_table,char,64*,\N,64,1,0,1,2
17
+ tap_schema.key_columns,key_id,\N,\N,\N,key to join to tap_schema.keys,char,64*,\N,64,1,0,1,1
18
+ tap_schema.key_columns,target_column,\N,\N,\N,column in the target_table,char,64*,\N,64,1,0,1,3
19
+ tap_schema.keys,description,\N,\N,\N,describes keys in the tableset,char,512*,\N,512,1,0,1,5
20
+ tap_schema.keys,from_table,\N,\N,\N,the table with the foreign key,char,64*,\N,64,1,0,1,2
21
+ tap_schema.keys,key_id,\N,\N,\N,unique key to join to tap_schema.key_columns,char,64*,\N,64,1,0,1,1
22
+ tap_schema.keys,target_table,\N,\N,\N,the table with the primary key,char,64*,\N,64,1,0,1,3
23
+ tap_schema.keys,utype,\N,\N,\N,lists the utype of keys in the tableset,char,512*,\N,512,1,0,1,4
24
+ tap_schema.schemas,description,\N,\N,\N,describes schemas in the tableset,char,512*,\N,512,1,0,1,3
25
+ tap_schema.schemas,schema_index,\N,\N,\N,recommended sort order when listing schemas,int,\N,\N,\N,1,0,1,4
26
+ tap_schema.schemas,schema_name,\N,\N,\N,schema name for reference to tap_schema.schemas,char,64*,\N,64,1,0,1,1
27
+ tap_schema.schemas,utype,\N,\N,\N,lists the utypes of schemas in the tableset,char,512*,\N,512,1,0,1,2
28
+ tap_schema.tables,description,\N,\N,\N,describes tables in the tableset,char,512*,\N,512,1,0,1,5
29
+ tap_schema.tables,schema_name,\N,\N,\N,the schema this table belongs to,char,512*,\N,512,1,0,1,1
30
+ tap_schema.tables,table_index,\N,\N,\N,recommended sort order when listing tables,int,\N,\N,\N,1,0,1,6
31
+ tap_schema.tables,table_name,\N,\N,\N,the fully qualified table name,char,64*,\N,64,1,0,1,2
32
+ tap_schema.tables,table_type,\N,\N,\N,one of: table view,char,8*,\N,8,1,0,1,3
33
+ tap_schema.tables,utype,\N,\N,\N,lists the utype of tables in the tableset,char,512*,\N,512,1,0,1,4