lsst-felis 27.2024.2500__py3-none-any.whl → 27.2024.2600__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/db/utils.py CHANGED
@@ -1,3 +1,5 @@
1
+ """Database utility functions and classes."""
2
+
1
3
  # This file is part of felis.
2
4
  #
3
5
  # Developed for the LSST Data Management System.
@@ -36,17 +38,44 @@ from sqlalchemy.types import TypeEngine
36
38
 
37
39
  from .dialects import get_dialect_module
38
40
 
41
+ __all__ = ["string_to_typeengine", "SQLWriter", "ConnectionWrapper", "DatabaseContext"]
42
+
39
43
  logger = logging.getLogger("felis")
40
44
 
41
45
  _DATATYPE_REGEXP = re.compile(r"(\w+)(\((.*)\))?")
42
- """Regular expression to match data types in the form "type(length)"""
46
+ """Regular expression to match data types with parameters in parentheses."""
43
47
 
44
48
 
45
49
  def string_to_typeengine(
46
50
  type_string: str, dialect: Dialect | None = None, length: int | None = None
47
51
  ) -> TypeEngine:
48
- """Convert a string representation of a data type to a SQLAlchemy
49
- TypeEngine.
52
+ """Convert a string representation of a datatype to a SQLAlchemy type.
53
+
54
+ Parameters
55
+ ----------
56
+ type_string
57
+ The string representation of the data type.
58
+ dialect
59
+ The SQLAlchemy dialect to use. If None, the default dialect will be
60
+ used.
61
+ length
62
+ The length of the data type. If the data type does not have a length
63
+ attribute, this parameter will be ignored.
64
+
65
+ Returns
66
+ -------
67
+ `sqlalchemy.types.TypeEngine`
68
+ The SQLAlchemy type engine object.
69
+
70
+ Raises
71
+ ------
72
+ ValueError
73
+ If the type string is invalid or the type is not supported.
74
+
75
+ Notes
76
+ -----
77
+ This function is used when converting type override strings defined in
78
+ fields such as ``mysql:datatype`` in the schema data.
50
79
  """
51
80
  match = _DATATYPE_REGEXP.search(type_string)
52
81
  if not match:
@@ -78,17 +107,17 @@ def string_to_typeengine(
78
107
 
79
108
 
80
109
  class SQLWriter:
81
- """Writes SQL statements to stdout or a file."""
110
+ """Write SQL statements to stdout or a file.
82
111
 
83
- def __init__(self, file: IO[str] | None = None) -> None:
84
- """Initialize the SQL writer.
112
+ Parameters
113
+ ----------
114
+ file
115
+ The file to write the SQL statements to. If None, the statements
116
+ will be written to stdout.
117
+ """
85
118
 
86
- Parameters
87
- ----------
88
- file : `io.TextIOBase` or `None`, optional
89
- The file to write the SQL statements to. If None, the statements
90
- will be written to stdout.
91
- """
119
+ def __init__(self, file: IO[str] | None = None) -> None:
120
+ """Initialize the SQL writer."""
92
121
  self.file = file
93
122
  self.dialect: Dialect | None = None
94
123
 
@@ -100,12 +129,17 @@ class SQLWriter:
100
129
 
101
130
  Parameters
102
131
  ----------
103
- sql : `typing.Any`
132
+ sql
104
133
  The SQL statement to write.
105
- multiparams : `typing.Any`
134
+ *multiparams
106
135
  The multiparams to use for the SQL statement.
107
- params : `typing.Any`
136
+ **params
108
137
  The params to use for the SQL statement.
138
+
139
+ Notes
140
+ -----
141
+ The functions arguments are typed very loosely because this method in
142
+ SQLAlchemy is untyped, amd we do not call it directly.
109
143
  """
110
144
  compiled = sql.compile(dialect=self.dialect)
111
145
  sql_str = str(compiled) + ";"
@@ -126,22 +160,37 @@ class SQLWriter:
126
160
 
127
161
 
128
162
  class ConnectionWrapper:
