lsst-felis 28.2025.402__py3-none-any.whl → 28.2025.600__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/cli.py CHANGED
@@ -38,7 +38,6 @@ from .db.schema import create_database
38
38
  from .db.utils import DatabaseContext, is_mock_url
39
39
  from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff
40
40
  from .metadata import MetaDataBuilder
41
- from .tap import Tap11Base, TapLoadingVisitor, init_tables
42
41
  from .tap_schema import DataLoader, TableManager
43
42
 
44
43
  __all__ = ["cli"]
@@ -179,174 +178,9 @@ def create(
179
178
  raise click.ClickException(str(e))
180
179
 
181
180
 
182
- @cli.command("init-tap", help="Initialize TAP_SCHEMA objects in the database")
183
- @click.option("--tap-schema-name", help="Alternate database schema name for 'TAP_SCHEMA'")
184
- @click.option("--tap-schemas-table", help="Alternate table name for 'schemas'")
185
- @click.option("--tap-tables-table", help="Alternate table name for 'tables'")
186
- @click.option("--tap-columns-table", help="Alternate table name for 'columns'")
187
- @click.option("--tap-keys-table", help="Alternate table name for 'keys'")
188
- @click.option("--tap-key-columns-table", help="Alternate table name for 'key_columns'")
189
- @click.argument("engine-url")
190
- def init_tap(
191
- engine_url: str,
192
- tap_schema_name: str,
193
- tap_schemas_table: str,
194
- tap_tables_table: str,
195
- tap_columns_table: str,
196
- tap_keys_table: str,
197
- tap_key_columns_table: str,
198
- ) -> None:
199
- """Initialize TAP_SCHEMA objects in the database.
200
-
201
- Parameters
202
- ----------
203
- engine_url
204
- SQLAlchemy Engine URL. The target PostgreSQL schema or MySQL database
205
- must already exist and be referenced in the URL.
206
- tap_schema_name
207
- Alterate name for the database schema ``TAP_SCHEMA``.
208
- tap_schemas_table
209
- Alterate table name for ``schemas``.
210
- tap_tables_table
211
- Alterate table name for ``tables``.
212
- tap_columns_table
213
- Alterate table name for ``columns``.
214
- tap_keys_table
215
- Alterate table name for ``keys``.
216
- tap_key_columns_table
217
- Alterate table name for ``key_columns``.
218
-
219
- Notes
220
- -----
221
- The supported version of TAP_SCHEMA in the SQLAlchemy metadata is 1.1. The
222
- tables are created in the database schema specified by the engine URL,
223
- which must be a PostgreSQL schema or MySQL database that already exists.
224
- """
225
- engine = create_engine(engine_url)
226
- init_tables(
227
- tap_schema_name,
228
- tap_schemas_table,
229
- tap_tables_table,
230
- tap_columns_table,
231
- tap_keys_table,
232
- tap_key_columns_table,
233
- )
234
- Tap11Base.metadata.create_all(engine)
235
-
236
-
237
- @cli.command("load-tap", help="Load metadata from a Felis file into a TAP_SCHEMA database")
238
- @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
239
- @click.option("--schema-name", help="Alternate Schema Name for Felis file")
240
- @click.option("--catalog-name", help="Catalog Name for Schema")
241
- @click.option("--dry-run", is_flag=True, help="Dry Run Only. Prints out the DDL that would be executed")
242
- @click.option("--tap-schema-name", help="Alternate schema name for 'TAP_SCHEMA'")
243
- @click.option("--tap-tables-postfix", help="Postfix for TAP_SCHEMA table names")
244
- @click.option("--tap-schemas-table", help="Alternate table name for 'schemas'")
245
- @click.option("--tap-tables-table", help="Alternate table name for 'tables'")
246
- @click.option("--tap-columns-table", help="Alternate table name for 'columns'")
247
- @click.option("--tap-keys-table", help="Alternate table name for 'keys'")
248
- @click.option("--tap-key-columns-table", help="Alternate table name for 'key_columns'")
249
- @click.option("--tap-schema-index", type=int, help="TAP_SCHEMA index of the schema in this environment")
250
- @click.argument("file", type=click.File())
251
- def load_tap(
252
- engine_url: str,
253
- schema_name: str,
254
- catalog_name: str,
255
- dry_run: bool,
256
- tap_schema_name: str,
257
- tap_tables_postfix: str,
258
- tap_schemas_table: str,
259
- tap_tables_table: str,
260
- tap_columns_table: str,
261
- tap_keys_table: str,
262
- tap_key_columns_table: str,
263
- tap_schema_index: int,
264
- file: IO[str],
265
- ) -> None:
266
- """Load TAP metadata from a Felis file.
267
-
268
- This command loads the associated TAP metadata from a Felis YAML file
269
- into the TAP_SCHEMA tables.
270
-
271
- Parameters
272
- ----------
273
- engine_url
274
- SQLAlchemy Engine URL to catalog.
275
- schema_name
276
- Alternate schema name. This overrides the schema name in the
277
- ``catalog`` field of the Felis file.
278
- catalog_name
279
- Catalog name for the schema. This possibly duplicates the
280
- ``tap_schema_name`` argument (DM-44870).
281
- dry_run
282
- Dry run only to print out commands instead of executing.
283
- tap_schema_name
284
- Alternate name for the schema of TAP_SCHEMA in the database.
285
- tap_tables_postfix
286
- Postfix for TAP table names that will be automatically appended.
287
- tap_schemas_table
288
- Alternate table name for ``schemas``.
289
- tap_tables_table
290
- Alternate table name for ``tables``.
291
- tap_columns_table
292
- Alternate table name for ``columns``.
293
- tap_keys_table
294
- Alternate table name for ``keys``.
295
- tap_key_columns_table
296
- Alternate table name for ``key_columns``.
297
- tap_schema_index
298
- TAP_SCHEMA index of the schema in this TAP environment.
299
- file
300
- Felis file to read.
301
-
302
- Notes
303
- -----
304
- The data will be loaded into the TAP_SCHEMA from the engine URL. The
305
- tables must have already been initialized or an error will occur.
306
- """
307
- schema = Schema.from_stream(file)
308
-
309
- tap_tables = init_tables(
310
- tap_schema_name,
311
- tap_tables_postfix,
312
- tap_schemas_table,
313
- tap_tables_table,
314
- tap_columns_table,
315
- tap_keys_table,
316
- tap_key_columns_table,
317
- )
318
-
319
- if not dry_run:
320
- engine = create_engine(engine_url)
321
-
322
- if engine_url == "sqlite://" and not dry_run:
323
- # In Memory SQLite - Mostly used to test
324
- Tap11Base.metadata.create_all(engine)
325
-
326
- tap_visitor = TapLoadingVisitor(
327
- engine,
328
- catalog_name=catalog_name,
329
- schema_name=schema_name,
330
- tap_tables=tap_tables,
331
- tap_schema_index=tap_schema_index,
332
- )
333
- tap_visitor.visit_schema(schema)
334
- else:
335
- conn = DatabaseContext.create_mock_engine(engine_url)
336
-
337
- tap_visitor = TapLoadingVisitor.from_mock_connection(
338
- conn,
339
- catalog_name=catalog_name,
340
- schema_name=schema_name,
341
- tap_tables=tap_tables,
342
- tap_schema_index=tap_schema_index,
343
- )
344
- tap_visitor.visit_schema(schema)
345
-
346
-
347
181
  @cli.command("load-tap-schema", help="Load metadata from a Felis file into a TAP_SCHEMA database")
348
182
  @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
349
- @click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database")
183
+ @click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database (default: TAP_SCHEMA)")
350
184
  @click.option(
351
185
  "--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
352
186
  )
@@ -417,6 +251,40 @@ def load_tap_schema(
417
251
  ).load()
418
252
 
419
253
 
254
+ @cli.command("init-tap-schema", help="Initialize a standard TAP_SCHEMA database")
255
+ @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
256
+ @click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database")
257
+ @click.option(
258
+ "--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
259
+ )
260
+ @click.pass_context
261
+ def init_tap_schema(
262
+ ctx: click.Context, engine_url: str, tap_schema_name: str, tap_tables_postfix: str
263
+ ) -> None:
264
+ """Initialize a standard TAP_SCHEMA database.
265
+
266
+ Parameters
267
+ ----------
268
+ engine_url
269
+ SQLAlchemy Engine URL.
270
+ tap_schema_name
271
+ Name of the TAP_SCHEMA schema in the database.
272
+ tap_tables_postfix
273
+ Postfix which is applied to standard TAP_SCHEMA table names.
274
+ """
275
+ url = make_url(engine_url)
276
+ engine: Engine | MockConnection
277
+ if is_mock_url(url):
278
+ raise click.ClickException("Mock engine URL is not supported for this command")
279
+ engine = create_engine(engine_url)
280
+ mgr = TableManager(
281
+ apply_schema_to_metadata=False if engine.dialect.name == "sqlite" else True,
282
+ schema_name=tap_schema_name,
283
+ table_name_postfix=tap_tables_postfix,
284
+ )
285
+ mgr.initialize_database(engine)
286
+
287
+
420
288
  @cli.command("validate", help="Validate one or more Felis YAML files")
421
289
  @click.option(
422
290
  "--check-description", is_flag=True, help="Check that all objects have a description", default=False
felis/datamodel.py CHANGED
@@ -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
- if ivoa_ucd is not None:
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."""
felis/metadata.py CHANGED
@@ -127,6 +127,8 @@ class MetaDataBuilder:
127
127
  Whether to apply the schema name to the metadata object.
128
128
  ignore_constraints
129
129
  Whether to ignore constraints when building the metadata.
130
+ table_name_postfix
131
+ A string to append to the table names when building the metadata.
130
132
  """
131
133
 
132
134
  def __init__(
@@ -134,6 +136,7 @@ class MetaDataBuilder:
134
136
  schema: Schema,
135
137
  apply_schema_to_metadata: bool = True,
136
138
  ignore_constraints: bool = False,
139
+ table_name_postfix: str = "",
137
140
  ) -> None:
138
141
  """Initialize the metadata builder."""
139
142
  self.schema = schema
@@ -142,6 +145,7 @@ class MetaDataBuilder:
142
145
  self.metadata = MetaData(schema=schema.name if apply_schema_to_metadata else None)
143
146
  self._objects: dict[str, Any] = {}
144
147
  self.ignore_constraints = ignore_constraints
148
+ self.table_name_postfix = table_name_postfix
145
149
 
146
150
  def build(self) -> MetaData:
147
151
  """Build the SQLAlchemy tables and constraints from the schema.
@@ -225,7 +229,7 @@ class MetaDataBuilder:
225
229
  description = table_obj.description
226
230
  columns = [self.build_column(column) for column in table_obj.columns]
227
231
  table = Table(
228
- name,
232
+ name + self.table_name_postfix,
229
233
  self.metadata,
230
234
  *columns,
231
235
  comment=description,
felis/tap_schema.py CHANGED
@@ -91,9 +91,15 @@ class TableManager:
91
91
  self.table_name_postfix = table_name_postfix
92
92
  self.apply_schema_to_metadata = apply_schema_to_metadata
93
93
  self.schema_name = schema_name or TableManager._SCHEMA_NAME_STD
94
+ self.table_name_postfix = table_name_postfix
94
95
 
95
96
  if is_valid_engine(engine):
96
97
  assert isinstance(engine, Engine)
98
+ if table_name_postfix != "":
99
+ logger.warning(
100
+ "Table name postfix '%s' will be ignored when reflecting TAP_SCHEMA database",
101
+ table_name_postfix,
102
+ )
97
103
  logger.debug(
98
104
  "Reflecting TAP_SCHEMA database from existing database at %s",
99
105
  engine.url._replace(password="***"),
@@ -131,7 +137,9 @@ class TableManager:
131
137
  self.schema_name = self.schema.name
132
138
 
133
139
  self._metadata = MetaDataBuilder(
134
- self.schema, apply_schema_to_metadata=self.apply_schema_to_metadata
140
+ self.schema,
141
+ apply_schema_to_metadata=self.apply_schema_to_metadata,
142
+ table_name_postfix=self.table_name_postfix,
135
143
  ).build()
136
144
 
137
145
  logger.debug("Loaded TAP_SCHEMA '%s' from YAML resource", self.schema_name)
@@ -353,7 +361,7 @@ class TableManager:
353
361
  engine
354
362
  The SQLAlchemy engine to use to create the tables.
355
363
  """
356
- logger.info("Creating TAP_SCHEMA database '%s'", self.metadata.schema)
364
+ logger.info("Creating TAP_SCHEMA database '%s'", self.schema_name)
357
365
  self._create_schema(engine)
358
366
  self.metadata.create_all(engine)
359
367
 
@@ -424,7 +432,7 @@ class DataLoader:
424
432
  # Execute the inserts if not in dry run mode.
425
433
  self._execute_inserts()
426
434
  else:
427
- logger.info("Dry run: not loading data into database")
435
+ logger.info("Dry run - not loading data into database")
428
436
 
429
437
  def _insert_schemas(self) -> None:
430
438
  """Insert the schema data into the schemas table."""
@@ -565,7 +573,7 @@ class DataLoader:
565
573
  def _print_sql(self) -> None:
566
574
  """Print the generated inserts to stdout."""
567
575
  for insert_str in self._compiled_inserts():
568
- print(insert_str)
576
+ print(insert_str + ";")
569
577
 
570
578
  def _write_sql_to_file(self) -> None:
571
579
  """Write the generated insert statements to a file."""
@@ -573,7 +581,7 @@ class DataLoader:
573
581
  raise ValueError("No output path specified")
574
582
  with open(self.output_path, "w") as outfile:
575
583
  for insert_str in self._compiled_inserts():
576
- outfile.write(insert_str + "\n")
584
+ outfile.write(insert_str + ";" + "\n")
577
585
 
578
586
  def _insert(self, table_name: str, record: list[Any] | dict[str, Any]) -> None:
579
587
  """Generate an insert statement for a record.
felis/version.py CHANGED
@@ -1,2 +1,2 @@
1
1
  __all__ = ["__version__"]
2
- __version__ = "28.2025.402"
2
+ __version__ = "28.2025.600"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: lsst-felis
3
- Version: 28.2025.402
3
+ Version: 28.2025.600
4
4
  Summary: A vocabulary for describing catalogs and acting on those descriptions
5
5
  Author-email: Rubin Observatory Data Management <dm-admin@lists.lsst.org>
6
6
  License: GNU General Public License v3 or later (GPLv3+)
@@ -1,13 +1,12 @@
1
1
  felis/__init__.py,sha256=r1KFSnc55gziwUuYb9s2EfwrI_85aa3LpaKwk6rUvvs,1108
2
- felis/cli.py,sha256=uvrPZ7L6Jm3B69Kbejkv1qyDffIh6dxvj50sosJrg14,19220
3
- felis/datamodel.py,sha256=3iZiNlCrOjJj87cCdTdRraUyx5o-Iwzh46aQ-dIwVDQ,35942
2
+ felis/cli.py,sha256=Wf-sEUZ-B9zzn4M1huY2ruV1nkgVmpzX8f8iuFfyxZc,14469
3
+ felis/datamodel.py,sha256=NczAA4HBBC4-uxPNsrKAFX-hdlgvCT2qqEJCEqDy4yg,39265
4
4
  felis/diff.py,sha256=0N4OcBCzbL9DW_XGAeuvGsQ0zIhq8fY-Kx2QdvLv-Ds,7492
5
- felis/metadata.py,sha256=6Ct-tKIZB26bwBF3juxjpCmKzASkMr6uzhoEKQYcGAo,13322
5
+ felis/metadata.py,sha256=cYx_qizkLBqcoxWV46h4TbwTi1KVJAkuA2OuUmD-K5k,13536
6
6
  felis/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
- felis/tap.py,sha256=76o8-ijmwgSE8UxMVn9KTlKrCPjG72LWyyAXO8yeqFE,21564
8
- felis/tap_schema.py,sha256=MvRZdNYl9ObJCsfiWyAp-3EpTdVeKCKimYba-pSZNos,22392
7
+ felis/tap_schema.py,sha256=DgHH4hBf4q_F540TAR9GTKcALwUkk8iTw5pzQlmv1DA,22753
9
8
  felis/types.py,sha256=m80GSGfNHQ3-NzRuTzKOyRXLJboPxdk9kzpp1SO8XdY,5510
10
- felis/version.py,sha256=cQxdqMuS0r6APcE0FvPz5UloooZHknxy-vbWA0xA_Oo,54
9
+ felis/version.py,sha256=d9ENfpCeS2I77L3quCjEA1SqEJuEwmWi9q2wJwk_Xhw,54
11
10
  felis/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
11
  felis/db/dialects.py,sha256=n5La-shu-8fHLIyf8rrazHDyrzATmMCdELtKV_0ymxI,3517
13
12
  felis/db/schema.py,sha256=NOFXzBoBQcgpoRlgT3LoC70FKp7pCSmFEJ7rU8FIT-c,2101
@@ -17,11 +16,11 @@ felis/db/variants.py,sha256=eahthrbVeV8ZdGamWQccNmWgx6CCscGrU0vQRs5HZK8,5260
17
16
  felis/schemas/tap_schema_std.yaml,sha256=sPW-Vk72nY0PFpCvP5d8L8fWvhkif-x32sGtcfDZ8bU,7131
18
17
  felis/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
18
  felis/tests/postgresql.py,sha256=B_xk4fLual5-viGDqP20r94okuc0pbSvytRH_L0fvMs,4035
20
- lsst_felis-28.2025.402.dist-info/COPYRIGHT,sha256=vJAFLFTSF1mhy9eIuA3P6R-3yxTWKQgpig88P-1IzRw,129
21
- lsst_felis-28.2025.402.dist-info/LICENSE,sha256=jOtLnuWt7d5Hsx6XXB2QxzrSe2sWWh3NgMfFRetluQM,35147
22
- lsst_felis-28.2025.402.dist-info/METADATA,sha256=phxmqyk2-mYCObW5WreJMx6Xaq_GfQUnZamdgtDzqo0,1410
23
- lsst_felis-28.2025.402.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
24
- lsst_felis-28.2025.402.dist-info/entry_points.txt,sha256=Gk2XFujA_Gp52VBk45g5kim8TDoMDJFPctsMqiq72EM,40
25
- lsst_felis-28.2025.402.dist-info/top_level.txt,sha256=F4SvPip3iZRVyISi50CHhwTIAokAhSxjWiVcn4IVWRI,6
26
- lsst_felis-28.2025.402.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
27
- lsst_felis-28.2025.402.dist-info/RECORD,,
19
+ lsst_felis-28.2025.600.dist-info/COPYRIGHT,sha256=vJAFLFTSF1mhy9eIuA3P6R-3yxTWKQgpig88P-1IzRw,129
20
+ lsst_felis-28.2025.600.dist-info/LICENSE,sha256=jOtLnuWt7d5Hsx6XXB2QxzrSe2sWWh3NgMfFRetluQM,35147
21
+ lsst_felis-28.2025.600.dist-info/METADATA,sha256=spGx-It8JoY8mgWkQLwxngz3gD2Jr1bJi_0T1NeMAjs,1410
22
+ lsst_felis-28.2025.600.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
23
+ lsst_felis-28.2025.600.dist-info/entry_points.txt,sha256=Gk2XFujA_Gp52VBk45g5kim8TDoMDJFPctsMqiq72EM,40
24
+ lsst_felis-28.2025.600.dist-info/top_level.txt,sha256=F4SvPip3iZRVyISi50CHhwTIAokAhSxjWiVcn4IVWRI,6
25
+ lsst_felis-28.2025.600.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
26
+ lsst_felis-28.2025.600.dist-info/RECORD,,
felis/tap.py DELETED
@@ -1,597 +0,0 @@
1
- """Translate a Felis schema into a TAP_SCHEMA representation."""
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
- import re
28
- from collections.abc import Iterable, MutableMapping
29
- from typing import Any
30
-
31
- from sqlalchemy import Column, Integer, String
32
- from sqlalchemy.engine import Engine
33
- from sqlalchemy.engine.mock import MockConnection
34
- from sqlalchemy.orm import Session, declarative_base, sessionmaker
35
- from sqlalchemy.schema import MetaData
36
- from sqlalchemy.sql.expression import Insert, insert
37
-
38
- from felis import datamodel
39
-
40
- from .datamodel import Constraint, ForeignKeyConstraint, Index, Schema, Table
41
- from .types import FelisType
42
-
43
- __all__ = ["TapLoadingVisitor", "init_tables"]
44
-
45
- logger = logging.getLogger(__name__)
46
-
47
- Tap11Base: Any = declarative_base() # Any to avoid mypy mess with SA 2
48
-
49
- IDENTIFIER_LENGTH = 128
50
- SMALL_FIELD_LENGTH = 32
51
- SIMPLE_FIELD_LENGTH = 128
52
- TEXT_FIELD_LENGTH = 2048
53
- QUALIFIED_TABLE_LENGTH = 3 * IDENTIFIER_LENGTH + 2
54
-
55
- _init_table_once = False
56
-
57
-
58
- def init_tables(
59
- tap_schema_name: str | None = None,
60
- tap_tables_postfix: str | None = None,
61
- tap_schemas_table: str | None = None,
62
- tap_tables_table: str | None = None,
63
- tap_columns_table: str | None = None,
64
- tap_keys_table: str | None = None,
65
- tap_key_columns_table: str | None = None,
66
- ) -> MutableMapping[str, Any]:
67
- """Generate definitions for TAP tables.
68
-
69
- Parameters
70
- ----------
71
- tap_schema_name
72
- Name of the TAP schema.
73
- tap_tables_postfix
74
- Postfix for table names.
75
- tap_schemas_table
76
- Name of the schemas table.
77
- tap_tables_table
78
- Name of the tables table.
79
- tap_columns_table
80
- Name of the columns table.
81
- tap_keys_table
82
- Name of the keys table.
83
- tap_key_columns_table
84
- Name of the key columns table.
85
-
86
- Returns
87
- -------
88
- `dict` [ `str`, `Any`]
89
- A dictionary of table definitions.
90
- """
91
- postfix = tap_tables_postfix or ""
92
-
93
- # Dirty hack to enable this method to be called more than once, replaces
94
- # MetaData instance with a fresh copy if called more than once.
95
- # TODO: probably replace ORM stuff with core sqlalchemy functions.
96
- global _init_table_once
97
- if not _init_table_once:
98
- _init_table_once = True
99
- else:
100
- Tap11Base.metadata = MetaData()
101
-
102
- if tap_schema_name:
103
- Tap11Base.metadata.schema = tap_schema_name
104
-
105
- class Tap11Schemas(Tap11Base):
106
- __tablename__ = (tap_schemas_table or "schemas") + postfix
107
- schema_name = Column(String(IDENTIFIER_LENGTH), primary_key=True, nullable=False)
108
- utype = Column(String(SIMPLE_FIELD_LENGTH))
109
- description = Column(String(TEXT_FIELD_LENGTH))
110
- schema_index = Column(Integer)
111
-
112
- class Tap11Tables(Tap11Base):
113
- __tablename__ = (tap_tables_table or "tables") + postfix
114
- schema_name = Column(String(IDENTIFIER_LENGTH), nullable=False)
115
- table_name = Column(String(QUALIFIED_TABLE_LENGTH), nullable=False, primary_key=True)
116
- table_type = Column(String(SMALL_FIELD_LENGTH), nullable=False)
117
- utype = Column(String(SIMPLE_FIELD_LENGTH))
118
- description = Column(String(TEXT_FIELD_LENGTH))
119
- table_index = Column(Integer)
120
-
121
- class Tap11Columns(Tap11Base):
122
- __tablename__ = (tap_columns_table or "columns") + postfix
123
- table_name = Column(String(QUALIFIED_TABLE_LENGTH), nullable=False, primary_key=True)
124
- column_name = Column(String(IDENTIFIER_LENGTH), nullable=False, primary_key=True)
125
- datatype = Column(String(SIMPLE_FIELD_LENGTH), nullable=False)
126
- arraysize = Column(String(10))
127
- xtype = Column(String(SIMPLE_FIELD_LENGTH))
128
- # Size is deprecated
129
- size = Column("size", Integer(), quote=True)
130
- description = Column(String(TEXT_FIELD_LENGTH))
131
- utype = Column(String(SIMPLE_FIELD_LENGTH))
132
- unit = Column(String(SIMPLE_FIELD_LENGTH))
133
- ucd = Column(String(SIMPLE_FIELD_LENGTH))
134
- indexed = Column(Integer, nullable=False)
135
- principal = Column(Integer, nullable=False)
136
- std = Column(Integer, nullable=False)
137
- column_index = Column(Integer)
138
-
139
- class Tap11Keys(Tap11Base):
140
- __tablename__ = (tap_keys_table or "keys") + postfix
141
- key_id = Column(String(IDENTIFIER_LENGTH), nullable=False, primary_key=True)
142
- from_table = Column(String(QUALIFIED_TABLE_LENGTH), nullable=False)
143
- target_table = Column(String(QUALIFIED_TABLE_LENGTH), nullable=False)
144
- description = Column(String(TEXT_FIELD_LENGTH))
145
- utype = Column(String(SIMPLE_FIELD_LENGTH))
146
-
147
- class Tap11KeyColumns(Tap11Base):
148
- __tablename__ = (tap_key_columns_table or "key_columns") + postfix
149
- key_id = Column(String(IDENTIFIER_LENGTH), nullable=False, primary_key=True)
150
- from_column = Column(String(IDENTIFIER_LENGTH), nullable=False, primary_key=True)
151
- target_column = Column(String(IDENTIFIER_LENGTH), nullable=False, primary_key=True)
152
-
153
- return dict(
154
- schemas=Tap11Schemas,
155
- tables=Tap11Tables,
156
- columns=Tap11Columns,
157
- keys=Tap11Keys,
158
- key_columns=Tap11KeyColumns,
159
- )
160
-
161
-
162
- class TapLoadingVisitor:
163
- """Generate TAP_SCHEMA data and insert it into a database using the
164
- SQLAlchemy ORM.
165
-
166
- Parameters
167
- ----------
168
- engine
169
- SQLAlchemy engine instance.
170
- catalog_name
171
- Name of the database catalog.
172
- schema_name
173
- Name of the schema.
174
- tap_tables
175
- Mapping of TAP_SCHEMA table name to its SQLAlchemy table object.
176
- tap_schema_index
177
- The index of the schema for this TAP environment.
178
- """
179
-
180
- def __init__(
181
- self,
182
- engine: Engine | None,
183
- catalog_name: str | None = None,
184
- schema_name: str | None = None,
185
- tap_tables: MutableMapping[str, Any] | None = None,
186
- tap_schema_index: int | None = None,
187
- ) -> None:
188
- """Create a TAP loading visitor."""
189
- self.graph_index: MutableMapping[str, Any] = {}
190
- self.catalog_name = catalog_name
191
- self.schema_name = schema_name
192
- self.engine = engine
193
- self._mock_connection: MockConnection | None = None
194
- self.tables = tap_tables or init_tables()
195
- self.tap_schema_index = tap_schema_index
196
-
197
- @classmethod
198
- def from_mock_connection(
199
- cls,
200
- mock_connection: MockConnection,
201
- catalog_name: str | None = None,
202
- schema_name: str | None = None,
203
- tap_tables: MutableMapping[str, Any] | None = None,
204
- tap_schema_index: int | None = None,
205
- ) -> TapLoadingVisitor:
206
- """Create a TAP visitor from a mock connection.
207
-
208
- Parameters
209
- ----------
210
- mock_connection
211
- Mock connection object.
212
- catalog_name
213
- Name of the database catalog.
214
- schema_name
215
- Name of the database schema.
216
- tap_tables
217
- Optional mapping of table name to its SQLAlchemy table object.
218
- tap_schema_index
219
- The index of the schema for this TAP environment.
220
-
221
- Returns
222
- -------
223
- `TapLoadingVisitor`
224
- The TAP loading visitor.
225
- """
226
- visitor = cls(engine=None, catalog_name=catalog_name, schema_name=schema_name, tap_tables=tap_tables)
227
- visitor._mock_connection = mock_connection
228
- visitor.tap_schema_index = tap_schema_index
229
- return visitor
230
-
231
- def visit_schema(self, schema_obj: Schema) -> None:
232
- """Visit a schema object and insert it into the TAP_SCHEMA database.
233
-
234
- Parameters
235
- ----------
236
- schema_obj
237
- The schema object to visit.
238
- """
239
- schema = self.tables["schemas"]()
240
- # Override with default
241
- self.schema_name = self.schema_name or schema_obj.name
242
-
243
- schema.schema_name = self._schema_name()
244
- schema.description = schema_obj.description
245
- schema.utype = schema_obj.votable_utype
246
- schema.schema_index = self.tap_schema_index
247
- logger.debug(f"Set TAP_SCHEMA index: {self.tap_schema_index}")
248
-
249
- if self.engine is not None:
250
- session: Session = sessionmaker(self.engine)()
251
-
252
- session.add(schema)
253
-
254
- for table_obj in schema_obj.tables:
255
- table, columns = self.visit_table(table_obj, schema_obj)
256
- session.add(table)
257
- session.add_all(columns)
258
-
259
- keys, key_columns = self.visit_constraints(schema_obj)
260
- session.add_all(keys)
261
- session.add_all(key_columns)
262
-
263
- logger.debug("Committing TAP schema: %s", schema_obj.name)
264
- logger.debug("TAP tables: %s", len(self.tables))
265
- session.commit()
266
- else:
267
- logger.info("Dry run, not inserting into database")
268
-
269
- # Only if we are mocking (dry run)
270
- assert self._mock_connection is not None, "Mock connection must not be None"
271
- conn = self._mock_connection
272
- conn.execute(_insert(self.tables["schemas"], schema))
273
-
274
- for table_obj in schema_obj.tables:
275
- table, columns = self.visit_table(table_obj, schema_obj)
276
- conn.execute(_insert(self.tables["tables"], table))
277
- for column in columns:
278
- conn.execute(_insert(self.tables["columns"], column))
279
-
280
- keys, key_columns = self.visit_constraints(schema_obj)
281
- for key in keys:
282
- conn.execute(_insert(self.tables["keys"], key))
283
- for key_column in key_columns:
284
- conn.execute(_insert(self.tables["key_columns"], key_column))
285
-
286
- def visit_constraints(self, schema_obj: Schema) -> tuple:
287
- """Visit all constraints in a schema.
288
-
289
- Parameters
290
- ----------
291
- schema_obj
292
- The schema object to visit.
293
-
294
- Returns
295
- -------
296
- `tuple`
297
- A tuple of all TAP_SCHEMA keys and key columns that were created.
298
- """
299
- all_keys = []
300
- all_key_columns = []
301
- for table_obj in schema_obj.tables:
302
- for c in table_obj.constraints:
303
- key, key_columns = self.visit_constraint(c)
304
- if not key:
305
- continue
306
- all_keys.append(key)
307
- all_key_columns += key_columns
308
- return all_keys, all_key_columns
309
-
310
- def visit_table(self, table_obj: Table, schema_obj: Schema) -> tuple:
311
- """Visit a table object and build its TAP_SCHEMA representation.
312
-
313
- Parameters
314
- ----------
315
- table_obj
316
- The table object to visit.
317
- schema_obj
318
- The schema object which the table belongs to.
319
-
320
- Returns
321
- -------
322
- `tuple`
323
- A tuple of the SQLAlchemy ORM objects for the tables and columns.
324
- """
325
- table_id = table_obj.id
326
- table = self.tables["tables"]()
327
- table.schema_name = self._schema_name()
328
- table.table_name = self._table_name(table_obj.name)
329
- table.table_type = "table"
330
- table.utype = table_obj.votable_utype
331
- table.description = table_obj.description
332
- table.table_index = 0 if table_obj.tap_table_index is None else table_obj.tap_table_index
333
-
334
- columns = [self.visit_column(c, table_obj) for c in table_obj.columns]
335
- self.visit_primary_key(table_obj.primary_key, table_obj)
336
-
337
- for i in table_obj.indexes:
338
- self.visit_index(i, table)
339
-
340
- self.graph_index[table_id] = table
341
- return table, columns
342
-
343
- def check_column(self, column_obj: datamodel.Column) -> None:
344
- """Check consistency of VOTable attributes for a column.
345
-
346
- Parameters
347
- ----------
348
- column_obj
349
- The column object to check.
350
-
351
- Notes
352
- -----
353
- This method checks that a column with a sized datatype has either a
354
- ``votable:arraysize`` or a ``length`` attribute and issues a warning
355
- message if not. It also checks if a column with a timestamp datatype
356
- has a ``arraysize`` attribute and issues a warning if not.
357
- """
358
- _id = column_obj.id
359
- datatype_name = column_obj.datatype
360
- felis_type = FelisType.felis_type(datatype_name.value)
361
- if felis_type.is_sized:
362
- # It is expected that both arraysize and length are fine for
363
- # length types.
364
- arraysize = column_obj.votable_arraysize or column_obj.length
365
- if arraysize is None:
366
- logger.warning(
367
- f"votable:arraysize and length for {_id} are None for type {datatype_name}. "
368
- 'Using length "*". '
369
- "Consider setting `votable:arraysize` or `length`."
370
- )
371
- if felis_type.is_timestamp:
372
- # datetime types really should have a votable:arraysize, because
373
- # they are converted to strings and the `length` is loosely to the
374
- # string size
375
- if not column_obj.votable_arraysize:
376
- logger.warning(
377
- f"votable:arraysize for {_id} is None for type {datatype_name}. "
378
- f'Using length "*". '
379
- "Consider setting `votable:arraysize` to an appropriate size for "
380
- "materialized datetime/timestamp strings."
381
- )
382
-
383
- def visit_column(self, column_obj: datamodel.Column, table_obj: Table) -> Tap11Base:
384
- """Visit a column object and build its TAP_SCHEMA representation.
385
-
386
- Parameters
387
- ----------
388
- column_obj
389
- The column object to visit.
390
- table_obj
391
- The table object which the column belongs to.
392
-
393
- Returns
394
- -------
395
- ``Tap11Base``
396
- The SQLAlchemy ORM object for the column.
397
- """
398
- self.check_column(column_obj)
399
- column_id = column_obj.id
400
- table_name = self._table_name(table_obj.name)
401
-
402
- column = self.tables["columns"]()
403
- column.table_name = table_name
404
- column.column_name = column_obj.name
405
-
406
- felis_datatype = column_obj.datatype
407
- felis_type = FelisType.felis_type(felis_datatype.value)
408
- column.datatype = column_obj.votable_datatype or felis_type.votable_name
409
-
410
- column.arraysize = column_obj.votable_arraysize
411
-
412
- def _is_int(s: str) -> bool:
413
- try:
414
- int(s)
415
- return True
416
- except ValueError:
417
- return False
418
-
419
- # Handle the deprecated size attribute
420
- arraysize = column.arraysize
421
- if arraysize is not None and arraysize != "":
422
- if isinstance(arraysize, int):
423
- column.size = arraysize
424
- elif _is_int(arraysize):
425
- column.size = int(arraysize)
426
- elif bool(re.match(r"^[0-9]+\*$", arraysize)):
427
- column.size = int(arraysize.replace("*", ""))
428
-
429
- if column.size is not None:
430
- logger.debug(f"Set size to {column.size} for {column.column_name} with arraysize {arraysize}")
431
-
432
- column.xtype = column_obj.votable_xtype
433
- column.description = column_obj.description
434
- column.utype = column_obj.votable_utype
435
-
436
- unit = column_obj.ivoa_unit or column_obj.fits_tunit
437
- column.unit = unit
438
- column.ucd = column_obj.ivoa_ucd
439
-
440
- # We modify this after we process columns
441
- column.indexed = 0
442
-
443
- column.principal = column_obj.tap_principal
444
- column.std = column_obj.tap_std
445
- column.column_index = column_obj.tap_column_index
446
-
447
- self.graph_index[column_id] = column
448
- return column
449
-
450
- def visit_primary_key(self, primary_key_obj: str | Iterable[str] | None, table_obj: Table) -> None:
451
- """Visit a primary key object and update the TAP_SCHEMA representation.
452
-
453
- Parameters
454
- ----------
455
- primary_key_obj
456
- The primary key object to visit.
457
- table_obj
458
- The table object which the primary key belongs to.
459
- """
460
- if primary_key_obj:
461
- if isinstance(primary_key_obj, str):
462
- primary_key_obj = [primary_key_obj]
463
- columns = [self.graph_index[c_id] for c_id in primary_key_obj]
464
- # if just one column and it's indexed, update the object
465
- if len(columns) == 1:
466
- columns[0].indexed = 1
467
-
468
- def visit_constraint(self, constraint_obj: Constraint) -> tuple:
469
- """Visit a constraint object and build its TAP_SCHEMA representation.
470
-
471
- Parameters
472
- ----------
473
- constraint_obj
474
- The constraint object to visit.
475
-
476
- Returns
477
- -------
478
- `tuple`
479
- A tuple of the SQLAlchemy ORM objects for the TAP_SCHEMA ``key``
480
- and ``key_columns`` data.
481
- """
482
- key = None
483
- key_columns = []
484
- if isinstance(constraint_obj, ForeignKeyConstraint):
485
- constraint_name = constraint_obj.name
486
- description = constraint_obj.description
487
- utype = constraint_obj.votable_utype
488
-
489
- columns = [self.graph_index[col_id] for col_id in getattr(constraint_obj, "columns", [])]
490
- refcolumns = [
491
- self.graph_index[refcol_id] for refcol_id in getattr(constraint_obj, "referenced_columns", [])
492
- ]
493
-
494
- table_name = None
495
- for column in columns:
496
- if not table_name:
497
- table_name = column.table_name
498
- if table_name != column.table_name:
499
- raise ValueError("Inconsisent use of table names")
500
-
501
- table_name = None
502
- for column in refcolumns:
503
- if not table_name:
504
- table_name = column.table_name
505
- if table_name != column.table_name:
506
- raise ValueError("Inconsisent use of table names")
507
- first_column = columns[0]
508
- first_refcolumn = refcolumns[0]
509
-
510
- key = self.tables["keys"]()
511
- key.key_id = constraint_name
512
- key.from_table = first_column.table_name
513
- key.target_table = first_refcolumn.table_name
514
- key.description = description
515
- key.utype = utype
516
- for column, refcolumn in zip(columns, refcolumns):
517
- key_column = self.tables["key_columns"]()
518
- key_column.key_id = constraint_name
519
- key_column.from_column = column.column_name
520
- key_column.target_column = refcolumn.column_name
521
- key_columns.append(key_column)
522
- return key, key_columns
523
-
524
- def visit_index(self, index_obj: Index, table_obj: Table) -> None:
525
- """Visit an index object and update the TAP_SCHEMA representation.
526
-
527
- Parameters
528
- ----------
529
- index_obj
530
- The index object to visit.
531
- table_obj
532
- The table object which the index belongs to.
533
- """
534
- columns = [self.graph_index[col_id] for col_id in getattr(index_obj, "columns", [])]
535
- # if just one column and it's indexed, update the object
536
- if len(columns) == 1:
537
- columns[0].indexed = 1
538
- return None
539
-
540
- def _schema_name(
541
- self, schema_name: str | None = None
542
- ) -> str | None: # DM-44870: Usage of this method needs to be better understood and possibly removed
543
- """Return the schema name.
544
-
545
- Parameters
546
- ----------
547
- schema_name
548
- Name of the schema.
549
-
550
- Returns
551
- -------
552
- schema_name
553
- The schema name.
554
- """
555
- # If _schema_name is None, SQLAlchemy will catch it
556
- _schema_name = schema_name or self.schema_name
557
- if self.catalog_name and _schema_name:
558
- return ".".join([self.catalog_name, _schema_name])
559
- return _schema_name
560
-
561
- def _table_name(self, table_name: str) -> str:
562
- """Return the table name.
563
-
564
- Parameters
565
- ----------
566
- table_name
567
- Name of the table.
568
- """
569
- schema_name = self._schema_name()
570
- if schema_name:
571
- return ".".join([schema_name, table_name])
572
- return table_name
573
-
574
-
575
- def _insert(table: Tap11Base, value: Any) -> Insert:
576
- """Return a SQLAlchemy insert statement.
577
-
578
- Parameters
579
- ----------
580
- table
581
- The table we are inserting into.
582
- value
583
- An object representing the object we are inserting to the table.
584
-
585
- Returns
586
- -------
587
- `Insert`
588
- SQLAlchemy insert statement.
589
- """
590
- values_dict = {}
591
- for i in table.__table__.columns:
592
- name = i.name
593
- column_value = getattr(value, i.name)
594
- if isinstance(column_value, str):
595
- column_value = column_value.replace("'", "''")
596
- values_dict[name] = column_value
597
- return insert(table).values(values_dict)