lsst-felis 29.0.1__tar.gz → 30.0.0rc3__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.
Files changed (51) hide show
  1. {lsst_felis-29.0.1/python/lsst_felis.egg-info → lsst_felis-30.0.0rc3}/PKG-INFO +5 -5
  2. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/pyproject.toml +50 -32
  3. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/felis/__init__.py +9 -5
  4. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/felis/cli.py +256 -85
  5. lsst_felis-30.0.0rc3/python/felis/config/tap_schema/columns.csv +33 -0
  6. lsst_felis-30.0.0rc3/python/felis/config/tap_schema/key_columns.csv +8 -0
  7. lsst_felis-30.0.0rc3/python/felis/config/tap_schema/keys.csv +8 -0
  8. lsst_felis-30.0.0rc3/python/felis/config/tap_schema/schemas.csv +2 -0
  9. lsst_felis-30.0.0rc3/python/felis/config/tap_schema/tables.csv +6 -0
  10. lsst_felis-30.0.0rc3/python/felis/config/tap_schema/tap_schema_extensions.yaml +73 -0
  11. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/felis/datamodel.py +476 -61
  12. lsst_felis-29.0.1/python/felis/db/dialects.py → lsst_felis-30.0.0rc3/python/felis/db/_dialects.py +69 -4
  13. lsst_felis-29.0.1/python/felis/db/variants.py → lsst_felis-30.0.0rc3/python/felis/db/_variants.py +1 -1
  14. lsst_felis-30.0.0rc3/python/felis/db/database_context.py +917 -0
  15. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/felis/diff.py +23 -18
  16. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/felis/metadata.py +84 -11
  17. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/felis/tap_schema.py +265 -166
  18. lsst_felis-30.0.0rc3/python/felis/tests/run_cli.py +79 -0
  19. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3/python/lsst_felis.egg-info}/PKG-INFO +5 -5
  20. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/lsst_felis.egg-info/SOURCES.txt +14 -8
  21. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/lsst_felis.egg-info/requires.txt +2 -2
  22. lsst_felis-30.0.0rc3/tests/test_cli.py +362 -0
  23. lsst_felis-30.0.0rc3/tests/test_database_context.py +264 -0
  24. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/tests/test_datamodel.py +178 -6
  25. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/tests/test_diff.py +2 -0
  26. lsst_felis-30.0.0rc3/tests/test_import_modules.py +36 -0
  27. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/tests/test_metadata.py +84 -69
  28. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/tests/test_postgres.py +76 -3
  29. lsst_felis-30.0.0rc3/tests/test_tap_schema.py +801 -0
  30. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/tests/test_tap_schema_postgres.py +48 -40
  31. lsst_felis-29.0.1/python/felis/db/schema.py +0 -62
  32. lsst_felis-29.0.1/python/felis/db/utils.py +0 -409
  33. lsst_felis-29.0.1/python/felis/version.py +0 -2
  34. lsst_felis-29.0.1/tests/test_cli.py +0 -232
  35. lsst_felis-29.0.1/tests/test_db.py +0 -79
  36. lsst_felis-29.0.1/tests/test_tap_schema.py +0 -298
  37. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/COPYRIGHT +0 -0
  38. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/LICENSE +0 -0
  39. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/README.rst +0 -0
  40. {lsst_felis-29.0.1/python/felis/schemas → lsst_felis-30.0.0rc3/python/felis/config/tap_schema}/tap_schema_std.yaml +0 -0
  41. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/felis/db/__init__.py +0 -0
  42. lsst_felis-29.0.1/python/felis/db/sqltypes.py → lsst_felis-30.0.0rc3/python/felis/db/_sqltypes.py +7 -7
  43. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/felis/py.typed +0 -0
  44. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/felis/tests/__init__.py +0 -0
  45. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/felis/tests/postgresql.py +1 -1
  46. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/felis/types.py +7 -7
  47. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/lsst_felis.egg-info/dependency_links.txt +0 -0
  48. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/lsst_felis.egg-info/entry_points.txt +0 -0
  49. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/lsst_felis.egg-info/top_level.txt +0 -0
  50. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/python/lsst_felis.egg-info/zip-safe +0 -0
  51. {lsst_felis-29.0.1 → lsst_felis-30.0.0rc3}/setup.cfg +0 -0
