lsst-felis 26.2024.400__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/__init__.py +47 -0
- felis/check.py +381 -0
- felis/cli.py +398 -0
- felis/datamodel.py +409 -0
- felis/db/__init__.py +0 -0
- felis/db/sqltypes.py +209 -0
- felis/py.typed +0 -0
- felis/simple.py +424 -0
- felis/sql.py +264 -0
- felis/tap.py +434 -0
- felis/types.py +137 -0
- felis/utils.py +98 -0
- felis/version.py +2 -0
- felis/visitor.py +180 -0
- lsst_felis-26.2024.400.dist-info/COPYRIGHT +1 -0
- lsst_felis-26.2024.400.dist-info/LICENSE +674 -0
- lsst_felis-26.2024.400.dist-info/METADATA +1064 -0
- lsst_felis-26.2024.400.dist-info/RECORD +22 -0
- lsst_felis-26.2024.400.dist-info/WHEEL +5 -0
- lsst_felis-26.2024.400.dist-info/entry_points.txt +2 -0
- lsst_felis-26.2024.400.dist-info/top_level.txt +1 -0
- lsst_felis-26.2024.400.dist-info/zip-safe +1 -0
felis/cli.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
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 io
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import sys
|
|
26
|
+
from collections.abc import Iterable, Mapping, MutableMapping
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import click
|
|
30
|
+
import yaml
|
|
31
|
+
from pydantic import ValidationError
|
|
32
|
+
from pyld import jsonld
|
|
33
|
+
from sqlalchemy.engine import Engine, create_engine, create_mock_engine, make_url
|
|
34
|
+
from sqlalchemy.engine.mock import MockConnection
|
|
35
|
+
|
|
36
|
+
from . import DEFAULT_CONTEXT, DEFAULT_FRAME, __version__
|
|
37
|
+
from .check import CheckingVisitor
|
|
38
|
+
from .datamodel import Schema
|
|
39
|
+
from .sql import SQLVisitor
|
|
40
|
+
from .tap import Tap11Base, TapLoadingVisitor, init_tables
|
|
41
|
+
from .utils import ReorderingVisitor
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger("felis")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@click.group()
|
|
47
|
+
@click.version_option(__version__)
|
|
48
|
+
def cli() -> None:
|
|
49
|
+
"""Felis Command Line Tools."""
|
|
50
|
+
logging.basicConfig(level=logging.INFO)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@cli.command("create-all")
|
|
54
|
+
@click.option("--engine-url", envvar="ENGINE_URL", help="SQLAlchemy Engine URL")
|
|
55
|
+
@click.option("--schema-name", help="Alternate Schema Name for Felis File")
|
|
56
|
+
@click.option("--dry-run", is_flag=True, help="Dry Run Only. Prints out the DDL that would be executed")
|
|
57
|
+
@click.argument("file", type=click.File())
|
|
58
|
+
def create_all(engine_url: str, schema_name: str, dry_run: bool, file: io.TextIOBase) -> None:
|
|
59
|
+
"""Create schema objects from the Felis FILE."""
|
|
60
|
+
schema_obj = yaml.load(file, Loader=yaml.SafeLoader)
|
|
61
|
+
visitor = SQLVisitor(schema_name=schema_name)
|
|
62
|
+
schema = visitor.visit_schema(schema_obj)
|
|
63
|
+
|
|
64
|
+
metadata = schema.metadata
|
|
65
|
+
|
|
66
|
+
engine: Engine | MockConnection
|
|
67
|
+
if not dry_run:
|
|
68
|
+
engine = create_engine(engine_url)
|
|
69
|
+
else:
|
|
70
|
+
_insert_dump = InsertDump()
|
|
71
|
+
engine = create_mock_engine(make_url(engine_url), executor=_insert_dump.dump)
|
|
72
|
+
_insert_dump.dialect = engine.dialect
|
|
73
|
+
metadata.create_all(engine)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@cli.command("init-tap")
|
|
77
|
+
@click.option("--tap-schema-name", help="Alt Schema Name for TAP_SCHEMA")
|
|
78
|
+
@click.option("--tap-schemas-table", help="Alt Table Name for TAP_SCHEMA.schemas")
|
|
79
|
+
@click.option("--tap-tables-table", help="Alt Table Name for TAP_SCHEMA.tables")
|
|
80
|
+
@click.option("--tap-columns-table", help="Alt Table Name for TAP_SCHEMA.columns")
|
|
81
|
+
@click.option("--tap-keys-table", help="Alt Table Name for TAP_SCHEMA.keys")
|
|
82
|
+
@click.option("--tap-key-columns-table", help="Alt Table Name for TAP_SCHEMA.key_columns")
|
|
83
|
+
@click.argument("engine-url")
|
|
84
|
+
def init_tap(
|
|
85
|
+
engine_url: str,
|
|
86
|
+
tap_schema_name: str,
|
|
87
|
+
tap_schemas_table: str,
|
|
88
|
+
tap_tables_table: str,
|
|
89
|
+
tap_columns_table: str,
|
|
90
|
+
tap_keys_table: str,
|
|
91
|
+
tap_key_columns_table: str,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Initialize TAP 1.1 TAP_SCHEMA objects.
|
|
94
|
+
|
|
95
|
+
Please verify the schema/catalog you are executing this in in your
|
|
96
|
+
engine URL.
|
|
97
|
+
"""
|
|
98
|
+
engine = create_engine(engine_url, echo=True)
|
|
99
|
+
init_tables(
|
|
100
|
+
tap_schema_name,
|
|
101
|
+
tap_schemas_table,
|
|
102
|
+
tap_tables_table,
|
|
103
|
+
tap_columns_table,
|
|
104
|
+
tap_keys_table,
|
|
105
|
+
tap_key_columns_table,
|
|
106
|
+
)
|
|
107
|
+
Tap11Base.metadata.create_all(engine)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@cli.command("load-tap")
|
|
111
|
+
@click.option("--engine-url", envvar="ENGINE_URL", help="SQLAlchemy Engine URL to catalog")
|
|
112
|
+
@click.option("--schema-name", help="Alternate Schema Name for Felis file")
|
|
113
|
+
@click.option("--catalog-name", help="Catalog Name for Schema")
|
|
114
|
+
@click.option("--dry-run", is_flag=True, help="Dry Run Only. Prints out the DDL that would be executed")
|
|
115
|
+
@click.option("--tap-schema-name", help="Alt Schema Name for TAP_SCHEMA")
|
|
116
|
+
@click.option("--tap-tables-postfix", help="Postfix for TAP table names")
|
|
117
|
+
@click.option("--tap-schemas-table", help="Alt Table Name for TAP_SCHEMA.schemas")
|
|
118
|
+
@click.option("--tap-tables-table", help="Alt Table Name for TAP_SCHEMA.tables")
|
|
119
|
+
@click.option("--tap-columns-table", help="Alt Table Name for TAP_SCHEMA.columns")
|
|
120
|
+
@click.option("--tap-keys-table", help="Alt Table Name for TAP_SCHEMA.keys")
|
|
121
|
+
@click.option("--tap-key-columns-table", help="Alt Table Name for TAP_SCHEMA.key_columns")
|
|
122
|
+
@click.argument("file", type=click.File())
|
|
123
|
+
def load_tap(
|
|
124
|
+
engine_url: str,
|
|
125
|
+
schema_name: str,
|
|
126
|
+
catalog_name: str,
|
|
127
|
+
dry_run: bool,
|
|
128
|
+
tap_schema_name: str,
|
|
129
|
+
tap_tables_postfix: str,
|
|
130
|
+
tap_schemas_table: str,
|
|
131
|
+
tap_tables_table: str,
|
|
132
|
+
tap_columns_table: str,
|
|
133
|
+
tap_keys_table: str,
|
|
134
|
+
tap_key_columns_table: str,
|
|
135
|
+
file: io.TextIOBase,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Load TAP metadata from a Felis FILE.
|
|
138
|
+
|
|
139
|
+
This command loads the associated TAP metadata from a Felis FILE
|
|
140
|
+
to the TAP_SCHEMA tables.
|
|
141
|
+
"""
|
|
142
|
+
top_level_object = yaml.load(file, Loader=yaml.SafeLoader)
|
|
143
|
+
schema_obj: dict
|
|
144
|
+
if isinstance(top_level_object, dict):
|
|
145
|
+
schema_obj = top_level_object
|
|
146
|
+
if "@graph" not in schema_obj:
|
|
147
|
+
schema_obj["@type"] = "felis:Schema"
|
|
148
|
+
schema_obj["@context"] = DEFAULT_CONTEXT
|
|
149
|
+
elif isinstance(top_level_object, list):
|
|
150
|
+
schema_obj = {"@context": DEFAULT_CONTEXT, "@graph": top_level_object}
|
|
151
|
+
else:
|
|
152
|
+
logger.error("Schema object not of recognizable type")
|
|
153
|
+
raise click.exceptions.Exit(1)
|
|
154
|
+
|
|
155
|
+
normalized = _normalize(schema_obj, embed="@always")
|
|
156
|
+
if len(normalized["@graph"]) > 1 and (schema_name or catalog_name):
|
|
157
|
+
logger.error("--schema-name and --catalog-name incompatible with multiple schemas")
|
|
158
|
+
raise click.exceptions.Exit(1)
|
|
159
|
+
|
|
160
|
+
# Force normalized["@graph"] to a list, which is what happens when there's
|
|
161
|
+
# multiple schemas
|
|
162
|
+
if isinstance(normalized["@graph"], dict):
|
|
163
|
+
normalized["@graph"] = [normalized["@graph"]]
|
|
164
|
+
|
|
165
|
+
tap_tables = init_tables(
|
|
166
|
+
tap_schema_name,
|
|
167
|
+
tap_tables_postfix,
|
|
168
|
+
tap_schemas_table,
|
|
169
|
+
tap_tables_table,
|
|
170
|
+
tap_columns_table,
|
|
171
|
+
tap_keys_table,
|
|
172
|
+
tap_key_columns_table,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if not dry_run:
|
|
176
|
+
engine = create_engine(engine_url)
|
|
177
|
+
|
|
178
|
+
if engine_url == "sqlite://" and not dry_run:
|
|
179
|
+
# In Memory SQLite - Mostly used to test
|
|
180
|
+
Tap11Base.metadata.create_all(engine)
|
|
181
|
+
|
|
182
|
+
for schema in normalized["@graph"]:
|
|
183
|
+
tap_visitor = TapLoadingVisitor(
|
|
184
|
+
engine,
|
|
185
|
+
catalog_name=catalog_name,
|
|
186
|
+
schema_name=schema_name,
|
|
187
|
+
tap_tables=tap_tables,
|
|
188
|
+
)
|
|
189
|
+
tap_visitor.visit_schema(schema)
|
|
190
|
+
else:
|
|
191
|
+
_insert_dump = InsertDump()
|
|
192
|
+
conn = create_mock_engine(make_url(engine_url), executor=_insert_dump.dump, paramstyle="pyformat")
|
|
193
|
+
# After the engine is created, update the executor with the dialect
|
|
194
|
+
_insert_dump.dialect = conn.dialect
|
|
195
|
+
|
|
196
|
+
for schema in normalized["@graph"]:
|
|
197
|
+
tap_visitor = TapLoadingVisitor.from_mock_connection(
|
|
198
|
+
conn,
|
|
199
|
+
catalog_name=catalog_name,
|
|
200
|
+
schema_name=schema_name,
|
|
201
|
+
tap_tables=tap_tables,
|
|
202
|
+
)
|
|
203
|
+
tap_visitor.visit_schema(schema)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@cli.command("modify-tap")
|
|
207
|
+
@click.option("--start-schema-at", type=int, help="Rewrite index for tap:schema_index")
|
|
208
|
+
@click.argument("files", nargs=-1, type=click.File())
|
|
209
|
+
def modify_tap(start_schema_at: int, files: Iterable[io.TextIOBase]) -> None:
|
|
210
|
+
"""Modify TAP information in Felis schema FILES.
|
|
211
|
+
|
|
212
|
+
This command has some utilities to aid in rewriting felis FILES
|
|
213
|
+
in specific ways. It will write out a merged version of these files.
|
|
214
|
+
"""
|
|
215
|
+
count = 0
|
|
216
|
+
graph = []
|
|
217
|
+
for file in files:
|
|
218
|
+
schema_obj = yaml.load(file, Loader=yaml.SafeLoader)
|
|
219
|
+
if "@graph" not in schema_obj:
|
|
220
|
+
schema_obj["@type"] = "felis:Schema"
|
|
221
|
+
schema_obj["@context"] = DEFAULT_CONTEXT
|
|
222
|
+
schema_index = schema_obj.get("tap:schema_index")
|
|
223
|
+
if not schema_index or (schema_index and schema_index > start_schema_at):
|
|
224
|
+
schema_index = start_schema_at + count
|
|
225
|
+
count += 1
|
|
226
|
+
schema_obj["tap:schema_index"] = schema_index
|
|
227
|
+
graph.extend(jsonld.flatten(schema_obj))
|
|
228
|
+
merged = {"@context": DEFAULT_CONTEXT, "@graph": graph}
|
|
229
|
+
normalized = _normalize(merged, embed="@always")
|
|
230
|
+
_dump(normalized)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@cli.command("basic-check")
|
|
234
|
+
@click.argument("file", type=click.File())
|
|
235
|
+
def basic_check(file: io.TextIOBase) -> None:
|
|
236
|
+
"""Perform a basic check on a felis FILE.
|
|
237
|
+
|
|
238
|
+
This performs a very check to ensure required fields are
|
|
239
|
+
populated and basic semantics are okay. It does not ensure semantics
|
|
240
|
+
are valid for other commands like create-all or load-tap.
|
|
241
|
+
"""
|
|
242
|
+
schema_obj = yaml.load(file, Loader=yaml.SafeLoader)
|
|
243
|
+
schema_obj["@type"] = "felis:Schema"
|
|
244
|
+
# Force Context and Schema Type
|
|
245
|
+
schema_obj["@context"] = DEFAULT_CONTEXT
|
|
246
|
+
check_visitor = CheckingVisitor()
|
|
247
|
+
check_visitor.visit_schema(schema_obj)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@cli.command("normalize")
|
|
251
|
+
@click.argument("file", type=click.File())
|
|
252
|
+
def normalize(file: io.TextIOBase) -> None:
|
|
253
|
+
"""Normalize a Felis FILE.
|
|
254
|
+
|
|
255
|
+
Takes a felis schema FILE, expands it (resolving the full URLs),
|
|
256
|
+
then compacts it, and finally produces output in the canonical
|
|
257
|
+
format.
|
|
258
|
+
|
|
259
|
+
(This is most useful in some debugging scenarios)
|
|
260
|
+
|
|
261
|
+
See Also :
|
|
262
|
+
|
|
263
|
+
https://json-ld.org/spec/latest/json-ld/#expanded-document-form
|
|
264
|
+
https://json-ld.org/spec/latest/json-ld/#compacted-document-form
|
|
265
|
+
"""
|
|
266
|
+
schema_obj = yaml.load(file, Loader=yaml.SafeLoader)
|
|
267
|
+
schema_obj["@type"] = "felis:Schema"
|
|
268
|
+
# Force Context and Schema Type
|
|
269
|
+
schema_obj["@context"] = DEFAULT_CONTEXT
|
|
270
|
+
expanded = jsonld.expand(schema_obj)
|
|
271
|
+
normalized = _normalize(expanded, embed="@always")
|
|
272
|
+
_dump(normalized)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@cli.command("merge")
|
|
276
|
+
@click.argument("files", nargs=-1, type=click.File())
|
|
277
|
+
def merge(files: Iterable[io.TextIOBase]) -> None:
|
|
278
|
+
"""Merge a set of Felis FILES.
|
|
279
|
+
|
|
280
|
+
This will expand out the felis FILES so that it is easy to
|
|
281
|
+
override values (using @Id), then normalize to a single
|
|
282
|
+
output.
|
|
283
|
+
"""
|
|
284
|
+
graph = []
|
|
285
|
+
for file in files:
|
|
286
|
+
schema_obj = yaml.load(file, Loader=yaml.SafeLoader)
|
|
287
|
+
if "@graph" not in schema_obj:
|
|
288
|
+
schema_obj["@type"] = "felis:Schema"
|
|
289
|
+
schema_obj["@context"] = DEFAULT_CONTEXT
|
|
290
|
+
graph.extend(jsonld.flatten(schema_obj))
|
|
291
|
+
updated_map: MutableMapping[str, Any] = {}
|
|
292
|
+
for item in graph:
|
|
293
|
+
_id = item["@id"]
|
|
294
|
+
item_to_update = updated_map.get(_id, item)
|
|
295
|
+
if item_to_update and item_to_update != item:
|
|
296
|
+
logger.debug(f"Overwriting {_id}")
|
|
297
|
+
item_to_update.update(item)
|
|
298
|
+
updated_map[_id] = item_to_update
|
|
299
|
+
merged = {"@context": DEFAULT_CONTEXT, "@graph": list(updated_map.values())}
|
|
300
|
+
normalized = _normalize(merged, embed="@always")
|
|
301
|
+
_dump(normalized)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@cli.command("validate")
|
|
305
|
+
@click.argument("files", nargs=-1, type=click.File())
|
|
306
|
+
def validate(files: Iterable[io.TextIOBase]) -> None:
|
|
307
|
+
"""Validate one or more felis YAML files."""
|
|
308
|
+
rc = 0
|
|
309
|
+
for file in files:
|
|
310
|
+
file_name = getattr(file, "name", None)
|
|
311
|
+
logger.info(f"Validating {file_name}")
|
|
312
|
+
try:
|
|
313
|
+
Schema.model_validate(yaml.load(file, Loader=yaml.SafeLoader))
|
|
314
|
+
except ValidationError as e:
|
|
315
|
+
logger.error(e)
|
|
316
|
+
rc = 1
|
|
317
|
+
if rc:
|
|
318
|
+
raise click.exceptions.Exit(rc)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@cli.command("dump-json")
|
|
322
|
+
@click.option("-x", "--expanded", is_flag=True, help="Extended schema before dumping.")
|
|
323
|
+
@click.option("-f", "--framed", is_flag=True, help="Frame schema before dumping.")
|
|
324
|
+
@click.option("-c", "--compacted", is_flag=True, help="Compact schema before dumping.")
|
|
325
|
+
@click.option("-g", "--graph", is_flag=True, help="Pass graph option to compact.")
|
|
326
|
+
@click.argument("file", type=click.File())
|
|
327
|
+
def dump_json(
|
|
328
|
+
file: io.TextIOBase,
|
|
329
|
+
expanded: bool = False,
|
|
330
|
+
compacted: bool = False,
|
|
331
|
+
framed: bool = False,
|
|
332
|
+
graph: bool = False,
|
|
333
|
+
) -> None:
|
|
334
|
+
"""Dump JSON representation using various JSON-LD options."""
|
|
335
|
+
schema_obj = yaml.load(file, Loader=yaml.SafeLoader)
|
|
336
|
+
schema_obj["@type"] = "felis:Schema"
|
|
337
|
+
# Force Context and Schema Type
|
|
338
|
+
schema_obj["@context"] = DEFAULT_CONTEXT
|
|
339
|
+
|
|
340
|
+
if expanded:
|
|
341
|
+
schema_obj = jsonld.expand(schema_obj)
|
|
342
|
+
if framed:
|
|
343
|
+
schema_obj = jsonld.frame(schema_obj, DEFAULT_FRAME)
|
|
344
|
+
if compacted:
|
|
345
|
+
options = {}
|
|
346
|
+
if graph:
|
|
347
|
+
options["graph"] = True
|
|
348
|
+
schema_obj = jsonld.compact(schema_obj, DEFAULT_CONTEXT, options=options)
|
|
349
|
+
json.dump(schema_obj, sys.stdout, indent=4)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _dump(obj: Mapping[str, Any]) -> None:
|
|
353
|
+
class OrderedDumper(yaml.Dumper):
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
def _dict_representer(dumper: yaml.Dumper, data: Any) -> Any:
|
|
357
|
+
return dumper.represent_mapping(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, data.items())
|
|
358
|
+
|
|
359
|
+
OrderedDumper.add_representer(dict, _dict_representer)
|
|
360
|
+
print(yaml.dump(obj, Dumper=OrderedDumper, default_flow_style=False))
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _normalize(schema_obj: Mapping[str, Any], embed: str = "@last") -> MutableMapping[str, Any]:
|
|
364
|
+
framed = jsonld.frame(schema_obj, DEFAULT_FRAME, options=dict(embed=embed))
|
|
365
|
+
compacted = jsonld.compact(framed, DEFAULT_CONTEXT, options=dict(graph=True))
|
|
366
|
+
graph = compacted["@graph"]
|
|
367
|
+
graph = [ReorderingVisitor(add_type=True).visit_schema(schema_obj) for schema_obj in graph]
|
|
368
|
+
compacted["@graph"] = graph if len(graph) > 1 else graph[0]
|
|
369
|
+
return compacted
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class InsertDump:
|
|
373
|
+
"""An Insert Dumper for SQL statements."""
|
|
374
|
+
|
|
375
|
+
dialect: Any = None
|
|
376
|
+
|
|
377
|
+
def dump(self, sql: Any, *multiparams: Any, **params: Any) -> None:
|
|
378
|
+
compiled = sql.compile(dialect=self.dialect)
|
|
379
|
+
sql_str = str(compiled) + ";"
|
|
380
|
+
params_list = [compiled.params]
|
|
381
|
+
for params in params_list:
|
|
382
|
+
if not params:
|
|
383
|
+
print(sql_str)
|
|
384
|
+
continue
|
|
385
|
+
new_params = {}
|
|
386
|
+
for key, value in params.items():
|
|
387
|
+
if isinstance(value, str):
|
|
388
|
+
new_params[key] = f"'{value}'"
|
|
389
|
+
elif value is None:
|
|
390
|
+
new_params[key] = "null"
|
|
391
|
+
else:
|
|
392
|
+
new_params[key] = value
|
|
393
|
+
|
|
394
|
+
print(sql_str % new_params)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
if __name__ == "__main__":
|
|
398
|
+
cli()
|