129
- """A wrapper for a SQLAlchemy engine or mock connection which provides a
130
- consistent interface for executing SQL statements.
163
+ """Wrap a SQLAlchemy engine or mock connection to provide a consistent
164
+ interface for executing SQL statements.
165
+
166
+ Parameters
167
+ ----------
168
+ engine
169
+ The SQLAlchemy engine or mock connection to wrap.
131
170
  """
132
171
 
133
172
  def __init__(self, engine: Engine | MockConnection):
134
- """Initialize the connection wrapper.
173
+ """Initialize the connection wrapper."""
174
+ self.engine = engine
175
+
176
+ def execute(self, statement: Any) -> ResultProxy:
177
+ """Execute a SQL statement on the engine and return the result.
135
178
 
136
179
  Parameters
137
180
  ----------
138
- engine : `sqlalchemy.Engine` or `sqlalchemy.MockConnection`
139
- The SQLAlchemy engine or mock connection to wrap.
181
+ statement
182
+ The SQL statement to execute.
183
+
184
+ Returns
185
+ -------
186
+ ``sqlalchemy.engine.ResultProxy``
187
+ The result of the statement execution.
188
+
189
+ Notes
190
+ -----
191
+ The statement will be executed in a transaction block if not using
192
+ a mock connection.
140
193
  """
141
- self.engine = engine
142
-
143
- def execute(self, statement: Any) -> ResultProxy:
144
- """Execute a SQL statement on the engine and return the result."""
145
194
  if isinstance(statement, str):
146
195
  statement = text(statement)
147
196
  if isinstance(self.engine, MockConnection):
@@ -153,19 +202,19 @@ class ConnectionWrapper:
153
202
 
154
203
 
155
204
  class DatabaseContext:
156
- """A class for managing the schema and its database connection."""
205
+ """Manage the database connection and SQLAlchemy metadata.
157
206
 
158
- def __init__(self, metadata: MetaData, engine: Engine | MockConnection):
159
- """Initialize the database context.
207
+ Parameters
208
+ ----------
209
+ metadata
210
+ The SQLAlchemy metadata object.
160
211
 
161
- Parameters
162
- ----------
163
- metadata : `sqlalchemy.MetaData`
164
- The SQLAlchemy metadata object.
212
+ engine
213
+ The SQLAlchemy engine or mock connection object.
214
+ """
165
215
 
166
- engine : `sqlalchemy.Engine` or `sqlalchemy.MockConnection`
167
- The SQLAlchemy engine or mock connection object.
168
- """
216
+ def __init__(self, metadata: MetaData, engine: Engine | MockConnection):
217
+ """Initialize the database context."""
169
218
  self.engine = engine
170
219
  self.dialect_name = engine.dialect.name
171
220
  self.metadata = metadata
@@ -174,16 +223,18 @@ class DatabaseContext:
174
223
  def create_if_not_exists(self) -> None:
175
224
  """Create the schema in the database if it does not exist.
176
225
 
177
- In MySQL, this will create a new database. In PostgreSQL, it will
226
+ Raises
227
+ ------
228
+ ValueError
229
+ If the database is not supported.
230
+ sqlalchemy.exc.SQLAlchemyError
231
+ If there is an error creating the schema.
232
+
233
+ Notes
234
+ -----
235
+ In MySQL, this will create a new database and, in PostgreSQL, it will
178
236
  create a new schema. For other variants, this is an unsupported
179
237
  operation.
180
-
181
- Parameters
182
- ----------
183
- engine: `sqlalchemy.Engine`
184
- The SQLAlchemy engine object.
185
- schema_name: `str`
186
- The name of the schema (or database) to create.
187
238
  """
188
239
  schema_name = self.metadata.schema
189
240
  try:
@@ -202,15 +253,15 @@ class DatabaseContext:
202
253
  def drop_if_exists(self) -> None:
