lsst-felis 28.2024.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 +9 -1
- felis/cli.py +308 -209
- felis/config/tap_schema/columns.csv +33 -0
- felis/config/tap_schema/key_columns.csv +8 -0
- felis/config/tap_schema/keys.csv +8 -0
- felis/config/tap_schema/schemas.csv +2 -0
- felis/config/tap_schema/tables.csv +6 -0
- felis/config/tap_schema/tap_schema_extensions.yaml +73 -0
- felis/datamodel.py +599 -59
- felis/db/{dialects.py → _dialects.py} +69 -4
- felis/db/{variants.py → _variants.py} +1 -1
- felis/db/database_context.py +917 -0
- felis/diff.py +234 -0
- felis/metadata.py +89 -19
- felis/tap_schema.py +271 -166
- felis/tests/postgresql.py +1 -1
- felis/tests/run_cli.py +79 -0
- felis/types.py +7 -7
- {lsst_felis-28.2024.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/METADATA +20 -16
- lsst_felis-30.0.0rc3.dist-info/RECORD +31 -0
- {lsst_felis-28.2024.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/WHEEL +1 -1
- felis/db/utils.py +0 -409
- felis/tap.py +0 -597
- felis/tests/utils.py +0 -122
- felis/version.py +0 -2
- lsst_felis-28.2024.4500.dist-info/RECORD +0 -26
- felis/{schemas → config/tap_schema}/tap_schema_std.yaml +0 -0
- felis/db/{sqltypes.py → _sqltypes.py} +7 -7
- {lsst_felis-28.2024.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/entry_points.txt +0 -0
- {lsst_felis-28.2024.4500.dist-info → lsst_felis-30.0.0rc3.dist-info/licenses}/COPYRIGHT +0 -0
- {lsst_felis-28.2024.4500.dist-info → lsst_felis-30.0.0rc3.dist-info/licenses}/LICENSE +0 -0
- {lsst_felis-28.2024.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/top_level.txt +0 -0
- {lsst_felis-28.2024.4500.dist-info → lsst_felis-30.0.0rc3.dist-info}/zip-safe +0 -0
felis/tests/run_cli.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Test utility for running cli commands."""
|
|
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 logging
|
|
25
|
+
|
|
26
|
+
from click.testing import CliRunner
|
|
27
|
+
|
|
28
|
+
from felis.cli import cli
|
|
29
|
+
|
|
30
|
+
__all__ = ["run_cli"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def run_cli(
|
|
34
|
+
cmd: list[str],
|
|
35
|
+
log_level: int = logging.WARNING,
|
|
36
|
+
catch_exceptions: bool = False,
|
|
37
|
+
expect_error: bool = False,
|
|
38
|
+
print_cmd: bool = False,
|
|
39
|
+
print_output: bool = False,
|
|
40
|
+
id_generation: bool = False,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Run a CLI command and check the exit code.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
cmd : list[str]
|
|
47
|
+
The command to run.
|
|
48
|
+
log_level : int
|
|
49
|
+
The logging level to use, by default logging.WARNING.
|
|
50
|
+
catch_exceptions : bool
|
|
51
|
+
Whether to catch exceptions, by default False.
|
|
52
|
+
expect_error : bool
|
|
53
|
+
Whether to expect an error, by default False.
|
|
54
|
+
print_cmd : bool
|
|
55
|
+
Whether to print the command, by default False.
|
|
56
|
+
print_output : bool
|
|
57
|
+
Whether to print the output, by default False.
|
|
58
|
+
id_generation : bool
|
|
59
|
+
Whether to enable id generation, by default False.
|
|
60
|
+
"""
|
|
61
|
+
if not cmd:
|
|
62
|
+
raise ValueError("No command provided.")
|
|
63
|
+
full_cmd = ["--log-level", logging.getLevelName(log_level)] + cmd
|
|
64
|
+
if id_generation:
|
|
65
|
+
full_cmd = ["--id-generation"] + full_cmd
|
|
66
|
+
if print_cmd:
|
|
67
|
+
print(f"Running command: felis {' '.join(full_cmd)}")
|
|
68
|
+
runner = CliRunner()
|
|
69
|
+
result = runner.invoke(
|
|
70
|
+
cli,
|
|
71
|
+
full_cmd,
|
|
72
|
+
catch_exceptions=catch_exceptions,
|
|
73
|
+
)
|
|
74
|
+
if print_output:
|
|
75
|
+
print(result.output)
|
|
76
|
+
if expect_error:
|
|
77
|
+
assert result.exit_code != 0
|
|
78
|
+
else:
|
|
79
|
+
assert result.exit_code == 0
|
felis/types.py
CHANGED
|
@@ -26,20 +26,20 @@ from __future__ import annotations
|
|
|
26
26
|
from typing import Any
|
|
27
27
|
|
|
28
28
|
__all__ = [
|
|
29
|
-
"
|
|
29
|
+
"Binary",
|
|
30
30
|
"Boolean",
|
|
31
31
|
"Byte",
|
|
32
|
-
"
|
|
32
|
+
"Char",
|
|
33
|
+
"Double",
|
|
34
|
+
"FelisType",
|
|
35
|
+
"Float",
|
|
33
36
|
"Int",
|
|
34
37
|
"Long",
|
|
35
|
-
"
|
|
36
|
-
"Double",
|
|
37
|
-
"Char",
|
|
38
|
+
"Short",
|
|
38
39
|
"String",
|
|
39
|
-
"Unicode",
|
|
40
40
|
"Text",
|
|
41
|
-
"Binary",
|
|
42
41
|
"Timestamp",
|
|
42
|
+
"Unicode",
|
|
43
43
|
]
|
|
44
44
|
|
|
45
45
|
|
|
@@ -1,34 +1,38 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: lsst-felis
|
|
3
|
-
Version:
|
|
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:
|
|
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
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
17
|
Classifier: Topic :: Scientific/Engineering :: Astronomy
|
|
17
18
|
Requires-Python: >=3.11.0
|
|
18
19
|
Description-Content-Type: text/markdown
|
|
19
20
|
License-File: COPYRIGHT
|
|
20
21
|
License-File: LICENSE
|
|
21
|
-
Requires-Dist:
|
|
22
|
-
Requires-Dist:
|
|
23
|
-
Requires-Dist: click
|
|
24
|
-
Requires-Dist:
|
|
25
|
-
Requires-Dist: pydantic <3,>=2
|
|
26
|
-
Requires-Dist: lsst-utils
|
|
22
|
+
Requires-Dist: alembic
|
|
23
|
+
Requires-Dist: astropy
|
|
24
|
+
Requires-Dist: click
|
|
25
|
+
Requires-Dist: deepdiff
|
|
27
26
|
Requires-Dist: lsst-resources
|
|
28
|
-
|
|
29
|
-
Requires-Dist:
|
|
30
|
-
Requires-Dist:
|
|
31
|
-
Requires-Dist:
|
|
27
|
+
Requires-Dist: lsst-utils
|
|
28
|
+
Requires-Dist: numpy
|
|
29
|
+
Requires-Dist: pydantic<3,>=2
|
|
30
|
+
Requires-Dist: pyyaml
|
|
31
|
+
Requires-Dist: sqlalchemy
|
|
32
32
|
Provides-Extra: test
|
|
33
|
-
Requires-Dist: pytest
|
|
34
|
-
|
|
33
|
+
Requires-Dist: pytest>=3.2; extra == "test"
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: documenteer[guide]<2; extra == "dev"
|
|
36
|
+
Requires-Dist: autodoc_pydantic; extra == "dev"
|
|
37
|
+
Requires-Dist: sphinx-click; extra == "dev"
|
|
38
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
felis/__init__.py,sha256=j7SDnTb0u0ElxDaJDNSeEznpqvxR1Az4Af8nESsDcDs,1148
|
|
2
|
+
felis/cli.py,sha256=BHZ75V0PupuAAAGzHPPb8j3G8iueC_vA9wJw5KV0kOM,19431
|
|
3
|
+
felis/datamodel.py,sha256=4AEyP9cYpIvuJIABBzsOm8iQuEsV31dZOJG4vjEBq8w,52116
|
|
4
|
+
felis/diff.py,sha256=ZzjOJ57p5ZwFn6eem7CYoPjSnxti5OZY33B6Ds5Q-Rg,7797
|
|
5
|
+
felis/metadata.py,sha256=WRtwx4lA8n6r8LbeNSMwvRzHuv22oGuttGlHsm_qJt0,16157
|
|
6
|
+
felis/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
felis/tap_schema.py,sha256=X_5LD_T78Ga9OvKBBbDDJcLA6DpXwIld8no84WYtYPs,26736
|
|
8
|
+
felis/types.py,sha256=ifZQjc-Uw5CM3L7hmFUb7wcHY1O_HgJCw6HPqyUkHvk,5510
|
|
9
|
+
felis/config/tap_schema/columns.csv,sha256=9RsyuPObUQ_6myux9vKtlQ-aJgs7rvvxoLf6yYSRWqc,3272
|
|
10
|
+
felis/config/tap_schema/key_columns.csv,sha256=dRezco5ltcM1mG--2DvPsbOxB6cwVaBwczwi3It2vag,210
|
|
11
|
+
felis/config/tap_schema/keys.csv,sha256=6zTXyo-1GNfu5sBWpX-7ZJFAtHrxOys78AViCcdPgu8,377
|
|
12
|
+
felis/config/tap_schema/schemas.csv,sha256=z5g1bW1Y9H8nKLZyH4e5xiBBoK9JezR2Xf8L79K2TZk,138
|
|
13
|
+
felis/config/tap_schema/tables.csv,sha256=o0KioOiL7hw9ntCyKWili-iFMjAaGRMUOE-nM30LBD0,510
|
|
14
|
+
felis/config/tap_schema/tap_schema_extensions.yaml,sha256=abNqmjW8hVUBLAXMcNt_VmZ8wPikz9N32epxbBApTdU,2164
|
|
15
|
+
felis/config/tap_schema/tap_schema_std.yaml,sha256=sPW-Vk72nY0PFpCvP5d8L8fWvhkif-x32sGtcfDZ8bU,7131
|
|
16
|
+
felis/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
+
felis/db/_dialects.py,sha256=Y7FV0nI4Vm-fQG3oR71CZor09DKN1QEp9rkOwwvwzuM,5601
|
|
18
|
+
felis/db/_sqltypes.py,sha256=Q2p3Af3O5-B1ZxQ4M2j_w8SH1o_kp6ezg8h7LmSlfww,11060
|
|
19
|
+
felis/db/_variants.py,sha256=Vy5s8lF80WErADKsCwgMvlET--4Q8NW4lK9dSltH8nc,5261
|
|
20
|
+
felis/db/database_context.py,sha256=VHjOZ71r-HbcCijq4WG2rw1r0sYeGQTEJ5SgjBfRSrM,29270
|
|
21
|
+
felis/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
+
felis/tests/postgresql.py,sha256=JtUgNaM54e5ktcWF-NE4p4B6eeJRpVjz0QVJogTd3eg,4035
|
|
23
|
+
felis/tests/run_cli.py,sha256=Gg8loUIGj9t6KlkRKrEc9Z9b5dtlkpJy94ORuj4BrxU,2503
|
|
24
|
+
lsst_felis-30.0.0rc3.dist-info/licenses/COPYRIGHT,sha256=vJAFLFTSF1mhy9eIuA3P6R-3yxTWKQgpig88P-1IzRw,129
|
|
25
|
+
lsst_felis-30.0.0rc3.dist-info/licenses/LICENSE,sha256=jOtLnuWt7d5Hsx6XXB2QxzrSe2sWWh3NgMfFRetluQM,35147
|
|
26
|
+
lsst_felis-30.0.0rc3.dist-info/METADATA,sha256=IXBalUeGwBneWF2X5HFbG7KSQwNr4DqJyJz8bKF9vKk,1374
|
|
27
|
+
lsst_felis-30.0.0rc3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
28
|
+
lsst_felis-30.0.0rc3.dist-info/entry_points.txt,sha256=Gk2XFujA_Gp52VBk45g5kim8TDoMDJFPctsMqiq72EM,40
|
|
29
|
+
lsst_felis-30.0.0rc3.dist-info/top_level.txt,sha256=F4SvPip3iZRVyISi50CHhwTIAokAhSxjWiVcn4IVWRI,6
|
|
30
|
+
lsst_felis-30.0.0rc3.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
|
|
31
|
+
lsst_felis-30.0.0rc3.dist-info/RECORD,,
|
felis/db/utils.py
DELETED
|
@@ -1,409 +0,0 @@
|
|
|
1
|
-
"""Database utility functions and classes."""
|
|
2
|
-
|
|
3
|
-
# This file is part of felis.
|
|
4
|
-
#
|
|
5
|
-
# Developed for the LSST Data Management System.
|
|
6
|
-
# This product includes software developed by the LSST Project
|
|
7
|
-
# (https://www.lsst.org).
|
|
8
|
-
# See the COPYRIGHT file at the top-level directory of this distribution
|
|
9
|
-
# for details of code ownership.
|
|
10
|
-
#
|
|
11
|
-
# This program is free software: you can redistribute it and/or modify
|
|
12
|
-
# it under the terms of the GNU General Public License as published by
|
|
13
|
-
# the Free Software Foundation, either version 3 of the License, or
|
|
14
|
-
# (at your option) any later version.
|
|
15
|
-
#
|
|
16
|
-
# This program is distributed in the hope that it will be useful,
|
|
17
|
-
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
18
|
-
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
19
|
-
# GNU General Public License for more details.
|
|
20
|
-
#
|
|
21
|
-
# You should have received a copy of the GNU General Public License
|
|
22
|
-
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
23
|
-
|
|
24
|
-
from __future__ import annotations
|
|
25
|
-
|
|
26
|
-
import logging
|
|
27
|
-
import re
|
|
28
|
-
from typing import IO, Any
|
|
29
|
-
|
|
30
|
-
from sqlalchemy import MetaData, types
|
|
31
|
-
from sqlalchemy.engine import Dialect, Engine, ResultProxy
|
|
32
|
-
from sqlalchemy.engine.mock import MockConnection, create_mock_engine
|
|
33
|
-
from sqlalchemy.engine.url import URL
|
|
34
|
-
from sqlalchemy.exc import SQLAlchemyError
|
|
35
|
-
from sqlalchemy.schema import CreateSchema, DropSchema
|
|
36
|
-
from sqlalchemy.sql import text
|
|
37
|
-
from sqlalchemy.types import TypeEngine
|
|
38
|
-
|
|
39
|
-
from .dialects import get_dialect_module
|
|
40
|
-
|
|
41
|
-
__all__ = ["string_to_typeengine", "SQLWriter", "ConnectionWrapper", "DatabaseContext"]
|
|
42
|
-
|
|
43
|
-
logger = logging.getLogger("felis")
|
|
44
|
-
|
|
45
|
-
_DATATYPE_REGEXP = re.compile(r"(\w+)(\((.*)\))?")
|
|
46
|
-
"""Regular expression to match data types with parameters in parentheses."""
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def string_to_typeengine(
|
|
50
|
-
type_string: str, dialect: Dialect | None = None, length: int | None = None
|
|
51
|
-
) -> 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
|
-
Raised 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.
|
|
79
|
-
"""
|
|
80
|
-
match = _DATATYPE_REGEXP.search(type_string)
|
|
81
|
-
if not match:
|
|
82
|
-
raise ValueError(f"Invalid type string: {type_string}")
|
|
83
|
-
|
|
84
|
-
type_name, _, params = match.groups()
|
|
85
|
-
if dialect is None:
|
|
86
|
-
type_class = getattr(types, type_name.upper(), None)
|
|
87
|
-
else:
|
|
88
|
-
try:
|
|
89
|
-
dialect_module = get_dialect_module(dialect.name)
|
|
90
|
-
except KeyError:
|
|
91
|
-
raise ValueError(f"Unsupported dialect: {dialect}")
|
|
92
|
-
type_class = getattr(dialect_module, type_name.upper(), None)
|
|
93
|
-
|
|
94
|
-
if not type_class:
|
|
95
|
-
raise ValueError(f"Unsupported type: {type_class}")
|
|
96
|
-
|
|
97
|
-
if params:
|
|
98
|
-
params = [int(param) if param.isdigit() else param for param in params.split(",")]
|
|
99
|
-
type_obj = type_class(*params)
|
|
100
|
-
else:
|
|
101
|
-
type_obj = type_class()
|
|
102
|
-
|
|
103
|
-
if hasattr(type_obj, "length") and getattr(type_obj, "length") is None and length is not None:
|
|
104
|
-
type_obj.length = length
|
|
105
|
-
|
|
106
|
-
return type_obj
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def is_mock_url(url: URL) -> bool:
|
|
110
|
-
"""Check if the engine URL is a mock URL.
|
|
111
|
-
|
|
112
|
-
Parameters
|
|
113
|
-
----------
|
|
114
|
-
url
|
|
115
|
-
The SQLAlchemy engine URL.
|
|
116
|
-
|
|
117
|
-
Returns
|
|
118
|
-
-------
|
|
119
|
-
bool
|
|
120
|
-
True if the URL is a mock URL, False otherwise.
|
|
121
|
-
"""
|
|
122
|
-
return (url.drivername == "sqlite" and url.database is None) or (
|
|
123
|
-
url.drivername != "sqlite" and url.host is None
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
def is_valid_engine(engine: Engine | MockConnection | None) -> bool:
|
|
128
|
-
"""Check if the engine is valid.
|
|
129
|
-
|
|
130
|
-
The engine cannot be none; it must not be a mock connection; and it must
|
|
131
|
-
not be a mock URL which is missing a host or, for sqlite, a database name.
|
|
132
|
-
|
|
133
|
-
Parameters
|
|
134
|
-
----------
|
|
135
|
-
engine
|
|
136
|
-
The SQLAlchemy engine or mock connection.
|
|
137
|
-
|
|
138
|
-
Returns
|
|
139
|
-
-------
|
|
140
|
-
bool
|
|
141
|
-
True if the engine is valid, False otherwise.
|
|
142
|
-
"""
|
|
143
|
-
return engine is not None and not isinstance(engine, MockConnection) and not is_mock_url(engine.url)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
class SQLWriter:
|
|
147
|
-
"""Write SQL statements to stdout or a file.
|
|
148
|
-
|
|
149
|
-
Parameters
|
|
150
|
-
----------
|
|
151
|
-
file
|
|
152
|
-
The file to write the SQL statements to. If None, the statements
|
|
153
|
-
will be written to stdout.
|
|
154
|
-
"""
|
|
155
|
-
|
|
156
|
-
def __init__(self, file: IO[str] | None = None) -> None:
|
|
157
|
-
"""Initialize the SQL writer."""
|
|
158
|
-
self.file = file
|
|
159
|
-
self.dialect: Dialect | None = None
|
|
160
|
-
|
|
161
|
-
def write(self, sql: Any, *multiparams: Any, **params: Any) -> None:
|
|
162
|
-
"""Write the SQL statement to a file or stdout.
|
|
163
|
-
|
|
164
|
-
Statements with parameters will be formatted with the values
|
|
165
|
-
inserted into the resultant SQL output.
|
|
166
|
-
|
|
167
|
-
Parameters
|
|
168
|
-
----------
|
|
169
|
-
sql
|
|
170
|
-
The SQL statement to write.
|
|
171
|
-
*multiparams
|
|
172
|
-
The multiparams to use for the SQL statement.
|
|
173
|
-
**params
|
|
174
|
-
The params to use for the SQL statement.
|
|
175
|
-
|
|
176
|
-
Notes
|
|
177
|
-
-----
|
|
178
|
-
The functions arguments are typed very loosely because this method in
|
|
179
|
-
SQLAlchemy is untyped, amd we do not call it directly.
|
|
180
|
-
"""
|
|
181
|
-
compiled = sql.compile(dialect=self.dialect)
|
|
182
|
-
sql_str = str(compiled) + ";"
|
|
183
|
-
params_list = [compiled.params]
|
|
184
|
-
for params in params_list:
|
|
185
|
-
if not params:
|
|
186
|
-
print(sql_str, file=self.file)
|
|
187
|
-
continue
|
|
188
|
-
new_params = {}
|
|
189
|
-
for key, value in params.items():
|
|
190
|
-
if isinstance(value, str):
|
|
191
|
-
new_params[key] = f"'{value}'"
|
|
192
|
-
elif value is None:
|
|
193
|
-
new_params[key] = "null"
|
|
194
|
-
else:
|
|
195
|
-
new_params[key] = value
|
|
196
|
-
print(sql_str % new_params, file=self.file)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
class ConnectionWrapper:
|
|
200
|
-
"""Wrap a SQLAlchemy engine or mock connection to provide a consistent
|
|
201
|
-
interface for executing SQL statements.
|
|
202
|
-
|
|
203
|
-
Parameters
|
|
204
|
-
----------
|
|
205
|
-
engine
|
|
206
|
-
The SQLAlchemy engine or mock connection to wrap.
|
|
207
|
-
"""
|
|
208
|
-
|
|
209
|
-
def __init__(self, engine: Engine | MockConnection):
|
|
210
|
-
"""Initialize the connection wrapper."""
|
|
211
|
-
self.engine = engine
|
|
212
|
-
|
|
213
|
-
def execute(self, statement: Any) -> ResultProxy:
|
|
214
|
-
"""Execute a SQL statement on the engine and return the result.
|
|
215
|
-
|
|
216
|
-
Parameters
|
|
217
|
-
----------
|
|
218
|
-
statement
|
|
219
|
-
The SQL statement to execute.
|
|
220
|
-
|
|
221
|
-
Returns
|
|
222
|
-
-------
|
|
223
|
-
``sqlalchemy.engine.ResultProxy``
|
|
224
|
-
The result of the statement execution.
|
|
225
|
-
|
|
226
|
-
Notes
|
|
227
|
-
-----
|
|
228
|
-
The statement will be executed in a transaction block if not using
|
|
229
|
-
a mock connection.
|
|
230
|
-
"""
|
|
231
|
-
if isinstance(statement, str):
|
|
232
|
-
statement = text(statement)
|
|
233
|
-
if isinstance(self.engine, Engine):
|
|
234
|
-
try:
|
|
235
|
-
with self.engine.begin() as connection:
|
|
236
|
-
result = connection.execute(statement)
|
|
237
|
-
return result
|
|
238
|
-
except SQLAlchemyError as e:
|
|
239
|
-
connection.rollback()
|
|
240
|
-
logger.error(f"Error executing statement: {e}")
|
|
241
|
-
raise
|
|
242
|
-
elif isinstance(self.engine, MockConnection):
|
|
243
|
-
return self.engine.connect().execute(statement)
|
|
244
|
-
else:
|
|
245
|
-
raise ValueError("Unsupported engine type:" + str(type(self.engine)))
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
class DatabaseContext:
|
|
249
|
-
"""Manage the database connection and SQLAlchemy metadata.
|
|
250
|
-
|
|
251
|
-
Parameters
|
|
252
|
-
----------
|
|
253
|
-
metadata
|
|
254
|
-
The SQLAlchemy metadata object.
|
|
255
|
-
|
|
256
|
-
engine
|
|
257
|
-
The SQLAlchemy engine or mock connection object.
|
|
258
|
-
"""
|
|
259
|
-
|
|
260
|
-
def __init__(self, metadata: MetaData, engine: Engine | MockConnection):
|
|
261
|
-
"""Initialize the database context."""
|
|
262
|
-
self.engine = engine
|
|
263
|
-
self.dialect_name = engine.dialect.name
|
|
264
|
-
self.metadata = metadata
|
|
265
|
-
self.connection = ConnectionWrapper(engine)
|
|
266
|
-
|
|
267
|
-
def initialize(self) -> None:
|
|
268
|
-
"""Create the schema in the database if it does not exist.
|
|
269
|
-
|
|
270
|
-
Raises
|
|
271
|
-
------
|
|
272
|
-
ValueError
|
|
273
|
-
Raised if the database is not supported or it already exists.
|
|
274
|
-
sqlalchemy.exc.SQLAlchemyError
|
|
275
|
-
Raised if there is an error creating the schema.
|
|
276
|
-
|
|
277
|
-
Notes
|
|
278
|
-
-----
|
|
279
|
-
In MySQL, this will create a new database and, in PostgreSQL, it will
|
|
280
|
-
create a new schema. For other variants, this is an unsupported
|
|
281
|
-
operation.
|
|
282
|
-
"""
|
|
283
|
-
if self.engine.dialect.name == "sqlite":
|
|
284
|
-
# Initialization is unneeded for sqlite.
|
|
285
|
-
return
|
|
286
|
-
schema_name = self.metadata.schema
|
|
287
|
-
if schema_name is None:
|
|
288
|
-
raise ValueError("Schema name is required to initialize the schema.")
|
|
289
|
-
try:
|
|
290
|
-
if self.dialect_name == "mysql":
|
|
291
|
-
logger.debug(f"Checking if MySQL database exists: {schema_name}")
|
|
292
|
-
result = self.execute(text(f"SHOW DATABASES LIKE '{schema_name}'"))
|
|
293
|
-
if result.fetchone():
|
|
294
|
-
raise ValueError(f"MySQL database '{schema_name}' already exists.")
|
|
295
|
-
logger.debug(f"Creating MySQL database: {schema_name}")
|
|
296
|
-
self.execute(text(f"CREATE DATABASE {schema_name}"))
|
|
297
|
-
elif self.dialect_name == "postgresql":
|
|
298
|
-
logger.debug(f"Checking if PG schema exists: {schema_name}")
|
|
299
|
-
result = self.execute(
|
|
300
|
-
text(
|
|
301
|
-
f"""
|
|
302
|
-
SELECT schema_name
|
|
303
|
-
FROM information_schema.schemata
|
|
304
|
-
WHERE schema_name = '{schema_name}'
|
|
305
|
-
"""
|
|
306
|
-
)
|
|
307
|
-
)
|
|
308
|
-
if result.fetchone():
|
|
309
|
-
raise ValueError(f"PostgreSQL schema '{schema_name}' already exists.")
|
|
310
|
-
logger.debug(f"Creating PG schema: {schema_name}")
|
|
311
|
-
self.execute(CreateSchema(schema_name))
|
|
312
|
-
else:
|
|
313
|
-
raise ValueError(f"Initialization not supported for: {self.dialect_name}")
|
|
314
|
-
except SQLAlchemyError as e:
|
|
315
|
-
logger.error(f"Error creating schema: {e}")
|
|
316
|
-
raise
|
|
317
|
-
|
|
318
|
-
def drop(self) -> None:
|
|
319
|
-
"""Drop the schema in the database if it exists.
|
|
320
|
-
|
|
321
|
-
Raises
|
|
322
|
-
------
|
|
323
|
-
ValueError
|
|
324
|
-
Raised if the database is not supported.
|
|
325
|
-
|
|
326
|
-
Notes
|
|
327
|
-
-----
|
|
328
|
-
In MySQL, this will drop a database. In PostgreSQL, it will drop a
|
|
329
|
-
schema. A SQlite database will have all its tables dropped. For other
|
|
330
|
-
database variants, this is currently an unsupported operation.
|
|
331
|
-
"""
|
|
332
|
-
try:
|
|
333
|
-
if self.dialect_name == "sqlite":
|
|
334
|
-
if isinstance(self.engine, Engine):
|
|
335
|
-
logger.debug("Dropping tables in SQLite schema")
|
|
336
|
-
self.metadata.drop_all(bind=self.engine)
|
|
337
|
-
else:
|
|
338
|
-
schema_name = self.metadata.schema
|
|
339
|
-
if schema_name is None:
|
|
340
|
-
raise ValueError("Schema name is required to drop the schema.")
|
|
341
|
-
if self.dialect_name == "mysql":
|
|
342
|
-
logger.debug(f"Dropping MySQL database if exists: {schema_name}")
|
|
343
|
-
self.execute(text(f"DROP DATABASE IF EXISTS {schema_name}"))
|
|
344
|
-
elif self.dialect_name == "postgresql":
|
|
345
|
-
logger.debug(f"Dropping PostgreSQL schema if exists: {schema_name}")
|
|
346
|
-
self.execute(DropSchema(schema_name, if_exists=True, cascade=True))
|
|
347
|
-
except SQLAlchemyError as e:
|
|
348
|
-
logger.error(f"Error dropping schema: {e}")
|
|
349
|
-
raise
|
|
350
|
-
|
|
351
|
-
def create_all(self) -> None:
|
|
352
|
-
"""Create all tables in the schema using the metadata object."""
|
|
353
|
-
if isinstance(self.engine, Engine):
|
|
354
|
-
# Use a transaction for a real connection.
|
|
355
|
-
with self.engine.begin() as conn:
|
|
356
|
-
try:
|
|
357
|
-
self.metadata.create_all(bind=conn)
|
|
358
|
-
conn.commit()
|
|
359
|
-
except SQLAlchemyError as e:
|
|
360
|
-
conn.rollback()
|
|
361
|
-
logger.error(f"Error creating tables: {e}")
|
|
362
|
-
raise
|
|
363
|
-
elif isinstance(self.engine, MockConnection):
|
|
364
|
-
# Mock connection so no need for a transaction.
|
|
365
|
-
self.metadata.create_all(self.engine)
|
|
366
|
-
else:
|
|
367
|
-
raise ValueError("Unsupported engine type: " + str(type(self.engine)))
|
|
368
|
-
|
|
369
|
-
@staticmethod
|
|
370
|
-
def create_mock_engine(engine_url: str | URL, output_file: IO[str] | None = None) -> MockConnection:
|
|
371
|
-
"""Create a mock engine for testing or dumping DDL statements.
|
|
372
|
-
|
|
373
|
-
Parameters
|
|
374
|
-
----------
|
|
375
|
-
engine_url
|
|
376
|
-
The SQLAlchemy engine URL.
|
|
377
|
-
output_file
|
|
378
|
-
The file to write the SQL statements to. If None, the statements
|
|
379
|
-
will be written to stdout.
|
|
380
|
-
|
|
381
|
-
Returns
|
|
382
|
-
-------
|
|
383
|
-
``sqlalchemy.engine.mock.MockConnection``
|
|
384
|
-
The mock connection object.
|
|
385
|
-
"""
|
|
386
|
-
writer = SQLWriter(output_file)
|
|
387
|
-
engine = create_mock_engine(engine_url, executor=writer.write, paramstyle="pyformat")
|
|
388
|
-
writer.dialect = engine.dialect
|
|
389
|
-
return engine
|
|
390
|
-
|
|
391
|
-
def execute(self, statement: Any) -> ResultProxy:
|
|
392
|
-
"""Execute a SQL statement on the engine and return the result.
|
|
393
|
-
|
|
394
|
-
Parameters
|
|
395
|
-
----------
|
|
396
|
-
statement
|
|
397
|
-
The SQL statement to execute.
|
|
398
|
-
|
|
399
|
-
Returns
|
|
400
|
-
-------
|
|
401
|
-
``sqlalchemy.engine.ResultProxy``
|
|
402
|
-
The result of the statement execution.
|
|
403
|
-
|
|
404
|
-
Notes
|
|
405
|
-
-----
|
|
406
|
-
This is just a wrapper around the execution method of the connection
|
|
407
|
-
object, which may execute on a real or mock connection.
|
|
408
|
-
"""
|
|
409
|
-
return self.connection.execute(statement)
|