lsst-felis 29.2025.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.
felis/__init__.py CHANGED
@@ -19,10 +19,7 @@
19
19
  # You should have received a copy of the GNU General Public License
20
20
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
21
21
 
22
- from .datamodel import Schema
23
- from .db.schema import create_database
24
- from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff
25
- from .metadata import MetaDataBuilder
22
+ from .datamodel import *
26
23
 
27
24
  from importlib.metadata import PackageNotFoundError, version
28
25
 
felis/cli.py CHANGED
@@ -29,15 +29,12 @@ 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.schema import create_database
38
- from .db.utils import DatabaseContext, is_mock_url
35
+ from .db.database_context import create_database_context
39
36
  from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff
40
- from .metadata import MetaDataBuilder
37
+ from .metadata import create_metadata
41
38
  from .tap_schema import DataLoader, MetadataInserter, TableManager
42
39
 
43
40
  __all__ = ["cli"]
@@ -100,6 +97,7 @@ def cli(ctx: click.Context, log_level: str, log_file: str | None, id_generation:
100
97
  "--output-file", "-o", type=click.File(mode="w"), help="Write SQL commands to a file instead of executing"
101
98
  )
102
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")
103
101
  @click.argument("file", type=click.File())
104
102
  @click.pass_context
105
103
  def create(
@@ -112,6 +110,7 @@ def create(
112
110
  dry_run: bool,
113
111
  output_file: IO[str] | None,
114
112
  ignore_constraints: bool,
113
+ skip_indexes: bool,
115
114
  file: IO[str],
116
115
  ) -> None:
117
116
  """Create database objects from the Felis file.
@@ -134,55 +133,111 @@ def create(
134
133
  Write SQL commands to a file instead of executing.
135
134
  ignore_constraints
136
135
  Ignore constraints when creating tables.
136
+ skip_indexes
137
+ Skip creating indexes when building metadata.
137
138
  file
138
139
  Felis file to read.
139
140
  """
140
141
  try:
141
- schema = Schema.from_stream(file, context={"id_generation": ctx.obj["id_generation"]})
142
- url = make_url(engine_url)
143
- if schema_name:
144
- logger.info(f"Overriding schema name with: {schema_name}")
145
- schema.name = schema_name
146
- elif url.drivername == "sqlite":
147
- logger.info("Overriding schema name for sqlite with: main")
148
- schema.name = "main"
149
- if not url.host and not url.drivername == "sqlite":
150
- dry_run = True
151
- logger.info("Forcing dry run for non-sqlite engine URL with no host")
152
-
153
- metadata = MetaDataBuilder(schema, ignore_constraints=ignore_constraints).build()
154
- logger.debug(f"Created metadata with schema name: {metadata.schema}")
155
-
156
- engine: Engine | MockConnection
157
- if not dry_run and not output_file:
158
- engine = create_engine(url, echo=echo)
159
- else:
160
- if dry_run:
161
- logger.info("Dry run will be executed")
162
- engine = DatabaseContext.create_mock_engine(url, output_file)
163
- if output_file:
164
- 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
+ )
165
150
 
166
- 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")
167
160
 
168
- if drop and initialize:
169
- 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.
170
165
 
171
- if drop:
172
- logger.debug("Dropping schema if it exists")
173
- context.drop()
174
- 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()
175
169
 
176
- if initialize:
177
- logger.debug("Creating schema if not exists")
178
- context.initialize()
170
+ db_ctx.create_all()
179
171
 
180
- context.create_all()
181
172
  except Exception as e:
182
173
  logger.exception(e)
183
174
  raise click.ClickException(str(e))
184
175
 
185
176
 
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,
184
+ engine_url: str,
185
+ schema_name: str | None,
186
+ file: IO[str],
187
+ ) -> None:
188
+ """Create indexes from a Felis YAML file in a target database.
189
+
190
+ Parameters
191
+ ----------
192
+ engine_url
193
+ SQLAlchemy Engine URL.
194
+ file
195
+ Felis file to read.
196
+ """
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))
206
+
207
+
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")
211
+ @click.argument("file", type=click.File())
212
+ @click.pass_context
213
+ def drop_indexes(
214
+ ctx: click.Context,
215
+ engine_url: str,
216
+ schema_name: str | None,
217
+ file: IO[str],
218
+ ) -> None:
219
+ """Drop indexes from a Felis YAML file in a target database.
220
+
221
+ Parameters
222
+ ----------
223
+ engine_url
224
+ SQLAlchemy Engine URL.
225
+ schema-name
226
+ Alternate schema name to override Felis file.
227
+ file
228
+ Felis file to read.
229
+ """
230
+ try:
231
+ metadata = create_metadata(
232
+ file, id_generation=ctx.obj["id_generation"], schema_name=schema_name, engine_url=engine_url
233
+ )
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))
239
+
240
+
186
241
  @cli.command("load-tap-schema", help="Load metadata from a Felis file into a TAP_SCHEMA database")
