lsst-felis 27.2024.2900__tar.gz → 27.2024.3100__tar.gz

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

Potentially problematic release.


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

Files changed (34) hide show
  1. {lsst_felis-27.2024.2900/python/lsst_felis.egg-info → lsst_felis-27.2024.3100}/PKG-INFO +1 -1
  2. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/README.rst +9 -34
  3. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/felis/cli.py +58 -54
  4. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/felis/datamodel.py +14 -14
  5. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/felis/db/dialects.py +1 -1
  6. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/felis/db/sqltypes.py +1 -1
  7. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/felis/db/utils.py +35 -10
  8. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/felis/db/variants.py +2 -2
  9. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/felis/metadata.py +2 -2
  10. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/felis/tap.py +24 -7
  11. lsst_felis-27.2024.3100/python/felis/tests/__init__.py +0 -0
  12. lsst_felis-27.2024.3100/python/felis/tests/postgresql.py +134 -0
  13. lsst_felis-27.2024.3100/python/felis/version.py +2 -0
  14. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100/python/lsst_felis.egg-info}/PKG-INFO +1 -1
  15. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/lsst_felis.egg-info/SOURCES.txt +3 -0
  16. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/tests/test_cli.py +10 -0
  17. lsst_felis-27.2024.3100/tests/test_postgresql.py +89 -0
  18. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/tests/test_tap.py +1 -3
  19. lsst_felis-27.2024.2900/python/felis/version.py +0 -2
  20. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/COPYRIGHT +0 -0
  21. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/LICENSE +0 -0
  22. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/pyproject.toml +0 -0
  23. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/felis/__init__.py +0 -0
  24. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/felis/db/__init__.py +0 -0
  25. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/felis/py.typed +0 -0
  26. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/felis/types.py +0 -0
  27. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/lsst_felis.egg-info/dependency_links.txt +0 -0
  28. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/lsst_felis.egg-info/entry_points.txt +0 -0
  29. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/lsst_felis.egg-info/requires.txt +0 -0
  30. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/lsst_felis.egg-info/top_level.txt +0 -0
  31. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/python/lsst_felis.egg-info/zip-safe +0 -0
  32. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/setup.cfg +0 -0
  33. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/tests/test_datamodel.py +0 -0
  34. {lsst_felis-27.2024.2900 → lsst_felis-27.2024.3100}/tests/test_metadata.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lsst-felis
3
- Version: 27.2024.2900
3
+ Version: 27.2024.3100
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,7 +1,7 @@
1
1
  Felis
2
2
  =====
3
3
 
4
- |PyPI| |Python|
4
+ |Tag| |PyPI| |Python| |Codecov|
5
5
 
6
6
  .. |PyPI| image:: https://img.shields.io/pypi/v/lsst-felis
7
7
  :target: https://pypi.org/project/lsst-felis
@@ -11,6 +11,14 @@ Felis
11
11
  :target: https://pypi.org/project/lsst-felis
12
12
  :alt: PyPI - Python Version
13
13
 
14
+ .. |Codecov| image:: https://codecov.io/gh/lsst/felis/branch/main/graph/badge.svg
15
+ :target: https://codecov.io/gh/lsst/felis
16
+ :alt: Codecov
17
+
18
+ .. |Tag| image:: https://img.shields.io/github/v/tag/lsst/felis
19
+ :target: https://github.com/lsst/felis/tags
20
+ :alt: Latest Tag
21
+
14
22
  YAML Schema Definition Language for Databases
15
23
 
16
24
  Overview
@@ -39,45 +47,12 @@ that it can be used as a general tool to define, update, and manage database
39
47
  schemas in a way that is independent of database variant or implementation
40
48
  language such as SQL.
41
49
 
42
- Installation and Usage
43
- ----------------------
44
-
45
- Felis is designed to work with Python 3.11 and 3.12 and may be installed using
46
- `pip <https://pypi.org/project/pip/>`_::
47
-
48
- pip install lsst-felis
49
-
50
- The `felis` command-line tool that is installed with the package can be used to
51
- perform various actions on the YAML schema files, including validating the
52
- schema definitions, generating DDL statements for various databases, or
53
- updating a TAP service with schema metadata. The command line help provides
54
- documentation on all of these utilities::
55
-
56
- felis --help
57
-
58
- Individual subcommands also have their own documentation::
59
-
60
- felis validate --help
61
-
62
- For instance, this command can be used to validate a schema file::
63
-
64
- felis validate myschema.yaml
65
-
66
- If the schema generates validation errors, then these will be printed to the
67
- terminal. These errors may include missing required attributes, misspelled YAML
68
- keys, invalid data values, etc.
69
-
70
50
  Documentation