203
254
  """Drop the schema in the database if it exists.
204
255
 
205
- In MySQL, this will drop a database. In PostgreSQL, it will drop a
206
- schema. For other variants, this is unsupported for now.
256
+ Raises
257
+ ------
258
+ ValueError
259
+ If the database is not supported.
207
260
 
208
- Parameters
209
- ----------
210
- engine: `sqlalchemy.Engine`
211
- The SQLAlchemy engine object.
212
- schema_name: `str`
213
- The name of the schema (or database) to drop.
261
+ Notes
262
+ -----
263
+ In MySQL, this will drop a database. In PostgreSQL, it will drop a
264
+ schema. For other variants, this is an unsupported operation.
214
265
  """
215
266
  schema_name = self.metadata.schema
216
267
  try:
@@ -236,11 +287,16 @@ class DatabaseContext:
236
287
 
237
288
  Parameters
238
289
  ----------
239
- engine_url : `sqlalchemy.engine.url.URL`
290
+ engine_url
240
291
  The SQLAlchemy engine URL.
241
- output_file : `typing.IO` [ `str` ] or `None`, optional
292
+ output_file
242
293
  The file to write the SQL statements to. If None, the statements
243
294
  will be written to stdout.
295
+
296
+ Returns
297
+ -------
298
+ ``sqlalchemy.engine.mock.MockConnection``
299
+ The mock connection object.
244
300
  """
245
301
  writer = SQLWriter(output_file)
246
302
  engine = create_mock_engine(engine_url, executor=writer.write)
felis/db/variants.py CHANGED
@@ -1,3 +1,5 @@
1
+ """Handle variant overrides for a Felis column."""
2
+
1
3
  # This file is part of felis.
2
4
  #
3
5
  # Developed for the LSST Data Management System.
@@ -19,7 +21,11 @@
19
21
  # You should have received a copy of the GNU General Public License
20
22
  # along with this program. If not, see <https://www.gnu.org/licenses/>.
21
23
 
24
+ from __future__ import annotations
25
+
22
26
  import re
27
+ from collections.abc import Mapping
28
+ from types import MappingProxyType
23
29
  from typing import Any
24
30
 
25
31
  from sqlalchemy import types
@@ -28,26 +34,55 @@ from sqlalchemy.types import TypeEngine
28
34
  from ..datamodel import Column
29
35
  from .dialects import get_dialect_module, get_supported_dialects
30
36
 
37
+ __all__ = ["make_variant_dict"]
38
+
31
39
 
32
40
  def _create_column_variant_overrides() -> dict[str, str]:
33
- """Create a dictionary of column variant overrides."""
41
+ """Map column variant overrides to their dialect name.
42
+
43
+ Returns
44
+ -------
45
+ column_variant_overrides : `dict` [ `str`, `str` ]
46
+ A mapping of column variant overrides to their dialect name.
47
+
48
+ Notes
49
+ -----
50
+ This function is intended for internal use only.
51
+ """
34
52
  column_variant_overrides = {}
35
53
  for dialect_name in get_supported_dialects().keys():
36
54
  column_variant_overrides[f"{dialect_name}_datatype"] = dialect_name
37
55
  return column_variant_overrides
38
56
 
39
57
 
40
- _COLUMN_VARIANT_OVERRIDES = _create_column_variant_overrides()
58
+ _COLUMN_VARIANT_OVERRIDES = MappingProxyType(_create_column_variant_overrides())
59
+ """Map of column variant overrides to their dialect name."""
60
+
41
61
 
62
+ def _get_column_variant_overrides() -> Mapping[str, str]:
63
+ """Get a dictionary of column variant overrides.
42
64
 
43
- def _get_column_variant_overrides() -> dict[str, str]:
44
- """Return a dictionary of column variant overrides."""
65
+ Returns
66
+ -------
67
+ column_variant_overrides : `dict` [ `str`, `str` ]
68
+ A mapping of column variant overrides to their dialect name.
69
+ """
45
70
  return _COLUMN_VARIANT_OVERRIDES
46
71
 
47
72
 
48
73
  def _get_column_variant_override(field_name: str) -> str:
49
- """Return the dialect name from an override field name on the column like
74
+ """Get the dialect name from an override field name on the column like
50
75
  ``mysql_datatype``.
76
+
77
+ Returns
78
+ -------
79
+ dialect_name : `str`
80
+ The name of the dialect.
81
+
82
+ Raises
83
+ ------
84
+ ValueError
85
+ If the field name is not found in the column variant overrides.
51
86
  """