187
242
  @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
188
243
  @click.option(
@@ -197,7 +252,9 @@ def create(
197
252
  @click.option("--tap-schema-index", "-i", type=int, help="TAP_SCHEMA index of the schema in this environment")
198
253
  @click.option("--dry-run", "-D", is_flag=True, help="Execute dry run only. Does not insert any data.")
199
254
  @click.option("--echo", "-e", is_flag=True, help="Print out the generated insert statements to stdout")
200
- @click.option("--output-file", "-o", type=click.Path(), help="Write SQL commands to a file")
255
+ @click.option(
256
+ "--output-file", "-o", type=click.File(mode="w"), help="Write SQL commands to a file instead of executing"
257
+ )
201
258
  @click.option(
202
259
  "--force-unbounded-arraysize",
203
260
  is_flag=True,
@@ -221,7 +278,7 @@ def load_tap_schema(
221
278
  tap_schema_index: int,
222
279
  dry_run: bool,
223
280
  echo: bool,
224
- output_file: str | None,
281
+ output_file: IO[str] | None,
225
282
  force_unbounded_arraysize: bool,
226
283
  unique_keys: bool,
227
284
  file: IO[str],
@@ -250,42 +307,51 @@ def load_tap_schema(
250
307
  The TAP_SCHEMA database must already exist or the command will fail. This
251
308
  command will not initialize the TAP_SCHEMA tables.
252
309
  """
253
- url = make_url(engine_url)
254
- engine: Engine | MockConnection
255
- if dry_run or is_mock_url(url):
256
- engine = create_mock_engine(url, executor=None)
257
- else:
258
- engine = create_engine(engine_url)
310
+ # Create TableManager with automatic dialect detection
259
311
  mgr = TableManager(
260
- engine=engine,
261
- apply_schema_to_metadata=False if engine.dialect.name == "sqlite" else True,
312
+ engine_url=engine_url,
262
313
  schema_name=tap_schema_name,
263
314
  table_name_postfix=tap_tables_postfix,
264
315
  )
265
316
 
266
- schema = Schema.from_stream(
267
- file,
268
- context={
269
- "id_generation": ctx.obj["id_generation"],
270
- "force_unbounded_arraysize": force_unbounded_arraysize,
271
- },
272
- )
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
+ )
273
328
 
274
- DataLoader(
275
- schema,
276
- mgr,
277
- engine,
278
- tap_schema_index=tap_schema_index,
279
- dry_run=dry_run,
280
- print_sql=echo,
281
- output_path=output_file,
282
- unique_keys=unique_keys,
283
- ).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()
284
339
 
285
340
 
286
341
  @cli.command("init-tap-schema", help="Initialize a standard TAP_SCHEMA database")
287
342
  @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL", required=True)
288
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
+ )
289
355
  @click.option(
290
356
  "--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
291
357
  )
@@ -297,7 +363,12 @@ def load_tap_schema(
297
363
  )
298
364
  @click.pass_context
299
365
  def init_tap_schema(
300
- ctx: click.Context, engine_url: str, tap_schema_name: str, tap_tables_postfix: str, insert_metadata: bool
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,
301
372
  ) -> None:
302
373
  """Initialize a standard TAP_SCHEMA database.
303
374
 
@@ -307,6 +378,8 @@ def init_tap_schema(
307
378
  SQLAlchemy Engine URL.
308
379
  tap_schema_name
309
380
  Name of the TAP_SCHEMA schema in the database.
381
+ extensions
382
+ Extensions YAML file.
310
383
  tap_tables_postfix
311
384
  Postfix which is applied to standard TAP_SCHEMA table names.
312
385
  insert_metadata
@@ -314,20 +387,19 @@ def init_tap_schema(
314
387
  If set to False, only the TAP_SCHEMA tables will be created, but no
315
388
  metadata will be inserted.
316
389
  """
317
- url = make_url(engine_url)
318
- engine: Engine | MockConnection
319
- if is_mock_url(url):
320
- raise click.ClickException("Mock engine URL is not supported for this command")
321
- engine = create_engine(engine_url)
390
+ # Create TableManager with automatic dialect detection
322
391
  mgr = TableManager(
323
- apply_schema_to_metadata=False if engine.dialect.name == "sqlite" else True,
392
+ engine_url=engine_url,
324
393
  schema_name=tap_schema_name,
325
394
  table_name_postfix=tap_tables_postfix,
395
+ extensions_path=extensions,
326
396
  )
327
- mgr.initialize_database(engine)
328
- if insert_metadata:
329
- inserter = MetadataInserter(mgr, engine)
330
- inserter.insert_metadata()
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()
331
403
 
332
404
 
333
405
  @cli.command("validate", help="Validate one or more Felis YAML files")
@@ -440,24 +512,37 @@ def diff(
440
512
  error_on_change: bool,
441
513
  files: Iterable[IO[str]],
442
514
  ) -> None:
515
+ files_list = list(files)
443
516
  schemas = [
444
- Schema.from_stream(file, context={"id_generation": ctx.obj["id_generation"]}) for file in files
517
+ Schema.from_stream(file, context={"id_generation": ctx.obj["id_generation"]}) for file in files_list
445
518
  ]
446
-
447
519
  diff: SchemaDiff
448
- if len(schemas) == 2 and engine_url is None:
520
+ if len(schemas) == 2:
449
521
  if comparator == "alembic":
450
- db_context = create_database(schemas[0])
451
- assert isinstance(db_context.engine, Engine)
452
- diff = DatabaseDiff(schemas[1], db_context.engine)
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)
453
533
  else:
454
534
  diff = FormattedSchemaDiff(schemas[0], schemas[1])
455
535
  elif len(schemas) == 1 and engine_url is not None:
456
- engine = create_engine(engine_url)
457
- diff = DatabaseDiff(schemas[0], engine)
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)
458
543
  else:
459
544
  raise click.ClickException(
460
- "Invalid arguments - provide two schemas or a schema and a database engine URL"
545
+ "Invalid arguments - provide two schemas or a single schema and a database engine URL"
461
546
  )
462
547
 
463
548
  diff.print()
@@ -0,0 +1,73 @@
1
+ # TAP_SCHEMA Extensions
2
+ # This file defines additional columns to be added to the standard TAP_SCHEMA tables
3
+ # These are extensions beyond the IVOA TAP 1.1 specification and needed for the CADC TAP service
4
+
5
+ # Extension columns for each TAP_SCHEMA table
6
+ name: tap_schema_extensions
7
+ description: Extensions to the standard TAP_SCHEMA tables
8
+
9
+ tables:
10
+ - name: schemas
11
+ description: "Extensions to TAP_SCHEMA.schemas table"
12
+ columns:
13
+ - name: owner_id
14
+ datatype: char
15
+ length: 32
16
+ description: "Owner identifier for user-created content"
17
+
18
+ - name: read_anon
19
+ datatype: int
20
+ description: "Anonymous read permission flag (0 or 1)"
21
+
22
+ - name: read_only_group
23
+ datatype: char
24
+ length: 128
25
+ description: "Read-only group identifier"
26
+
27
+ - name: read_write_group
28
+ datatype: char
29
+ length: 128
30
+ description: "Read-write group identifier"
31
+
32
+ - name: api_created
33
+ datatype: int
34
+ description: "Flag indicating if schema was created via TAP service API (0 or 1)"
35
+
36
+ - name: tables
37
+ description: "Extensions to TAP_SCHEMA.tables table"
38
+ columns:
39
+ - name: owner_id
40
+ datatype: char
41
+ length: 32
42
+ description: "Owner identifier for user-created content"
43
+
44
+ - name: read_anon
45
+ datatype: int
46
+ description: "Anonymous read permission flag (0 or 1)"
47
+
48
+ - name: read_only_group
49
+ datatype: char
50
+ length: 128
51
+ description: "Read-only group identifier"
52
+
53
+ - name: read_write_group
54
+ datatype: char
55
+ length: 128
56
+ description: "Read-write group identifier"
57
+
58
+ - name: api_created
59
+ datatype: int
60
+ description: "Flag indicating if table was created via TAP service API (0 or 1)"
61
+
62
+ - name: view_target
63
+ datatype: char
64
+ length: 128
65
+ description: "View target identifier"
66
+
67
+ - name: columns
68
+ description: "Extensions to TAP_SCHEMA.columns table"
69
+ columns:
70
+ - name: column_id
71
+ datatype: char
72
+ length: 32
73
+ description: "Globally unique columnID for use as an XML ID attribute on the FIELD in VOTable output"
felis/datamodel.py CHANGED
@@ -47,9 +47,8 @@ from pydantic import (
47
47
  )
48
48
  from pydantic_core import InitErrorDetails
49
49
 
50
- from .db.dialects import get_supported_dialects
51
- from .db.sqltypes import get_type_func
52
- from .db.utils import string_to_typeengine
50
+ from .db._dialects import get_supported_dialects, string_to_typeengine
51
+ from .db._sqltypes import get_type_func
53
52
  from .types import Boolean, Byte, Char, Double, FelisType, Float, Int, Long, Short, String, Text, Unicode
54
53
 
55
54
  logger = logging.getLogger(__name__)
@@ -1,4 +1,4 @@
1
- """Get SQLAlchemy dialects and their type modules."""
1
+ """Utilities for accessing SQLAlchemy dialects and their type modules."""
2
2
 
3
3
  # This file is part of felis.
4
4
  #
@@ -23,16 +23,18 @@
23
23
 
24
24
  from __future__ import annotations
25
25
 
26
+ import re
26
27
  from collections.abc import Mapping
27
28
  from types import MappingProxyType, ModuleType
28
29
 
29
- from sqlalchemy import dialects
30
+ from sqlalchemy import dialects, types
30
31
  from sqlalchemy.engine import Dialect
31
32
  from sqlalchemy.engine.mock import create_mock_engine
33
+ from sqlalchemy.types import TypeEngine
32
34
 
33
- from .sqltypes import MYSQL, POSTGRES, SQLITE
35
+ from ._sqltypes import MYSQL, POSTGRES, SQLITE
34
36
 
35
- __all__ = ["get_dialect_module", "get_supported_dialects"]
37
+ __all__ = ["get_dialect_module", "get_supported_dialects", "string_to_typeengine"]
36
38
 
37
39
  _DIALECT_NAMES = (MYSQL, POSTGRES, SQLITE)
38
40
  """List of supported dialect names.
@@ -40,6 +42,9 @@ _DIALECT_NAMES = (MYSQL, POSTGRES, SQLITE)
40
42
  This list is used to create the dialect and module dictionaries.
41
43
  """
42
44
 
45
+ _DATATYPE_REGEXP = re.compile(r"(\w+)(\((.*)\))?")
46
+ """Regular expression to match data types with parameters in parentheses."""
47
+
43
48
 
44
49
  def _dialect(dialect_name: str) -> Dialect:
45
50
  """Create the SQLAlchemy dialect for the given name using a mock engine.
@@ -114,3 +119,63 @@ def get_dialect_module(dialect_name: str) -> ModuleType:
114
119
  if dialect_name not in _DIALECT_MODULES:
115
120
  raise ValueError(f"Unsupported dialect: {dialect_name}")
116
121
  return _DIALECT_MODULES[dialect_name]
122
+
123
+
124
+ def string_to_typeengine(
125
+ type_string: str, dialect: Dialect | None = None, length: int | None = None
126
+ ) -> TypeEngine:
127
+ """Convert a string representation of a datatype to a SQLAlchemy type.
128
+
129
+ Parameters
130
+ ----------
131
+ type_string
132
+ The string representation of the data type.
133
+ dialect
134
+ The SQLAlchemy dialect to use. If None, the default dialect will be
135
+ used.
136
+ length
137
+ The length of the data type. If the data type does not have a length
138
+ attribute, this parameter will be ignored.
139
+
140
+ Returns
141
+ -------
142
+ `sqlalchemy.types.TypeEngine`
143
+ The SQLAlchemy type engine object.
144
+
145
+ Raises
146
+ ------
147
+ ValueError
148
+ Raised if the type string is invalid or the type is not supported.
149
+
150
+ Notes
151
+ -----
152
+ This function is used when converting type override strings defined in
153
+ fields such as ``mysql:datatype`` in the schema data.
154
+ """
155
+ match = _DATATYPE_REGEXP.search(type_string)
156
+ if not match:
157
+ raise ValueError(f"Invalid type string: {type_string}")
158
+
159
+ type_name, _, params = match.groups()
160
+ if dialect is None:
161
+ type_class = getattr(types, type_name.upper(), None)
162
+ else:
163
+ try:
164
+ dialect_module = get_dialect_module(dialect.name)
165
+ except KeyError:
166
+ raise ValueError(f"Unsupported dialect: {dialect}")
167
+ type_class = getattr(dialect_module, type_name.upper(), None)
168
+
169
+ if not type_class:
170
+ raise ValueError(f"Unsupported type: {type_name.upper()}")
171
+
172
+ if params:
173
+ params = [int(param) if param.isdigit() else param for param in params.split(",")]
174
+ type_obj = type_class(*params)
175
+ else:
176
+ type_obj = type_class()
177
+
178
+ if hasattr(type_obj, "length") and getattr(type_obj, "length") is None and length is not None:
179
+ type_obj.length = length
180
+
181
+ return type_obj
@@ -32,7 +32,7 @@ from sqlalchemy import types
32
32
  from sqlalchemy.types import TypeEngine
33
33
 
34
34
  from ..datamodel import Column
35
- from .dialects import get_dialect_module, get_supported_dialects
35
+ from ._dialects import get_dialect_module, get_supported_dialects
36
36
 
37
37
  __all__ = ["make_variant_dict"]
38
38