71
51
  -------------
72
52
 
73
53
  Detailed information on usage, customization, and design is available at the
74
54
  `Felis documentation site <https://felis.lsst.io>`_.
75
55
 
76
- Presentations
77
- -------------
78
-
79
- - `IVOA Inter Op 2018 <https://wiki.ivoa.net/internal/IVOA/InterOpNov2018Apps/Felis_ivoa-11_2018.pdf>`_ - "Felis: A YAML Schema Definition Language for Database Schemas" - `slides <https://wiki.ivoa.net/internal/IVOA/InterOpNov2018Apps/Felis_ivoa-11_2018.pdf>`__
80
-
81
56
  Support
82
57
  -------
83
58
 
@@ -74,9 +74,13 @@ def cli(log_level: str, log_file: str | None) -> None:
74
74
  @click.option("--engine-url", envvar="ENGINE_URL", help="SQLAlchemy Engine URL", default="sqlite://")
75
75
  @click.option("--schema-name", help="Alternate schema name to override Felis file")
76
76
  @click.option(
77
- "--create-if-not-exists", is_flag=True, help="Create the schema in the database if it does not exist"
77
+ "--initialize",
78
+ is_flag=True,
79
+ help="Create the schema in the database if it does not exist (error if already exists)",
80
+ )
81
+ @click.option(
82
+ "--drop", is_flag=True, help="Drop schema if it already exists in the database (implies --initialize)"
78
83
  )
79
- @click.option("--drop-if-exists", is_flag=True, help="Drop schema if it already exists in the database")
80
84
  @click.option("--echo", is_flag=True, help="Echo database commands as they are executed")
81
85
  @click.option("--dry-run", is_flag=True, help="Dry run only to print out commands instead of executing")