52
87
  if field_name not in _COLUMN_VARIANT_OVERRIDES:
53
88
  raise ValueError(f"Field name {field_name} not found in column variant overrides")
@@ -59,7 +94,30 @@ _length_regex = re.compile(r"\((\d+)\)")
59
94
 
60
95
 
61
96
  def _process_variant_override(dialect_name: str, variant_override_str: str) -> types.TypeEngine:
62
- """Return variant type for given dialect."""
97
+ """Get the variant type for the given dialect.
98
+
99
+ Parameters
100
+ ----------
101
+ dialect_name
102
+ The name of the dialect to create.
103
+ variant_override_str
104
+ The string representation of the variant override.
105
+
106
+ Returns
107
+ -------
108
+ variant_type : `~sqlalchemy.types.TypeEngine`
109
+ The variant type for the given dialect.
110
+
111
+ Raises
112
+ ------
113
+ ValueError
114
+ If the type is not found in the dialect.
115
+
116
+ Notes
117
+ -----
118
+ This function converts a string representation of a variant override
119
+ into a `sqlalchemy.types.TypeEngine` object.
120
+ """
63
121
  dialect = get_dialect_module(dialect_name)
64
122
  variant_type_name = variant_override_str.split("(")[0]
65
123
 
@@ -82,12 +140,12 @@ def make_variant_dict(column_obj: Column) -> dict[str, TypeEngine[Any]]:
82
140
 
83
141
  Parameters
84
142
  ----------
85
- column_obj : `felis.datamodel.Column`
143
+ column_obj
86
144
  The column object from which to build the variant dictionary.
87
145
 
88
146
  Returns
89
147
  -------
90
- variant_dict : `dict`
148
+ `dict` [ `str`, `~sqlalchemy.types.TypeEngine` ]
91
149
  The dictionary of `str` to `sqlalchemy.types.TypeEngine` containing
92
150
  variant datatype information (e.g., for mysql, postgresql, etc).
93
151
  """
felis/metadata.py CHANGED
@@ -1,3 +1,5 @@
1
+ """Build SQLAlchemy metadata from a Felis schema."""
2
+
1
3
  # This file is part of felis.
2
4
  #
3
5
  # Developed for the LSST Data Management System.
@@ -47,18 +49,25 @@ from . import datamodel
47
49
  from .db import sqltypes
48
50
  from .types import FelisType
49
51
 
52
+ __all__ = ("MetaDataBuilder", "get_datatype_with_variants")
53
+
50
54
  logger = logging.getLogger(__name__)
51
55
 
52
56
 
53
57
  def get_datatype_with_variants(column_obj: datamodel.Column) -> TypeEngine:
54
58
  """Use the Felis type system to get a SQLAlchemy datatype with variant
55
- overrides from the information in a `Column` object.
59
+ overrides from the information in a Felis column object.
56
60
 
57
61
  Parameters
58
62
  ----------
59
- column_obj : `felis.datamodel.Column`
63
+ column_obj
60
64
  The column object from which to get the datatype.
61
65
 
66
+ Returns
67
+ -------
68
+ `~sqlalchemy.types.TypeEngine`
69
+ The SQLAlchemy datatype object.
70
+
62
71
  Raises
63
72
  ------
64
73
  ValueError