@@ -1,19 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lsst-felis
3
- Version: 29.0.1
3
+ Version: 30.0.0rc3
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
- License: GNU General Public License v3 or later (GPLv3+)
6
+ License-Expression: GPL-3.0-or-later
7
7
  Project-URL: Homepage, https://felis.lsst.io
8
8
  Project-URL: Source, https://github.com/lsst/felis
9
9
  Keywords: lsst
10
10
  Classifier: Intended Audience :: Science/Research
11
- Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
12
11
  Classifier: Operating System :: OS Independent
13
12
  Classifier: Programming Language :: Python :: 3
14
13
  Classifier: Programming Language :: Python :: 3.11
15
14
  Classifier: Programming Language :: Python :: 3.12
16
15
  Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Topic :: Scientific/Engineering :: Astronomy
18
18
  Requires-Python: >=3.11.0
19
19
  Description-Content-Type: text/markdown
@@ -23,8 +23,8 @@ Requires-Dist: alembic
23
23
  Requires-Dist: astropy
24
24
  Requires-Dist: click
25
25
  Requires-Dist: deepdiff
26
- Requires-Dist: lsst-resources<29.100
27
- Requires-Dist: lsst-utils<29.100
26
+ Requires-Dist: lsst-resources
27
+ Requires-Dist: lsst-utils
28
28
  Requires-Dist: numpy
29
29
  Requires-Dist: pydantic<3,>=2
30
30
  Requires-Dist: pyyaml
@@ -5,19 +5,20 @@ build-backend = "setuptools.build_meta"
5
5
  [project]
6
6
  name = "lsst-felis"
7
7
  description = "A vocabulary for describing catalogs and acting on those descriptions"
8
- license = {text = "GNU General Public License v3 or later (GPLv3+)"}
8
+ license = "GPL-3.0-or-later"
9
+ license-files = ["COPYRIGHT", "LICENSE"]
9
10
  readme = "README.md"
10
11
  authors = [
11
12
  {name="Rubin Observatory Data Management", email="dm-admin@lists.lsst.org"},
12
13
  ]
13
14
  classifiers = [
14
15
  "Intended Audience :: Science/Research",
15
- "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
16
16
  "Operating System :: OS Independent",
17
17
  "Programming Language :: Python :: 3",
18
18
  "Programming Language :: Python :: 3.11",
19
19
  "Programming Language :: Python :: 3.12",
20
20
  "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
21
22
  "Topic :: Scientific/Engineering :: Astronomy"
22
23
  ]
23
24
  keywords = ["lsst"]
