lsst-felis 29.2025.2400__tar.gz → 29.2025.2500__tar.gz

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

Potentially problematic release.


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

Files changed (44) hide show
  1. {lsst_felis-29.2025.2400/python/lsst_felis.egg-info → lsst_felis-29.2025.2500}/PKG-INFO +1 -1
  2. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/pyproject.toml +1 -1
  3. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/cli.py +27 -5
  4. lsst_felis-29.2025.2500/python/felis/config/tap_schema/columns.csv +33 -0
  5. lsst_felis-29.2025.2500/python/felis/config/tap_schema/key_columns.csv +8 -0
  6. lsst_felis-29.2025.2500/python/felis/config/tap_schema/keys.csv +8 -0
  7. lsst_felis-29.2025.2500/python/felis/config/tap_schema/schemas.csv +2 -0
  8. lsst_felis-29.2025.2500/python/felis/config/tap_schema/tables.csv +6 -0
  9. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/datamodel.py +55 -4
  10. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/metadata.py +5 -0
  11. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/tap_schema.py +49 -2
  12. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500/python/lsst_felis.egg-info}/PKG-INFO +1 -1
  13. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/lsst_felis.egg-info/SOURCES.txt +6 -1
  14. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/tests/test_cli.py +42 -0
  15. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/tests/test_datamodel.py +4 -3
  16. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/tests/test_metadata.py +17 -0
  17. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/COPYRIGHT +0 -0
  18. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/LICENSE +0 -0
  19. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/README.rst +0 -0
  20. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/__init__.py +0 -0
  21. {lsst_felis-29.2025.2400/python/felis/schemas → lsst_felis-29.2025.2500/python/felis/config/tap_schema}/tap_schema_std.yaml +0 -0
  22. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/db/__init__.py +0 -0
  23. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/db/dialects.py +0 -0
  24. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/db/schema.py +0 -0
  25. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/db/sqltypes.py +0 -0
  26. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/db/utils.py +0 -0
  27. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/db/variants.py +0 -0
  28. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/diff.py +0 -0
  29. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/py.typed +0 -0
  30. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/tests/__init__.py +0 -0
  31. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/tests/postgresql.py +0 -0
  32. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/tests/run_cli.py +0 -0
  33. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/felis/types.py +0 -0
  34. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/lsst_felis.egg-info/dependency_links.txt +0 -0
  35. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/lsst_felis.egg-info/entry_points.txt +0 -0
  36. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/lsst_felis.egg-info/requires.txt +0 -0
  37. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/lsst_felis.egg-info/top_level.txt +0 -0
  38. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/python/lsst_felis.egg-info/zip-safe +0 -0
  39. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/setup.cfg +0 -0
  40. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/tests/test_db.py +0 -0
  41. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/tests/test_diff.py +0 -0
  42. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/tests/test_postgres.py +0 -0
  43. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/tests/test_tap_schema.py +0 -0
  44. {lsst_felis-29.2025.2400 → lsst_felis-29.2025.2500}/tests/test_tap_schema_postgres.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lsst-felis
3
- Version: 29.2025.2400
3
+ Version: 29.2025.2500
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+)
@@ -60,7 +60,7 @@ zip-safe = true
60
60
  license-files = ["COPYRIGHT", "LICENSE"]
61
61
 
62
62
  [tool.setuptools.package-data]
63
- "felis" = ["py.typed", "schemas/*.yaml"]
63
+ "felis" = ["py.typed", "config/tap_schema/*.yaml", "config/tap_schema/*.csv"]
64
64
 
65
65
  [tool.setuptools.dynamic]
66
66
  version = { attr = "lsst_versions.get_lsst_version" }
@@ -38,7 +38,7 @@ 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_schema import DataLoader, TableManager
41
+ from .tap_schema import DataLoader, MetadataInserter, TableManager
42
42
 
43
43
  __all__ = ["cli"]
44
44
 