@@ -80,22 +89,22 @@ _VALID_SERVER_DEFAULTS = ("CURRENT_TIMESTAMP", "NOW()", "LOCALTIMESTAMP", "NULL"
80
89
 
81
90
 
82
91
  class MetaDataBuilder:
83
- """A class for building a `MetaData` object from a Felis `Schema`."""
92
+ """Build a SQLAlchemy metadata object from a Felis schema.
93
+
94
+ Parameters
95
+ ----------
96
+ schema
97
+ The schema object from which to build the SQLAlchemy metadata.
98
+ apply_schema_to_metadata
99
+ Whether to apply the schema name to the metadata object.
100
+ apply_schema_to_tables
101
+ Whether to apply the schema name to the tables.
102
+ """
84
103
 
85
104
  def __init__(
86
105
  self, schema: Schema, apply_schema_to_metadata: bool = True, apply_schema_to_tables: bool = True
87
106
  ) -> None:
88
- """Initialize the metadata builder.
89
-
90
- Parameters
91
- ----------
92
- schema : `felis.datamodel.Schema`
93
- The schema object from which to build the SQLAlchemy metadata.
94
- apply_schema_to_metadata : `bool`, optional
95
- Whether to apply the schema name to the metadata object.
96
- apply_schema_to_tables : `bool`, optional
97
- Whether to apply the schema name to the tables.
98
- """
107
+ """Initialize the metadata builder."""
99
108
  self.schema = schema
100
109
  if not apply_schema_to_metadata:
101
110
  logger.debug("Schema name will not be applied to metadata")
@@ -106,20 +115,25 @@ class MetaDataBuilder:
106
115
  self.apply_schema_to_tables = apply_schema_to_tables
107
116
 
108
117
  def build(self) -> MetaData:
109
- """Build the SQLAlchemy tables and constraints from the schema."""
118
+ """Build the SQLAlchemy tables and constraints from the schema.
119
+
120
+ Notes
121
+ -----
122
+ This first builds the tables and then makes a second pass to build the
123
+ constraints. This is necessary because the constraints may reference
124
+ objects that are not yet created when the tables are built.
125
+
126
+ Returns
127
+ -------
128
+ `~sqlalchemy.sql.schema.MetaData`
129
+ The SQLAlchemy metadata object.
130
+ """
110
131
  self.build_tables()
111
132
  self.build_constraints()
112
133
  return self.metadata
113
134
 
114
135
  def build_tables(self) -> None:
115
- """Build the SQLAlchemy tables from the schema.
116
-
117
- Notes
118
- -----
119
- This function builds all the tables by calling ``build_table`` on
120
- each Pydantic object. It also calls ``build_primary_key`` to create the
121
- primary key constraints.
122
- """
136
+ """Build the SQLAlchemy tables from the schema."""
123
137
  for table in self.schema.tables:
124
138
  self.build_table(table)
125
139
  if table.primary_key:
@@ -127,40 +141,44 @@ class MetaDataBuilder:
127
141
  self._objects[table.id].append_constraint(primary_key)
128
142
 
129
143
  def build_primary_key(self, primary_key_columns: str | list[str]) -> PrimaryKeyConstraint:
130
- """Build a SQLAlchemy `PrimaryKeyConstraint` from a single column ID
131
- or a list.
132
-
133
- The `primary_key_columns` are strings or a list of strings representing
134
- IDs pointing to columns that will be looked up in the internal object
135
- dictionary.
144
+ """Build a SQAlchemy ``PrimaryKeyConstraint`` from a single column ID
145
+ or a list of them.
136
146
 
137
147
  Parameters
138
148
  ----------
139
- primary_key_columns : `str` or `list` of `str`
149
+ primary_key_columns
140
150
  The column ID or list of column IDs from which to build the primary
141
151
  key.
142
152
 
143
153
  Returns
144
154
  -------
145
- primary_key: `sqlalchemy.PrimaryKeyConstraint`
155
+ `~sqlalchemy.sql.schema.PrimaryKeyConstraint`
146
156
  The SQLAlchemy primary key constraint object.
157
+
158
+ Notes
159
+ -----
160
+ The ``primary_key_columns`` is a string or a list of strings
161
+ representing IDs which will be used to find the columnn objects in the
162
+ builder's internal ID map.
147
163
  """
148
164
  return PrimaryKeyConstraint(
149
165
  *[self._objects[column_id] for column_id in ensure_iterable(primary_key_columns)]
150
166
  )
151
167
 
152
168
  def build_table(self, table_obj: datamodel.Table) -> None:
153
- """Build a `sqlalchemy.Table` from a `felis.datamodel.Table` and add
154
- it to the `sqlalchemy.MetaData` object.
155
-
156
- Several MySQL table options are handled by annotations on the table,
157
- including the engine and charset. This is not needed for Postgres,
158
- which does not have equivalent options.
169
+ """Build a SQLAlchemy ``Table`` from a Felis table and add it to the
170
+ metadata.
159
171
 
160
172
  Parameters
161
173
  ----------
162
- table_obj : `felis.datamodel.Table`
163
- The table object to build the SQLAlchemy table from.
174
+ table_obj
175
+ The Felis table object from which to build the SQLAlchemy table.
176
+
177
+ Notes
178
+ -----
179
+ Several MySQL table options, including the engine and charset, are
180
+ handled by adding annotations to the table. This is not needed for
181
+ Postgres, as Felis does not support any table options for this dialect.
164
182
  """
165
183
  # Process mysql table options.
166
184
  optargs = {}
@@ -192,16 +210,16 @@ class MetaDataBuilder:
192
210
  self._objects[id] = table
193
211
 
194
212
  def build_column(self, column_obj: datamodel.Column) -> Column:
195
- """Build a SQLAlchemy column from a `felis.datamodel.Column` object.
213
+ """Build a SQLAlchemy ``Column`` from a Felis column object.
196
214
 
197
215
  Parameters
198
216
  ----------
199
- column_obj : `felis.datamodel.Column`
217
+ column_obj
200
218
  The column object from which to build the SQLAlchemy column.
201
219
 
202
220
  Returns
203
221
  -------
204
- column: `sqlalchemy.Column`
222
+ `~sqlalchemy.sql.schema.Column`
205
223
  The SQLAlchemy column object.
206
224
  """
207
225
  # Get basic column attributes.
@@ -244,8 +262,8 @@ class MetaDataBuilder:
244
262
  return column
245
263
 
246
264
  def build_constraints(self) -> None:
247
- """Build the SQLAlchemy constraints in the Felis schema and append them
248
- to the associated `Table`.
265
+ """Build the SQLAlchemy constraints from the Felis schema and append
266
+ them to the associated table in the metadata.
249
267
 
250
268
  Notes
251
269
  -----
@@ -260,18 +278,16 @@ class MetaDataBuilder:
260
278
  table.append_constraint(constraint)
261
279
 
262
280
  def build_constraint(self, constraint_obj: datamodel.Constraint) -> Constraint:
263
- """Build a SQLAlchemy `Constraint` from a `felis.datamodel.Constraint`
264
- object.
281
+ """Build a SQLAlchemy ``Constraint`` from a Felis constraint.
265
282
 
266
283
  Parameters
267
284
  ----------
268
- constraint_obj : `felis.datamodel.Constraint`
269
- The constraint object from which to build the SQLAlchemy
270
- constraint.
285
+ constraint_obj
286
+ The Felis object from which to build the constraint.
271
287
 
272
288
  Returns
273
289
  -------
274
- constraint: `sqlalchemy.Constraint`
290
+ `~sqlalchemy.sql.schema.Constraint`
275
291
  The SQLAlchemy constraint object.
276
292
 
277
293
  Raises
@@ -311,16 +327,16 @@ class MetaDataBuilder:
311
327
  return constraint
312
328
 
313
329
  def build_index(self, index_obj: datamodel.Index) -> Index:
314
- """Build a SQLAlchemy `Index` from a `felis.datamodel.Index` object.
330
+ """Build a SQLAlchemy ``Index`` from a Felis `~felis.datamodel.Index`.
315
331
 
316
332
  Parameters
317
333
  ----------
318
- index_obj : `felis.datamodel.Index`
319
- The index object from which to build the SQLAlchemy index.
334
+ index_obj
335
+ The Felis object from which to build the SQLAlchemy index.
320
336
 
321
337
  Returns
322
338
  -------
323
- index: `sqlalchemy.Index`
339
+ `~sqlalchemy.sql.schema.Index`
324
340
  The SQLAlchemy index object.
325
341
  """
326
342
  columns = [self._objects[c_id] for c_id in (index_obj.columns if index_obj.columns else [])]