@@ -26,8 +27,8 @@ dependencies = [
26
27
  "astropy",
27
28
  "click",
28
29
  "deepdiff",
29
- "lsst-resources < 29.100",
30
- "lsst-utils < 29.100",
30
+ "lsst-resources",
31
+ "lsst-utils",
31
32
  "numpy",
32
33
  "pydantic >=2,<3",
33
34
  "pyyaml",
@@ -57,10 +58,9 @@ where = ["python"]
57
58
 
58
59
  [tool.setuptools]
59
60
  zip-safe = true
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" }
@@ -120,31 +120,15 @@ target-version = ["py311"]
120
120
  profile = "black"
121
121
  line_length = 110
122
122
 
123
- [tool.lsst_versions]
124
- write_to = "python/felis/version.py"
125
-
126
123
  [tool.ruff]
127
124
  line-length = 110
128
125
  target-version = "py311"
129
126
  exclude = [
130
- "__init__.py",
131
- "lex.py",
132
- "yacc.py",
127
+ "__init__.py"
133
128
  ]
134
129
 
135
130
  [tool.ruff.lint]
136
131
  ignore = [
137
- "D100",
138
- "D102",
139
- "D104",
140
- "D105",
141
- "D107",
142
- "D200",
143
- "D203",
144
- "D205",
145
- "D213",
146
- "D400",
147
- "D413",
148
132
  "N802",
149
133
  "N803",
150
134
  "N806",
@@ -152,7 +136,14 @@ ignore = [
152
136
  "N815",
153
137
  "N816",
154
138
  "N999",
155
- "UP007", # Allow UNION in type annotation
139
+ "D107",
140
+ "D105",
141
+ "D102",
142
+ "D104",
143
+ "D100",
144
+ "D200",
145
+ "D205",
146
+ "D400",
156
147
  ]
157
148
  select = [
158
149
  "E", # pycodestyle
@@ -161,8 +152,29 @@ select = [
161
152
  "W", # pycodestyle
162
153
  "D", # pydocstyle
163
154
  "UP", # pyupgrade
155
+ "I", # isort
156
+ "RUF022", # sort __all__
157
+ ]
158
+ # Commented out to suppress "unused noqa" in jenkins which has older ruff not
159
+ # generating E721.
160
+ extend-select = [
161
+ "RUF100", # Warn about unused noqa
164
162
  ]
165
163
 
164
+ [tool.ruff.lint.isort]
165
+ known-first-party = ["felis"]
166
+
167
+ [tool.ruff.lint.pycodestyle]
168
+ max-doc-length = 79
169
+
170
+ [tool.ruff.lint.pydocstyle]
171
+ convention = "numpy"
172
+
173
+ [tool.ruff.format]
174
+ docstring-code-format = true
175
+ # Formatter does not know about indenting.
176
+ docstring-code-line-length = 69
177
+
166
178
  [tool.pydocstyle]
167
179
  convention = "numpy"
168
180
  # Our coding style does not require docstrings for magic methods (D105)
@@ -171,13 +183,16 @@ convention = "numpy"
171
183
  # Docstring at the very first line is not required
172
184
  # D200, D205 and D400 all complain if the first sentence of the docstring does
173
185
  # not fit on one line. We do not require docstrings in __init__ files (D104).
174
- add-ignore = ["D107", "D105", "D102", "D100", "D200", "D205", "D400", "D104"]
175
-
176
- [tool.ruff.lint.pycodestyle]
177
- max-doc-length = 79
178
-
179
- [tool.ruff.lint.pydocstyle]
180
- convention = "numpy"
186
+ add-ignore = [
187
+ "D107",
188
+ "D105",
189
+ "D102",
190
+ "D100",
191
+ "D200",
192
+ "D205",
193
+ "D400",
194
+ "D104",
195
+ ]
181
196
 
182
197
  [tool.numpydoc_validation]
183
198
  checks = [
@@ -189,11 +204,14 @@ checks = [
189
204
  "GL01", # Summary text can start on same line as """
190
205
  "GL08", # Do not require docstring.
191
206
  "ES01", # No extended summary required.
207
+ "PR04", # Do not require types on parameters.
208
+ "RT01", # Unfortunately our @property trigger this.
209
+ "RT02", # Does not want named return value. DM style says we do.
192
210
  "SS05", # pydocstyle is better at finding infinitive verb.
193
- "PR04", # Sphinx does not require parameter type.
194
211
  ]
195
212
  exclude = [
196
213
  "^test_.*", # Do not test docstrings in test code.
214
+ '^cli', # This is the main click command
197
215
  '^__init__$',
198
216
  '\._[a-zA-Z_]+$', # Private methods.
199
217
  ]
@@ -19,8 +19,12 @@
19
19
  # You should have received a copy of the GNU General Public License
20
20
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
21
21
 
22
- from .datamodel import Schema
23
- from .db.schema import create_database
24
- from .diff import DatabaseDiff, FormattedSchemaDiff, SchemaDiff
25
- from .metadata import MetaDataBuilder
26
- from .version import *
22
+ from .datamodel import *
23
+
24
+ from importlib.metadata import PackageNotFoundError, version
25
+
26
+ try:
27
+ __version__ = version("lsst-felis")
28
+ except PackageNotFoundError:
29
+ # Package not installed or scons not run.
30
+ __version__ = "0.0.0"
@@ -29,16 +29,13 @@ from typing import IO
29
29
 
30
30
  import click
31
31
  from pydantic import ValidationError
32
- from sqlalchemy.engine import Engine, create_engine, make_url
33
- from sqlalchemy.engine.mock import MockConnection, create_mock_engine
34
32
 
35
33
  from . import __version__
36
34
  from .datamodel import Schema
37
- from .db.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
41
- from .tap_schema import DataLoader, TableManager
37
+ from .metadata import create_metadata
38
+ from .tap_schema import DataLoader, MetadataInserter, TableManager
42
39
 
43
40
  __all__ = ["cli"]
44
41
 
@@ -63,7 +60,10 @@ loglevel_choices = ["CRITICAL", "FATAL", "ERROR", "WARNING", "INFO", "DEBUG"]
63
60
  help="Felis log file path",
64
61
  )
65
62
  @click.option(
66
- "--id-generation", is_flag=True, help="Generate IDs for all objects that do not have them", default=False
63
+ "--id-generation/--no-id-generation",
64
+ is_flag=True,
65
+ help="Generate IDs for all objects that do not have them",
66
+ default=True,
67
67
  )
68
68
  @click.pass_context
69
69
  def cli(ctx: click.Context, log_level: str, log_file: str | None, id_generation: bool) -> None:
@@ -72,6 +72,8 @@ def cli(ctx: click.Context, log_level: str, log_file: str | None, id_generation:
72
72
  ctx.obj["id_generation"] = id_generation
73
73
  if ctx.obj["id_generation"]:
74
74
  logger.info("ID generation is enabled")
75
+ else:
76
+ logger.info("ID generation is disabled")
75
77
  if log_file:
76
78
  logging.basicConfig(filename=log_file, level=log_level)
77
79
  else:
@@ -95,6 +97,7 @@ def cli(ctx: click.Context, log_level: str, log_file: str | None, id_generation:
95
97
  "--output-file", "-o", type=click.File(mode="w"), help="Write SQL commands to a file instead of executing"
96
98
  )
97
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")
98
101
  @click.argument("file", type=click.File())
99
102
  @click.pass_context
100
103
  def create(
@@ -107,6 +110,7 @@ def create(
107
110
  dry_run: bool,
108
111
  output_file: IO[str] | None,
109
112
  ignore_constraints: bool,
113
+ skip_indexes: bool,
110
114
  file: IO[str],
111
115
  ) -> None:
112
116
  """Create database objects from the Felis file.
@@ -129,65 +133,141 @@ def create(
129
133
  Write SQL commands to a file instead of executing.
130
134
  ignore_constraints
131
135
  Ignore constraints when creating tables.
136
+ skip_indexes
137
+ Skip creating indexes when building metadata.
132
138
  file
133
139
  Felis file to read.
134
140
  """
135
141
  try:
136
- schema = Schema.from_stream(file, context={"id_generation": ctx.obj["id_generation"]})
137
- url = make_url(engine_url)
138
- if schema_name:
139
- logger.info(f"Overriding schema name with: {schema_name}")
140
- schema.name = schema_name
141
- elif url.drivername == "sqlite":
142
- logger.info("Overriding schema name for sqlite with: main")
143
- schema.name = "main"
144
- if not url.host and not url.drivername == "sqlite":
145
- dry_run = True
146
- logger.info("Forcing dry run for non-sqlite engine URL with no host")
147
-
148
- metadata = MetaDataBuilder(schema, ignore_constraints=ignore_constraints).build()
149
- logger.debug(f"Created metadata with schema name: {metadata.schema}")
150
-
151
- engine: Engine | MockConnection
152
- if not dry_run and not output_file:
153
- engine = create_engine(url, echo=echo)
154
- else:
155
- if dry_run:
156
- logger.info("Dry run will be executed")
157
- engine = DatabaseContext.create_mock_engine(url, output_file)
158
- if output_file:
159
- 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
+ )
160
150
 
161
- 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")
162
160
 
163
- if drop and initialize:
164
- 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.
165
165
 
166
- if drop:
167
- logger.debug("Dropping schema if it exists")
168
- context.drop()
169
- 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()
170
169
 
171
- if initialize:
172
- logger.debug("Creating schema if not exists")
173
- context.initialize()
170
+ db_ctx.create_all()
174
171
 
175
- context.create_all()
176
172
  except Exception as e:
177
173
  logger.exception(e)
178
174
  raise click.ClickException(str(e))
179
175
 
180
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
+
181
241
  @cli.command("load-tap-schema", help="Load metadata from a Felis file into a TAP_SCHEMA database")
182
242
  @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL")
183
- @click.option("--tap-schema-name", help="Name of the TAP_SCHEMA schema in the database (default: TAP_SCHEMA)")
184
243
  @click.option(
185
- "--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
244
+ "--tap-schema-name", "-n", help="Name of the TAP_SCHEMA schema in the database (default: TAP_SCHEMA)"
245
+ )
246
+ @click.option(
247
+ "--tap-tables-postfix",
248
+ "-p",
249
+ help="Postfix which is applied to standard TAP_SCHEMA table names",
250
+ default="",
251
+ )
252
+ @click.option("--tap-schema-index", "-i", type=int, help="TAP_SCHEMA index of the schema in this environment")
253
+ @click.option("--dry-run", "-D", is_flag=True, help="Execute dry run only. Does not insert any data.")
254
+ @click.option("--echo", "-e", is_flag=True, help="Print out the generated insert statements to stdout")
255
+ @click.option(
256
+ "--output-file", "-o", type=click.File(mode="w"), help="Write SQL commands to a file instead of executing"
257
+ )
258
+ @click.option(
259
+ "--force-unbounded-arraysize",
260
+ is_flag=True,
261
+ help="Use unbounded arraysize by default for all variable length string columns"
262
+ ", e.g., ``votable:arraysize: *`` (workaround for astropy bug #18099)",
263
+ ) # DM-50899: Variable-length bounded strings are not handled correctly in astropy
264
+ @click.option(
265
+ "--unique-keys",
266
+ "-u",
267
+ is_flag=True,
268
+ help="Generate unique key_id values for keys and key_columns tables by prepending the schema name",
269
+ default=False,
186
270
  )
187
- @click.option("--tap-schema-index", type=int, help="TAP_SCHEMA index of the schema in this environment")
188
- @click.option("--dry-run", is_flag=True, help="Execute dry run only. Does not insert any data.")
189
- @click.option("--echo", is_flag=True, help="Print out the generated insert statements to stdout")
190
- @click.option("--output-file", type=click.Path(), help="Write SQL commands to a file")
191
271
  @click.argument("file", type=click.File())
192
272
  @click.pass_context
193
273
  def load_tap_schema(
@@ -198,7 +278,9 @@ def load_tap_schema(
198
278
  tap_schema_index: int,
199
279
  dry_run: bool,
200
280
  echo: bool,
201
- output_file: str | None,
281
+ output_file: IO[str] | None,
282
+ force_unbounded_arraysize: bool,
283
+ unique_keys: bool,
202
284
  file: IO[str],
203
285
  ) -> None:
204
286
  """Load TAP metadata from a Felis file.
@@ -225,41 +307,68 @@ def load_tap_schema(
225
307
  The TAP_SCHEMA database must already exist or the command will fail. This
226
308
  command will not initialize the TAP_SCHEMA tables.
227
309
  """
228
- url = make_url(engine_url)
229
- engine: Engine | MockConnection
230
- if dry_run or is_mock_url(url):
231
- engine = create_mock_engine(url, executor=None)
232
- else:
233
- engine = create_engine(engine_url)
310
+ # Create TableManager with automatic dialect detection
234
311
  mgr = TableManager(
235
- engine=engine,
236
- apply_schema_to_metadata=False if engine.dialect.name == "sqlite" else True,
312
+ engine_url=engine_url,
237
313
  schema_name=tap_schema_name,
238
314
  table_name_postfix=tap_tables_postfix,
239
315
  )
240
316
 
241
- schema = Schema.from_stream(file, context={"id_generation": ctx.obj["id_generation"]})
317
+ # Create DatabaseContext using TableManager's metadata
318
+ with create_database_context(
319
+ engine_url, mgr.metadata, echo=echo, dry_run=dry_run, output_file=output_file
320
+ ) as db_ctx:
321
+ schema = Schema.from_stream(
322
+ file,
323
+ context={
324
+ "id_generation": ctx.obj["id_generation"],
325
+ "force_unbounded_arraysize": force_unbounded_arraysize,
326
+ },
327
+ )
242
328
 
243
- DataLoader(
244
- schema,
245
- mgr,
246
- engine,
247
- tap_schema_index=tap_schema_index,
248
- dry_run=dry_run,
249
- print_sql=echo,
250
- output_path=output_file,
251
- ).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()
252
339
 
253
340
 
254
341
  @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")
342
+ @click.option("--engine-url", envvar="FELIS_ENGINE_URL", help="SQLAlchemy Engine URL", required=True)
256
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
+ )
257
355
  @click.option(
258
356
  "--tap-tables-postfix", help="Postfix which is applied to standard TAP_SCHEMA table names", default=""
259
357
  )
358
+ @click.option(
359
+ "--insert-metadata/--no-insert-metadata",
360
+ is_flag=True,
361
+ help="Insert metadata describing TAP_SCHEMA itself",
362
+ default=True,
363
+ )
260
364
  @click.pass_context
261
365
  def init_tap_schema(
262
- ctx: click.Context, engine_url: str, tap_schema_name: str, tap_tables_postfix: str
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,
263
372
  ) -> None:
264
373
  """Initialize a standard TAP_SCHEMA database.
265
374
 
@@ -269,20 +378,28 @@ def init_tap_schema(
269
378
  SQLAlchemy Engine URL.
270
379
  tap_schema_name
271
380
  Name of the TAP_SCHEMA schema in the database.
381
+ extensions
382
+ Extensions YAML file.
272
383
  tap_tables_postfix
273
384
  Postfix which is applied to standard TAP_SCHEMA table names.
385
+ insert_metadata
386
+ Insert metadata describing TAP_SCHEMA itself.
387
+ If set to False, only the TAP_SCHEMA tables will be created, but no
388
+ metadata will be inserted.
274
389
  """
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)
390
+ # Create TableManager with automatic dialect detection
280
391
  mgr = TableManager(
281
- apply_schema_to_metadata=False if engine.dialect.name == "sqlite" else True,
392
+ engine_url=engine_url,
282
393
  schema_name=tap_schema_name,
283
394
  table_name_postfix=tap_tables_postfix,
395
+ extensions_path=extensions,
284
396
  )
285
- mgr.initialize_database(engine)
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()
286
403
 
287
404
 
288
405
  @cli.command("validate", help="Validate one or more Felis YAML files")
@@ -395,24 +512,37 @@ def diff(
395
512
  error_on_change: bool,
396
513
  files: Iterable[IO[str]],
397
514
  ) -> None:
515
+ files_list = list(files)
398
516
  schemas = [
399
- 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
400
518
  ]
401
-
402
519
  diff: SchemaDiff
403
- if len(schemas) == 2 and engine_url is None:
520
+ if len(schemas) == 2:
404
521
  if comparator == "alembic":
405
- db_context = create_database(schemas[0])
406
- assert isinstance(db_context.engine, Engine)
407
- 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)
408
533
  else:
409
534
  diff = FormattedSchemaDiff(schemas[0], schemas[1])
410
535
  elif len(schemas) == 1 and engine_url is not None:
411
- engine = create_engine(engine_url)
412
- 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)
413
543
  else:
414
544
  raise click.ClickException(
415
- "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"
416
546
  )
417
547
 
418
548
  diff.print()
@@ -421,5 +551,46 @@ def diff(
421
551
  raise click.ClickException("Schema was changed")
422
552
 
423
553
 
554
+ @cli.command(
555
+ "dump",
556
+ help="""
557
+ Dump a schema file to YAML or JSON format
558
+
559
+ Example:
560
+
561
+ felis dump schema.yaml schema.json
562
+
563
+ felis dump schema.yaml schema_dump.yaml
564
+ """,
565
+ )
566
+ @click.option(
567
+ "--strip-ids/--no-strip-ids",
568
+ is_flag=True,
569
+ help="Strip IDs from the output schema",
570
+ default=False,
571
+ )
572
+ @click.argument("files", nargs=2, type=click.Path())
573
+ @click.pass_context
574
+ def dump(
575
+ ctx: click.Context,
576
+ strip_ids: bool,
577
+ files: list[str],
578
+ ) -> None:
579
+ if strip_ids:
580
+ logger.info("Stripping IDs from the output schema")
581
+ if files[1].endswith(".json"):
582
+ format = "json"
583
+ elif files[1].endswith(".yaml"):
584
+ format = "yaml"
585
+ else:
586
+ raise click.ClickException("Output file must have a .json or .yaml extension")
587
+ schema = Schema.from_uri(files[0], context={"id_generation": ctx.obj["id_generation"]})
588
+ with open(files[1], "w") as f:
589
+ if format == "yaml":
590
+ schema.dump_yaml(f, strip_ids=strip_ids)
591
+ elif format == "json":
592
+ schema.dump_json(f, strip_ids=strip_ids)
593
+
594
+
424
595
  if __name__ == "__main__":
425
596
  cli()