82
86
  @click.option(
@@ -86,8 +90,8 @@ def cli(log_level: str, log_file: str | None) -> None:
86
90
  def create(
87
91
  engine_url: str,
88
92
  schema_name: str | None,
89
- create_if_not_exists: bool,
90
- drop_if_exists: bool,
93
+ initialize: bool,
94
+ drop: bool,
91
95
  echo: bool,
92
96
  dry_run: bool,
93
97
  output_file: IO[str] | None,
@@ -101,9 +105,9 @@ def create(
101
105
  SQLAlchemy Engine URL.
102
106
  schema_name
103
107
  Alternate schema name to override Felis file.
104
- create_if_not_exists
108
+ initialize
105
109
  Create the schema in the database if it does not exist.
106
- drop_if_exists
110
+ drop
107
111
  Drop schema if it already exists in the database.
108
112
  echo
109
113
  Echo database commands as they are executed.
@@ -113,52 +117,52 @@ def create(
113
117
  Write SQL commands to a file instead of executing.
114
118
  file
115
119
  Felis file to read.
116
-
117
- Notes
118
- -----
119
- This command creates database objects from the Felis file. The
120
- ``--create-if-not-exists`` or ``--drop-if-exists`` flags can be used to
121
- create a new MySQL database or PostgreSQL schema if it does not exist
122
- already.
123
120
  """
124
- yaml_data = yaml.safe_load(file)
125
- schema = Schema.model_validate(yaml_data)
126
- url = make_url(engine_url)
127
- if schema_name:
128
- logger.info(f"Overriding schema name with: {schema_name}")
129
- schema.name = schema_name
130
- elif url.drivername == "sqlite":
131
- logger.info("Overriding schema name for sqlite with: main")
132
- schema.name = "main"
133
- if not url.host and not url.drivername == "sqlite":
134
- dry_run = True
135
- logger.info("Forcing dry run for non-sqlite engine URL with no host")
136
-
137
- metadata = MetaDataBuilder(schema).build()
138
- logger.debug(f"Created metadata with schema name: {metadata.schema}")
139
-
140
- engine: Engine | MockConnection
141
- if not dry_run and not output_file:
142
- engine = create_engine(url, echo=echo)
143
- else:
144
- if dry_run:
145
- logger.info("Dry run will be executed")
146
- engine = DatabaseContext.create_mock_engine(url, output_file)
147
- if output_file:
148
- logger.info("Writing SQL output to: " + output_file.name)
149
-
150
- context = DatabaseContext(metadata, engine)
151
-
152
- if drop_if_exists:
153
- logger.debug("Dropping schema if it exists")
154
- context.drop_if_exists()
155
- create_if_not_exists = True # If schema is dropped, it needs to be recreated.
156
-
157
- if create_if_not_exists:
158
- logger.debug("Creating schema if not exists")
159
- context.create_if_not_exists()
160
-
161
- context.create_all()
121
+ try:
122
+ yaml_data = yaml.safe_load(file)
123
+ schema = Schema.model_validate(yaml_data)
124
+ url = make_url(engine_url)
125
+ if schema_name:
126
+ logger.info(f"Overriding schema name with: {schema_name}")
127
+ schema.name = schema_name
128
+ elif url.drivername == "sqlite":
129
+ logger.info("Overriding schema name for sqlite with: main")
130
+ schema.name = "main"
131
+ if not url.host and not url.drivername == "sqlite":
132
+ dry_run = True
133
+ logger.info("Forcing dry run for non-sqlite engine URL with no host")
134
+
135
+ metadata = MetaDataBuilder(schema).build()
136
+ logger.debug(f"Created metadata with schema name: {metadata.schema}")
137
+
138
+ engine: Engine | MockConnection
139
+ if not dry_run and not output_file:
140
+ engine = create_engine(url, echo=echo)
141
+ else:
142
+ if dry_run:
143
+ logger.info("Dry run will be executed")
144
+ engine = DatabaseContext.create_mock_engine(url, output_file)
145
+ if output_file:
146
+ logger.info("Writing SQL output to: " + output_file.name)
147
+
148
+ context = DatabaseContext(metadata, engine)
149
+
150
+ if drop and initialize:
151
+ raise ValueError("Cannot drop and initialize schema at the same time")
152
+
153
+ if drop:
154
+ logger.debug("Dropping schema if it exists")
155
+ context.drop()
156
+ initialize = True # If schema is dropped, it needs to be recreated.
157
+
158
+ if initialize:
159
+ logger.debug("Creating schema if not exists")
160
+ context.initialize()
161
+
162
+ context.create_all()
163
+ except Exception as e:
164
+ logger.exception(e)
165
+ raise click.ClickException(str(e))
162
166
 
163
167
 
164
168
  @cli.command("init-tap", help="Initialize TAP_SCHEMA objects in the database")
@@ -372,9 +376,9 @@ def validate(
372
376
  Raises
373
377
  ------
374
378
  click.exceptions.Exit
375
- If any validation errors are found. The ``ValidationError`` which is
376
- thrown when a schema fails to validate will be logged as an error
377
- message.
379
+ Raised if any validation errors are found. The ``ValidationError``
380
+ which is thrown when a schema fails to validate will be logged as an
381
+ error message.
378
382
 
379
383
  Notes
380
384
  -----
@@ -26,7 +26,7 @@ from __future__ import annotations
26
26
  import logging
27
27
  from collections.abc import Mapping, Sequence
28
28
  from enum import StrEnum, auto
29
- from typing import Annotated, Any, Literal, TypeAlias
29
+ from typing import Annotated, Any, TypeAlias
30
30
 
31
31
  from astropy import units as units # type: ignore
32
32
  from astropy.io.votable import ucd # type: ignore
@@ -178,7 +178,7 @@ class Column(BaseObject):
178
178
  tap_principal: int | None = Field(0, alias="tap:principal", ge=0, le=1)
179
179
  """Whether this is a TAP_SCHEMA principal column."""
180
180
 
181
- votable_arraysize: int | Literal["*"] | None = Field(None, alias="votable:arraysize")
181
+ votable_arraysize: int | str | None = Field(None, alias="votable:arraysize")
182
182
  """VOTable arraysize of the column."""
183
183
 
184
184
  tap_std: int | None = Field(0, alias="tap:std", ge=0, le=1)
@@ -253,7 +253,7 @@ class Column(BaseObject):
253
253
  Raises
254
254
  ------
255
255
  ValueError
256
- If both FITS and IVOA units are provided, or if the unit is
256
+ Raised If both FITS and IVOA units are provided, or if the unit is
257
257
  invalid.
258
258
  """
259
259
  fits_unit = self.fits_tunit
@@ -289,7 +289,7 @@ class Column(BaseObject):
289
289
  Raises
290
290
  ------
291
291
  ValueError
292
- If a length is not provided for a sized type.
292
+ Raised if a length is not provided for a sized type.
293
293
  """
294
294
  datatype = values.get("datatype")
295
295
  if datatype is None:
@@ -326,7 +326,7 @@ class Column(BaseObject):
326
326
  Raises
327
327
  ------
328
328
  ValueError
329
- If a datatype override is redundant.
329
+ Raised if a datatype override is redundant.
330
330
  """
331
331
  context = info.context
332
332
  if not context or not context.get("check_redundant_datatypes", False):
@@ -445,8 +445,8 @@ class Index(BaseObject):
445
445
  Raises
446
446
  ------
447
447
  ValueError
448
- If both columns and expressions are specified, or if neither are
449
- specified.
448
+ Raised if both columns and expressions are specified, or if neither
449
+ are specified.
450
450
  """
451
451
  if "columns" in values and "expressions" in values:
452
452
  raise ValueError("Defining columns and expressions is not valid")
@@ -547,7 +547,7 @@ class Table(BaseObject):
547
547
  Raises
548
548
  ------
549
549
  ValueError
550
- If column names are not unique.
550
+ Raised if column names are not unique.
551
551
  """
552
552
  if len(columns) != len(set(column.name for column in columns)):
553
553
  raise ValueError("Column names must be unique")
@@ -570,7 +570,7 @@ class Table(BaseObject):
570
570
  Raises
571
571
  ------
572
572
  ValueError
573
- If the table is missing a TAP table index.
573
+ Raised If the table is missing a TAP table index.
574
574
  """
575
575
  context = info.context
576
576
  if not context or not context.get("check_tap_table_indexes", False):
@@ -597,7 +597,7 @@ class Table(BaseObject):
597
597
  Raises
598
598
  ------
599
599
  ValueError
600
- If the table is missing a column flagged as 'principal'.
600
+ Raised if the table is missing a column flagged as 'principal'.
601
601
  """
602
602
  context = info.context
603
603
  if not context or not context.get("check_tap_principal", False):
@@ -711,7 +711,7 @@ class SchemaIdVisitor:
711
711
  class Schema(BaseObject):
712
712
  """Database schema model.
713
713
 
714
- This is the root object of the Felis data model.
714
+ This represents a database schema, which contains one or more tables.
715
715
  """
716
716
 
717
717
  version: SchemaVersion | str | None = None
@@ -741,7 +741,7 @@ class Schema(BaseObject):
741
741
  Raises
742
742
  ------
743
743
  ValueError
744
- If table names are not unique.
744
+ Raised if table names are not unique.
745
745
  """
746
746
  if len(tables) != len(set(table.name for table in tables)):
747
747
  raise ValueError("Table names must be unique")
@@ -779,7 +779,7 @@ class Schema(BaseObject):
779
779
  Raises
780
780
  ------
781
781
  ValueError
782
- If duplicate IDs are found in the schema.
782
+ Raised if duplicate identifiers are found in the schema.
783
783
 
784
784
  Notes
785
785
  -----
@@ -826,7 +826,7 @@ class Schema(BaseObject):
826
826
  Raises
827
827
  ------
828
828
  KeyError
829
- If the object with the given ID is not found in the schema.
829
+ Raised if the object with the given ID is not found in the schema.
830
830
  """
831
831
  if id not in self:
832
832
  raise KeyError(f"Object with ID '{id}' not found in schema")
@@ -109,7 +109,7 @@ def get_dialect_module(dialect_name: str) -> ModuleType:
109
109
  Raises
110
110
  ------
111
111
  ValueError
112
- If the dialect name is not supported.
112
+ Raised if the dialect name is not supported.
113
113
  """
114
114
  if dialect_name not in _DIALECT_MODULES:
115
115
  raise ValueError(f"Unsupported dialect: {dialect_name}")
@@ -383,7 +383,7 @@ def get_type_func(type_name: str) -> Callable:
383
383
  Raises
384
384
  ------
385
385
  ValueError
386
- If the type name is not recognized.
386
+ Raised if the type name is not recognized.
387
387
 
388
388
  Notes
389
389
  -----
@@ -70,7 +70,7 @@ def string_to_typeengine(
70
70
  Raises
71
71
  ------
72
72
  ValueError
73
- If the type string is invalid or the type is not supported.
73
+ Raised if the type string is invalid or the type is not supported.
74
74
 
75
75
  Notes
76
76
  -----
@@ -220,15 +220,15 @@ class DatabaseContext:
220
220
  self.metadata = metadata
221
221
  self.conn = ConnectionWrapper(engine)
222
222
 
223
- def create_if_not_exists(self) -> None:
223
+ def initialize(self) -> None:
224
224
  """Create the schema in the database if it does not exist.
225
225
 
226
226
  Raises
227
227
  ------
228
228
  ValueError
229
- If the database is not supported.
229
+ Raised if the database is not supported or it already exists.
230
230
  sqlalchemy.exc.SQLAlchemyError
231
- If there is an error creating the schema.
231
+ Raised if there is an error creating the schema.
232
232
 
233
233
  Notes
234
234
  -----
@@ -239,24 +239,45 @@ class DatabaseContext:
239
239
  schema_name = self.metadata.schema
240
240
  try:
241
241
  if self.dialect_name == "mysql":
242
+ logger.debug(f"Checking if MySQL database exists: {schema_name}")
243
+ result = self.conn.execute(text(f"SHOW DATABASES LIKE '{schema_name}'"))
244
+ if result.fetchone():
245
+ raise ValueError(f"MySQL database '{schema_name}' already exists.")
242
246
  logger.debug(f"Creating MySQL database: {schema_name}")
243
- self.conn.execute(text(f"CREATE DATABASE IF NOT EXISTS {schema_name}"))
247
+ self.conn.execute(text(f"CREATE DATABASE {schema_name}"))
244
248
  elif self.dialect_name == "postgresql":
249
+ logger.debug(f"Checking if PG schema exists: {schema_name}")
250
+ result = self.conn.execute(
251
+ text(
252
+ f"""
253
+ SELECT schema_name
254
+ FROM information_schema.schemata
255
+ WHERE schema_name = '{schema_name}'
256
+ """
257
+ )
258
+ )
259
+ if result.fetchone():
260
+ raise ValueError(f"PostgreSQL schema '{schema_name}' already exists.")
245
261
  logger.debug(f"Creating PG schema: {schema_name}")
246
- self.conn.execute(CreateSchema(schema_name, if_not_exists=True))
262
+ self.conn.execute(CreateSchema(schema_name))
263
+ elif self.dialect_name == "sqlite":
264
+ # Just silently ignore this operation for SQLite. The database
265
+ # will still be created if it does not exist and the engine
266
+ # URL is valid.
267
+ pass
247
268
  else:
248
- raise ValueError("Unsupported database type:" + self.dialect_name)
269
+ raise ValueError(f"Initialization not supported for: {self.dialect_name}")
249
270
  except SQLAlchemyError as e:
250
271
  logger.error(f"Error creating schema: {e}")
251
272
  raise
252
273
 
253
- def drop_if_exists(self) -> None:
274
+ def drop(self) -> None:
254
275
  """Drop the schema in the database if it exists.
255
276
 
256
277
  Raises
257
278
  ------
258
279
  ValueError
259
- If the database is not supported.
280
+ Raised if the database is not supported.
260
281
 
261
282
  Notes
262
283
  -----
@@ -271,8 +292,12 @@ class DatabaseContext:
271
292
  elif self.dialect_name == "postgresql":
272
293
  logger.debug(f"Dropping PostgreSQL schema if exists: {schema_name}")
273
294
  self.conn.execute(DropSchema(schema_name, if_exists=True, cascade=True))
295
+ elif self.dialect_name == "sqlite":
296
+ if isinstance(self.engine, Engine):
297
+ logger.debug("Dropping tables in SQLite schema")
298
+ self.metadata.drop_all(bind=self.engine)
274
299
  else:
275
- raise ValueError(f"Unsupported database type: {self.dialect_name}")
300
+ raise ValueError(f"Drop operation not supported for: {self.dialect_name}")
276
301
  except SQLAlchemyError as e:
277
302
  logger.error(f"Error dropping schema: {e}")
278
303
  raise
@@ -82,7 +82,7 @@ def _get_column_variant_override(field_name: str) -> str:
82
82
  Raises
83
83
  ------
84
84
  ValueError
85
- If the field name is not found in the column variant overrides.
85
+ Raised if the field name is not found in the column variant overrides.
86
86
  """
87
87
  if field_name not in _COLUMN_VARIANT_OVERRIDES:
88
88
  raise ValueError(f"Field name {field_name} not found in column variant overrides")
@@ -111,7 +111,7 @@ def _process_variant_override(dialect_name: str, variant_override_str: str) -> t
111
111
  Raises
112
112
  ------
113
113
  ValueError
114
- If the type is not found in the dialect.
114
+ Raised if the type is not found in the dialect.
115
115
 
116
116
  Notes
117
117
  -----
@@ -94,8 +94,8 @@ def get_datatype_with_variants(column_obj: datamodel.Column) -> TypeEngine:
94
94
  Raises
95
95
  ------
96
96
  ValueError
97
- If the column has a sized type but no length or if the datatype is
98
- invalid.
97
+ Raised if the column has a sized type but no length or if the datatype
98
+ is invalid.
99
99
  """
100
100
  variant_dict = make_variant_dict(column_obj)
101
101
  felis_type = FelisType.felis_type(column_obj.datatype.value)
@@ -24,6 +24,7 @@
24
24
  from __future__ import annotations
25
25
 
26
26
  import logging
27
+ import re
27
28
  from collections.abc import Iterable, MutableMapping
28
29
  from typing import Any
29
30
 
@@ -125,7 +126,7 @@ def init_tables(
125
126
  arraysize = Column(String(10))
126
127
  xtype = Column(String(SIMPLE_FIELD_LENGTH))
127
128
  # Size is deprecated
128
- # size = Column(Integer(), quote=True)
129
+ size = Column("size", Integer(), quote=True)
129
130
  description = Column(String(TEXT_FIELD_LENGTH))
130
131
  utype = Column(String(SIMPLE_FIELD_LENGTH))
131
132
  unit = Column(String(SIMPLE_FIELD_LENGTH))
@@ -406,12 +407,28 @@ class TapLoadingVisitor:
406
407
  felis_type = FelisType.felis_type(felis_datatype.value)
407
408
  column.datatype = column_obj.votable_datatype or felis_type.votable_name
408
409
 
409
- arraysize = None
410
- if felis_type.is_sized:
411
- arraysize = column_obj.votable_arraysize or column_obj.length or "*"
412
- if felis_type.is_timestamp:
413
- arraysize = column_obj.votable_arraysize or "*"
414
- column.arraysize = arraysize
410
+ column.arraysize = column_obj.votable_arraysize or column_obj.length
411
+ if (felis_type.is_timestamp or column_obj.datatype == "text") and column.arraysize is None:
412
+ column.arraysize = "*"
413
+
414
+ def _is_int(s: str) -> bool:
415
+ try:
416
+ int(s)
417
+ return True
418
+ except ValueError:
419
+ return False
420
+
421
+ # Handle the deprecated size attribute
422
+ arraysize = column_obj.votable_arraysize
423
+ if arraysize is not None and arraysize != "":
424
+ if isinstance(arraysize, int):
425
+ column.size = arraysize
426
+ elif _is_int(arraysize):
427
+ column.size = int(arraysize)
428
+ elif bool(re.match(r"^[0-9]+\*$", arraysize)):
429
+ column.size = int(arraysize.replace("*", ""))
430
+ if column.size is not None:
431
+ logger.debug(f"Set size to {column.size} for {column.column_name} from arraysize {arraysize}")
415
432
 
416
433
  column.xtype = column_obj.votable_xtype
417
434
  column.description = column_obj.description
File without changes
@@ -0,0 +1,134 @@
1
+ """Provides a temporary Postgresql instance for testing."""
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
+ import gc
25
+ import unittest
26
+ from collections.abc import Iterator
27
+ from contextlib import contextmanager
28
+
29
+ from sqlalchemy import text
30
+ from sqlalchemy.engine import Connection, Engine, create_engine
31
+
32
+ try:
33
+ from testing.postgresql import Postgresql # type: ignore
34
+ except ImportError:
35
+ Postgresql = None
36
+
37
+ __all__ = ["TemporaryPostgresInstance", "setup_postgres_test_db"]
38
+
39
+
40
+ class TemporaryPostgresInstance:
41
+ """Wrapper for a temporary Postgres database.
42
+
43
+ Parameters
44
+ ----------
45
+ server
46
+ The ``testing.postgresql.Postgresql`` instance.
47
+ engine
48
+ The SQLAlchemy engine for the temporary database server.
49
+
50
+ Notes
51
+ -----
52
+ This class was copied and modified from
53
+ ``lsst.daf.butler.tests.postgresql``.
54
+ """
55
+
56
+ def __init__(self, server: Postgresql, engine: Engine) -> None:
57
+ """Initialize the temporary Postgres database instance."""
58
+ self._server = server
59
+ self._engine = engine
60
+
61
+ @property
62
+ def url(self) -> str:
63
+ """Return connection URL for the temporary database server.
64
+
65
+ Returns
66
+ -------
67
+ str
68
+ The connection URL.
69
+ """
70
+ return self._server.url()
71
+
72
+ @property
73
+ def engine(self) -> Engine:
74
+ """Return the SQLAlchemy engine for the temporary database server.
75
+
76
+ Returns
77
+ -------
78
+ `~sqlalchemy.engine.Engine`
79
+ The SQLAlchemy engine.
80
+ """
81
+ return self._engine
82
+
83
+ @contextmanager
84
+ def begin(self) -> Iterator[Connection]:
85
+ """Return a SQLAlchemy connection to the test database.
86
+
87
+ Returns
88
+ -------
89
+ `~sqlalchemy.engine.Connection`
90
+ The SQLAlchemy connection.
91
+ """
92
+ with self._engine.begin() as connection:
93
+ yield connection
94
+
95
+ def print_info(self) -> None:
96
+ """Print information about the temporary database server."""
97
+ print("\n\n---- PostgreSQL URL ----")
98
+ print(self.url)
99
+ self._engine = create_engine(self.url)
100
+ with self.begin() as conn:
101
+ print("\n---- PostgreSQL Version ----")
102
+ res = conn.execute(text("SELECT version()")).fetchone()
103
+ if res:
104
+ print(res[0])
105
+ print("\n")
106
+
107
+
108
+ @contextmanager
109
+ def setup_postgres_test_db() -> Iterator[TemporaryPostgresInstance]:
110
+ """Set up a temporary Postgres database instance that can be used for
111
+ testing.
112
+
113
+ Returns
114
+ -------
115
+ TemporaryPostgresInstance
116
+ The temporary Postgres database instance.
117
+
118
+ Raises
119
+ ------
120
+ unittest.SkipTest
121
+ Raised if the ``testing.postgresql`` module is not available.
122
+ """
123
+ if Postgresql is None:
124
+ raise unittest.SkipTest("testing.postgresql module not available.")
125
+
126
+ with Postgresql() as server:
127
+ engine = create_engine(server.url())
128
+ instance = TemporaryPostgresInstance(server, engine)
129
+ yield instance
130
+
131
+ # Clean up any lingering SQLAlchemy engines/connections
132
+ # so they're closed before we shut down the server.
133
+ gc.collect()
134
+ engine.dispose()
@@ -0,0 +1,2 @@
1
+ __all__ = ["__version__"]
2
+ __version__ = "27.2024.3100"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lsst-felis
3
- Version: 27.2024.2900
3
+ Version: 27.2024.3100
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+)
@@ -16,6 +16,8 @@ python/felis/db/dialects.py
16
16
  python/felis/db/sqltypes.py
17
17
  python/felis/db/utils.py
18
18
  python/felis/db/variants.py
19
+ python/felis/tests/__init__.py
20
+ python/felis/tests/postgresql.py
19
21
  python/lsst_felis.egg-info/PKG-INFO
20
22
  python/lsst_felis.egg-info/SOURCES.txt
21
23
  python/lsst_felis.egg-info/dependency_links.txt
@@ -26,4 +28,5 @@ python/lsst_felis.egg-info/zip-safe
26
28
  tests/test_cli.py
27
29
  tests/test_datamodel.py
28
30
  tests/test_metadata.py
31
+ tests/test_postgresql.py
29
32
  tests/test_tap.py
@@ -123,6 +123,16 @@ class CliTestCase(unittest.TestCase):
123
123
  )
124
124
  self.assertEqual(result.exit_code, 0)
125
125
 
126
+ def test_initialize_and_drop(self) -> None:
127
+ """Test that initialize and drop can't be used together."""
128
+ runner = CliRunner()
129
+ result = runner.invoke(
130
+ cli,
131
+ ["create", "--initialize", "--drop", TEST_YAML],
132
+ catch_exceptions=False,
133
+ )
134
+ self.assertTrue(result.exit_code != 0)
135
+
126
136
 
127
137
  if __name__ == "__main__":
128
138
  unittest.main()
@@ -0,0 +1,89 @@
1
+ # This file is part of felis.
2
+ #
3
+ # Developed for the LSST Data Management System.
4
+ # This product includes software developed by the LSST Project
5
+ # (https://www.lsst.org).
6
+ # See the COPYRIGHT file at the top-level directory of this distribution
7
+ # for details of code ownership.
8
+ #
9
+ # This program is free software: you can redistribute it and/or modify
10
+ # it under the terms of the GNU General Public License as published by
11
+ # the Free Software Foundation, either version 3 of the License, or
12
+ # (at your option) any later version.
13
+ #
14
+ # This program is distributed in the hope that it will be useful,
15
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
16
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
+ # GNU General Public License for more details.
18
+ #
19
+ # You should have received a copy of the GNU General Public License
20
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
21
+
22
+ import os
23
+ import unittest
24
+
25
+ import yaml
26
+ from sqlalchemy import text
27
+
28
+ from felis.datamodel import Schema
29
+ from felis.db.utils import DatabaseContext
30
+ from felis.metadata import MetaDataBuilder
31
+ from felis.tests.postgresql import TemporaryPostgresInstance, setup_postgres_test_db # type: ignore
32
+
33
+ TESTDIR = os.path.abspath(os.path.dirname(__file__))
34
+ TEST_YAML = os.path.join(TESTDIR, "data", "sales.yaml")
35
+
36
+
37
+ class TestPostgresql(unittest.TestCase):
38
+ """Test PostgreSQL database setup."""
39
+
40
+ postgresql: TemporaryPostgresInstance
41
+
42
+ @classmethod
43
+ def setUpClass(cls) -> None:
44
+ # Create the postgres test server.
45
+ cls.postgresql = cls.enterClassContext(setup_postgres_test_db())
46
+ super().setUpClass()
47
+
48
+ def test_initialize_create_and_drop(self) -> None:
49
+ """Test database initialization, creation, and deletion in
50
+ PostgreSQL.
51
+ """
52
+ # Create the schema and metadata
53
+ yaml_data = yaml.safe_load(open(TEST_YAML))
54
+ schema = Schema.model_validate(yaml_data)
55
+ md = MetaDataBuilder(schema).build()
56
+
57
+ # Initialize the database
58
+ ctx = DatabaseContext(md, self.postgresql.engine)
59
+ ctx.initialize()
60
+ ctx.create_all()
61
+
62
+ # Get the names of the tables without the schema prepended
63
+ table_names = [name.split(".")[-1] for name in md.tables.keys()]
64
+
65
+ # Check that the tables and columns are created
66
+ with self.postgresql.begin() as conn:
67
+ res = conn.execute(text("SELECT table_name FROM information_schema.tables"))
68
+ tables = [row[0] for row in res.fetchall()]
69
+ for table_name in table_names:
70
+ self.assertIn(table_name, tables)
71
+ # Check that all columns are created
72
+ expected_columns = [col.name for col in md.tables[f"sales.{table_name}"].columns]
73
+ res = conn.execute(
74
+ text("SELECT column_name FROM information_schema.columns WHERE table_name = :table_name"),
75
+ {"table_name": table_name},
76
+ )
77
+ actual_columns = [row[0] for row in res.fetchall()]
78
+ self.assertSetEqual(set(expected_columns), set(actual_columns))
79
+
80
+ # Drop the schema
81
+ ctx.drop()
82
+
83
+ # Check that the "sales" schema was dropped
84
+ with self.postgresql.begin() as conn:
85
+ res = conn.execute(
86
+ text("SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'sales'")
87
+ )
88
+ schemas = [row[0] for row in res.fetchall()]
89
+ self.assertNotIn("sales", schemas)
@@ -23,8 +23,6 @@ import os
23
23
  import shutil
24
24
  import tempfile
25
25
  import unittest
26
- from collections.abc import MutableMapping
27
- from typing import Any
28
26
 
29
27
  import sqlalchemy
30
28
  import yaml
@@ -39,7 +37,7 @@ TEST_YAML = os.path.join(TESTDIR, "data", "test.yml")
39
37
  class VisitorTestCase(unittest.TestCase):
40
38
  """Test the TAP loading visitor."""
41
39
 
42
- schema_obj: MutableMapping[str, Any] = {}
40
+ schema_obj: Schema
43
41
 
44
42
  def setUp(self) -> None:
45
43
  """Load data from a test file."""
@@ -1,2 +0,0 @@
1
- __all__ = ["__version__"]
2
- __version__ = "27.2024.2900"