@@ -284,14 +284,20 @@ def load_tap_schema(
284
284
 
285
285
 
286
286
  @cli.command("init-tap-schema", help="Initialize a standard TAP_SCHEMA database")
287
- @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
287
+ @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL", required=True)
288
288
  @click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database")
289
289
  @click.option(
290
290
  "--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
291
291
  )
292
+ @click.option(
293
+ "--insert-metadata/--no-insert-metadata",
294
+ is_flag=True,
295
+ help="Insert metadata describing TAP_SCHEMA itself",
296
+ default=True,
297
+ )
292
298
  @click.pass_context
293
299
  def init_tap_schema(
294
- ctx: click.Context, engine_url: str, tap_schema_name: str, tap_tables_postfix: str
300
+ ctx: click.Context, engine_url: str, tap_schema_name: str, tap_tables_postfix: str, insert_metadata: bool
295
301
  ) -> None:
296
302
  """Initialize a standard TAP_SCHEMA database.
297
303
 
@@ -303,6 +309,10 @@ def init_tap_schema(
303
309
  Name of the TAP_SCHEMA schema in the database.
304
310
  tap_tables_postfix
305
311
  Postfix which is applied to standard TAP_SCHEMA table names.
312
+ insert_metadata
313
+ Insert metadata describing TAP_SCHEMA itself.
314
+ If set to False, only the TAP_SCHEMA tables will be created, but no
315
+ metadata will be inserted.
306
316
  """
307
317
  url = make_url(engine_url)
308
318
  engine: Engine | MockConnection
@@ -315,6 +325,9 @@ def init_tap_schema(
315
325
  table_name_postfix=tap_tables_postfix,
316
326
  )
317
327
  mgr.initialize_database(engine)
328
+ if insert_metadata:
329
+ inserter = MetadataInserter(mgr, engine)
330
+ inserter.insert_metadata()
318
331
 
319
332
 
320
333
  @cli.command("validate", help="Validate one or more Felis YAML files")
@@ -465,12 +478,21 @@ def diff(
465
478
  felis dump schema.yaml schema_dump.yaml
466
479
  """,
467
480
  )
481
+ @click.option(
482
+ "--strip-ids/--no-strip-ids",
483
+ is_flag=True,
484
+ help="Strip IDs from the output schema",
485
+ default=False,
486
+ )
468
487
  @click.argument("files", nargs=2, type=click.Path())
469
488
  @click.pass_context
470
489
  def dump(
471
490
  ctx: click.Context,
491
+ strip_ids: bool,
472
492
  files: list[str],
473
493
  ) -> None:
494
+ if strip_ids:
495
+ logger.info("Stripping IDs from the output schema")
474
496
  if files[1].endswith(".json"):
475
497
  format = "json"
476
498
  elif files[1].endswith(".yaml"):
@@ -480,9 +502,9 @@ def dump(
480
502
  schema = Schema.from_uri(files[0], context={"id_generation": ctx.obj["id_generation"]})
481
503
  with open(files[1], "w") as f:
482
504
  if format == "yaml":
483
- schema.dump_yaml(f)
505
+ schema.dump_yaml(f, strip_ids=strip_ids)
484
506
  elif format == "json":
485
- schema.dump_json(f)
507
+ schema.dump_json(f, strip_ids=strip_ids)
486
508
 
487
509
 
488
510
  if __name__ == "__main__":
@@ -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
@@ -0,0 +1,8 @@
1
+ key_id,from_column,target_column
2
+ k1,schema_name,schema_name
3
+ k2,table_name,table_name
4
+ k3,from_table,table_name
5
+ k4,target_table,table_name
6
+ k5,key_id,key_id
7
+ k6,from_column,column_name
8
+ k7,target_column,column_name
@@ -0,0 +1,8 @@
1
+ key_id,from_table,target_table,utype,description
2
+ k1,tap_schema.tables,tap_schema.schemas,\N,\N
3
+ k2,tap_schema.columns,tap_schema.tables,\N,\N
4
+ k3,tap_schema.keys,tap_schema.tables,\N,\N
5
+ k4,tap_schema.keys,tap_schema.tables,\N,\N
6
+ k5,tap_schema.key_columns,tap_schema.keys,\N,\N
7
+ k6,tap_schema.key_columns,tap_schema.columns,\N,\N
8
+ k7,tap_schema.key_columns,tap_schema.columns,\N,\N
@@ -0,0 +1,2 @@
1
+ schema_name,utype,description,schema_index
2
+ tap_schema,\N,A TAP-standard-mandated schema to describe tablesets in a TAP 1.1 service,100000
@@ -0,0 +1,6 @@
1
+ schema_name,table_name,table_type,utype,description,table_index
2
+ tap_schema,tap_schema.columns,table,\N,description of columns in this tableset,102000
3
+ tap_schema,tap_schema.key_columns,table,\N,description of foreign key columns in this tableset,104000
4
+ tap_schema,tap_schema.keys,table,\N,description of foreign keys in this tableset,103000
5
+ tap_schema,tap_schema.schemas,table,\N,description of schemas in this tableset,100000
6
+ tap_schema,tap_schema.tables,table,\N,description of tables in this tableset,101000
@@ -633,6 +633,12 @@ class ForeignKeyConstraint(Constraint):
633
633
  referenced_columns: list[str] = Field(alias="referencedColumns")
634
634
  """The columns referenced by the foreign key."""
635
635
 
636
+ on_delete: Literal["CASCADE", "SET NULL", "SET DEFAULT", "RESTRICT", "NO ACTION"] | None = None
637
+ """Action to take when the referenced row is deleted."""
638
+
639
+ on_update: Literal["CASCADE", "SET NULL", "SET DEFAULT", "RESTRICT", "NO ACTION"] | None = None
640
+ """Action to take when the referenced row is updated."""
641
+
636
642
  @field_serializer("type")
637
643
  def serialize_type(self, value: str) -> str:
638
644
  """Ensure '@type' is included in serialized output.
@@ -1022,6 +1028,26 @@ class SchemaIdVisitor:
1022
1028
  T = TypeVar("T", bound=BaseObject)
1023
1029
 
1024
1030
 
1031
+ def _strip_ids(data: Any) -> Any:
1032
+ """Recursively strip '@id' fields from a dictionary or list.
1033
+
1034
+ Parameters
1035
+ ----------
1036
+ data
1037
+ The data to strip IDs from, which can be a dictionary, list, or any
1038
+ other type. Other types will be returned unchanged.
1039
+ """
1040
+ if isinstance(data, dict):
1041
+ data.pop("@id", None)
1042
+ for k, v in data.items():
1043
+ data[k] = _strip_ids(v)
1044
+ return data
1045
+ elif isinstance(data, list):
1046
+ return [_strip_ids(item) for item in data]
1047
+ else:
1048
+ return data
1049
+
1050
+
1025
1051
  class Schema(BaseObject, Generic[T]):
1026
1052
  """Database schema model.
1027
1053
 
@@ -1388,31 +1414,56 @@ class Schema(BaseObject, Generic[T]):
1388
1414
  yaml_data = yaml.safe_load(source)
1389
1415
  return Schema.model_validate(yaml_data, context=context)
1390
1416
 
1391
- def dump_yaml(self, stream: IO[str] = sys.stdout) -> None:
1417
+ def _model_dump(self, strip_ids: bool = False) -> dict[str, Any]:
1418
+ """Dump the schema as a dictionary with some default arguments
1419
+ applied.
1420
+
1421
+ Parameters
1422
+ ----------
1423
+ strip_ids
1424
+ Whether to strip the IDs from the dumped data. Defaults to `False`.
1425
+
1426
+ Returns
1427
+ -------
1428
+ `dict` [ `str`, `Any` ]
1429
+ The dumped schema data as a dictionary.
1430
+ """
1431
+ data = self.model_dump(by_alias=True, exclude_none=True, exclude_defaults=True)
1432
+ if strip_ids:
1433
+ data = _strip_ids(data)
1434
+ return data
1435
+
1436
+ def dump_yaml(self, stream: IO[str] = sys.stdout, strip_ids: bool = False) -> None:
1392
1437
  """Pretty print the schema as YAML.
1393
1438
 
1394
1439
  Parameters
1395
1440
  ----------
1396
1441
  stream
1397
1442
  The stream to write the YAML data to.
1443
+ strip_ids
1444
+ Whether to strip the IDs from the dumped data. Defaults to `False`.
1398
1445
  """
1446
+ data = self._model_dump(strip_ids=strip_ids)
1399
1447
  yaml.safe_dump(
1400
- self.model_dump(by_alias=True, exclude_none=True, exclude_defaults=True),
1448
+ data,
1401
1449
  stream,
1402
1450
  default_flow_style=False,
1403
1451
  sort_keys=False,
1404
1452
  )
1405
1453
 
1406
- def dump_json(self, stream: IO[str] = sys.stdout) -> None:
1454
+ def dump_json(self, stream: IO[str] = sys.stdout, strip_ids: bool = False) -> None:
1407
1455
  """Pretty print the schema as JSON.
1408
1456
 
1409
1457
  Parameters
1410
1458
  ----------
1411
1459
  stream
1412
1460
  The stream to write the JSON data to.
1461
+ strip_ids
1462
+ Whether to strip the IDs from the dumped data. Defaults to `False`.
1413
1463
  """
1464
+ data = self._model_dump(strip_ids=strip_ids)
1414
1465
  json.dump(
1415
- self.model_dump(by_alias=True, exclude_none=True, exclude_defaults=True),
1466
+ data,
1416
1467
  stream,
1417
1468
  indent=4,
1418
1469
  sort_keys=False,
@@ -338,12 +338,17 @@ class MetaDataBuilder:
338
338
  "deferrable": constraint_obj.deferrable or None,
339
339
  "initially": constraint_obj.initially or None,
340
340
  }
341
+
341
342
  constraint: Constraint
342
343
 
343
344
  if isinstance(constraint_obj, datamodel.ForeignKeyConstraint):
344
345
  fk_obj: datamodel.ForeignKeyConstraint = constraint_obj
345
346
  columns = [self._objects[column_id] for column_id in fk_obj.columns]
346
347
  refcolumns = [self._objects[column_id] for column_id in fk_obj.referenced_columns]
348
+ if constraint_obj.on_delete is not None:
349
+ args["ondelete"] = constraint_obj.on_delete
350
+ if constraint_obj.on_update is not None:
351
+ args["onupdate"] = constraint_obj.on_update
347
352
  constraint = ForeignKeyConstraint(columns, refcolumns, **args)
348
353
  elif isinstance(constraint_obj, datamodel.CheckConstraint):
349
354
  check_obj: datamodel.CheckConstraint = constraint_obj
@@ -21,6 +21,8 @@
21
21
  # You should have received a copy of the GNU General Public License
22
22
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
23
 
24
+ import csv
25
+ import io
24
26
  import logging
25
27
  import os
26
28
  import re
@@ -208,7 +210,7 @@ class TableManager:
208
210
  str
209
211
  The path to the standard TAP_SCHEMA schema resource.
210
212
  """
211
- return os.path.join(os.path.dirname(__file__), "schemas", "tap_schema_std.yaml")
213
+ return os.path.join(os.path.dirname(__file__), "config", "tap_schema", "tap_schema_std.yaml")
212
214
 
213
215
  @classmethod
214
216
  def get_tap_schema_std_resource(cls) -> ResourcePath:
@@ -219,7 +221,7 @@ class TableManager:
219
221
  `~lsst.resources.ResourcePath`
220
222
  The standard TAP_SCHEMA schema resource.
221
223
  """
222
- return ResourcePath("resource://felis/schemas/tap_schema_std.yaml")
224
+ return ResourcePath("resource://felis/config/tap_schema/tap_schema_std.yaml")
223
225
 
224
226
  @classmethod
225
227
  def get_table_names_std(cls) -> list[str]:
@@ -708,3 +710,48 @@ class DataLoader:
708
710
  if index.columns and len(index.columns) == 1 and index.columns[0] == column.id:
709
711
  return 1
710
712
  return 0
713
+
714
+
715
+ class MetadataInserter:
716
+ """Insert TAP_SCHEMA self-description rows into the database.
717
+
718
+ Parameters
719
+ ----------
720
+ mgr
721
+ The table manager that contains the TAP_SCHEMA tables.
722
+ engine
723
+ The engine for connecting to the TAP_SCHEMA database.
724
+ """
725
+
726
+ def __init__(self, mgr: TableManager, engine: Engine):
727
+ """Initialize the metadata inserter.
728
+
729
+ Parameters
730
+ ----------
731
+ mgr
732
+ The table manager representing the TAP_SCHEMA tables.
733
+ engine
734
+ The SQLAlchemy engine for connecting to the database.
735
+ """
736
+ self._mgr = mgr
737
+ self._engine = engine
738
+
739
+ def insert_metadata(self) -> None:
740
+ """Insert the TAP_SCHEMA metadata into the database."""
741
+ for table_name in self._mgr.get_table_names_std():
742
+ table = self._mgr[table_name]
743
+ csv_bytes = ResourcePath(f"resource://felis/config/tap_schema/{table_name}.csv").read()
744
+ text_stream = io.TextIOWrapper(io.BytesIO(csv_bytes), encoding="utf-8")
745
+ reader = csv.reader(text_stream)
746
+ headers = next(reader)
747
+ rows = [
748
+ {key: None if value == "\\N" else value for key, value in zip(headers, row)} for row in reader
749
+ ]
750
+ logger.debug(
751
+ "Inserting %d rows into table '%s' with headers: %s",
752
+ len(rows),
753
+ table_name,
754
+ headers,
755
+ )
756
+ with self._engine.begin() as conn:
757
+ conn.execute(table.insert(), rows)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lsst-felis
3
- Version: 29.2025.2400
3
+ Version: 29.2025.2500
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+)
@@ -11,13 +11,18 @@ python/felis/metadata.py
11
11
  python/felis/py.typed
12
12
  python/felis/tap_schema.py
13
13
  python/felis/types.py
14
+ python/felis/config/tap_schema/columns.csv
15
+ python/felis/config/tap_schema/key_columns.csv
16
+ python/felis/config/tap_schema/keys.csv
17
+ python/felis/config/tap_schema/schemas.csv
18
+ python/felis/config/tap_schema/tables.csv
19
+ python/felis/config/tap_schema/tap_schema_std.yaml
14
20
  python/felis/db/__init__.py
15
21
  python/felis/db/dialects.py
16
22
  python/felis/db/schema.py
17
23
  python/felis/db/sqltypes.py
18
24
  python/felis/db/utils.py
19
25
  python/felis/db/variants.py
20
- python/felis/schemas/tap_schema_std.yaml
21
26
  python/felis/tests/__init__.py
22
27
  python/felis/tests/postgresql.py
23
28
  python/felis/tests/run_cli.py
@@ -23,7 +23,9 @@ import os
23
23
  import shutil
24
24
  import tempfile
25
25
  import unittest
26
+ from typing import Any
26
27
 
28
+ import yaml
27
29
  from sqlalchemy import create_engine
28
30
 
29
31
  import felis.tap_schema as tap_schema
@@ -170,11 +172,51 @@ class CliTestCase(unittest.TestCase):
170
172
  with tempfile.NamedTemporaryFile(delete=True, suffix=".yaml") as temp_file:
171
173
  run_cli(["dump", TEST_YAML, temp_file.name], print_output=True)
172
174
 
175
+ @classmethod
176
+ def _check_strip_ids(cls, obj: Any) -> None:
177
+ """
178
+ Recursively check that a dict/list structure has no attributes with key
179
+ '@id'. Raises a ValueError if any '@id' key is found. This is used to
180
+ check the output of the `--strip-ids` option in the `dump` command.
181
+ """
182
+ if isinstance(obj, dict):
183
+ for k, v in obj.items():
184
+ if k == "@id":
185
+ raise ValueError("Found forbidden key '@id'")
186
+ cls._check_strip_ids(v)
187
+ elif isinstance(obj, list):
188
+ for item in obj:
189
+ cls._check_strip_ids(item)
190
+
191
+ def test_dump_yaml_with_strip_ids(self) -> None:
192
+ """Test for ``dump`` command with YAML output and stripped IDs."""
193
+ with tempfile.NamedTemporaryFile(delete=True, suffix=".yaml") as temp_file:
194
+ run_cli(["dump", "--strip-ids", TEST_YAML, temp_file.name], print_output=True)
195
+ dumped_data = temp_file.read().decode("utf-8")
196
+ try:
197
+ # Load the dumped YAML data to check for '@id' keys.
198
+ data = yaml.safe_load(dumped_data)
199
+ self._check_strip_ids(data)
200
+ except ValueError:
201
+ self.fail("Dumped YAML contains forbidden key '@id'")
202
+
173
203
  def test_dump_json(self) -> None:
174
204
  """Test for ``dump`` command with JSON output."""
175
205
  with tempfile.NamedTemporaryFile(delete=True, suffix=".json") as temp_file:
176
206
  run_cli(["dump", TEST_YAML, temp_file.name], print_output=True)
177
207
 
208
+ def test_dump_json_with_strip_ids(self) -> None:
209
+ """Test for ``dump`` command with JSON output."""
210
+ with tempfile.NamedTemporaryFile(delete=True, suffix=".json") as temp_file:
211
+ run_cli(["dump", "--strip-ids", TEST_YAML, temp_file.name], print_output=True)
212
+ dumped_data = temp_file.read().decode("utf-8")
213
+ try:
214
+ # Load the dumped YAML data to check for '@id' keys.
215
+ data = yaml.safe_load(dumped_data)
216
+ self._check_strip_ids(data)
217
+ except ValueError:
218
+ self.fail("Dumped YAML contains forbidden key '@id'")
219
+
178
220
  def test_dump_with_invalid_file_extension_error(self) -> None:
179
221
  """Test for ``dump`` command with JSON output."""
180
222
  run_cli(["dump", TEST_YAML, "out.bad"], expect_error=True)
@@ -646,13 +646,14 @@ class SchemaTestCase(unittest.TestCase):
646
646
  """Test loading a schema from a resource."""
647
647
  # Test loading a schema from a resource string.
648
648
  schema = Schema.from_uri(
649
- "resource://felis/schemas/tap_schema_std.yaml", context={"id_generation": True}
649
+ "resource://felis/config/tap_schema/tap_schema_std.yaml", context={"id_generation": True}
650
650
  )
651
651
  self.assertIsInstance(schema, Schema)
652
652
 
653
653
  # Test loading a schema from a ResourcePath.
654
654
  schema = Schema.from_uri(
655
- ResourcePath("resource://felis/schemas/tap_schema_std.yaml"), context={"id_generation": True}
655
+ ResourcePath("resource://felis/config/tap_schema/tap_schema_std.yaml"),
656
+ context={"id_generation": True},
656
657
  )
657
658
  self.assertIsInstance(schema, Schema)
658
659
 
@@ -662,7 +663,7 @@ class SchemaTestCase(unittest.TestCase):
662
663
 
663
664
  # Without ID generation enabled, this schema should fail validation.
664
665
  with self.assertRaises(ValidationError):
665
- Schema.from_uri("resource://felis/schemas/tap_schema_std.yaml")
666
+ Schema.from_uri("resource://felis/config/tap_schema/tap_schema_std.yaml")
666
667
 
667
668
 
668
669
  class SchemaVersionTest(unittest.TestCase):
@@ -118,6 +118,8 @@ class MetaDataTestCase(unittest.TestCase):
118
118
  md_db_fk: ForeignKeyConstraint = md_db_constraint
119
119
  self.assertEqual(md_fk.referred_table.name, md_db_fk.referred_table.name)
120
120
  self.assertEqual(md_fk.column_keys, md_db_fk.column_keys)
121
+ self.assertEqual(md_fk.ondelete, md_db_fk.ondelete)
122
+ self.assertEqual(md_fk.onupdate, md_db_fk.onupdate)
121
123
  elif isinstance(md_constraint, UniqueConstraint) and isinstance(
122
124
  md_db_constraint, UniqueConstraint
123
125
  ):
@@ -240,6 +242,21 @@ class MetaDataTestCase(unittest.TestCase):
240
242
  for table in md.tables.values():
241
243
  self.assertTrue(table.name.endswith("_test"))
242
244
 
245
+ def test_fk_actions(self) -> None:
246
+ """Test that foreign key constraints with on delete and on update
247
+ actions are created correctly.
248
+ """
249
+ schema = Schema.model_validate(self.yaml_data)
250
+ schema.name = "main"
251
+ builder = MetaDataBuilder(schema)
252
+ md = builder.build()
253
+
254
+ for table in md.tables.values():
255
+ for constraint in table.constraints:
256
+ if isinstance(constraint, ForeignKeyConstraint):
257
+ self.assertIn(constraint.ondelete, ["SET NULL"])
258
+ self.assertIn(constraint.onupdate, ["CASCADE"])
259
+
243
260
 
244
261
  if __name__ == "__main__":
245
262
  unittest.main()