laketower 0.5.1__py3-none-any.whl → 0.6.5__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 laketower might be problematic. Click here for more details.
- laketower/__about__.py +1 -1
- laketower/cli.py +269 -101
- laketower/config.py +96 -14
- laketower/static/datatables.bundle.js +27931 -0
- laketower/static/datatables.js +55 -0
- laketower/static/editor.bundle.js +27433 -0
- laketower/static/editor.js +74 -0
- laketower/static/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- laketower/static/vendor/bootstrap-icons/bootstrap-icons.min.css +5 -0
- laketower/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- laketower/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- laketower/static/vendor/datatables.net-bs5/dataTables.bootstrap5.css +610 -0
- laketower/static/vendor/datatables.net-columncontrol-bs5/columnControl.bootstrap5.min.css +1 -0
- laketower/static/vendor/halfmoon/halfmoon.min.css +22 -0
- laketower/static/vendor/halfmoon/halfmoon.modern.css +282 -0
- laketower/tables.py +218 -16
- laketower/templates/_base.html +99 -20
- laketower/templates/queries/view.html +50 -8
- laketower/templates/tables/_macros.html +3 -0
- laketower/templates/tables/history.html +6 -0
- laketower/templates/tables/import.html +71 -0
- laketower/templates/tables/index.html +6 -0
- laketower/templates/tables/query.html +53 -7
- laketower/templates/tables/statistics.html +10 -4
- laketower/templates/tables/view.html +48 -42
- laketower/web.py +253 -30
- {laketower-0.5.1.dist-info → laketower-0.6.5.dist-info}/METADATA +189 -5
- laketower-0.6.5.dist-info/RECORD +35 -0
- laketower-0.6.5.dist-info/entry_points.txt +2 -0
- laketower-0.5.1.dist-info/RECORD +0 -22
- laketower-0.5.1.dist-info/entry_points.txt +0 -2
- {laketower-0.5.1.dist-info → laketower-0.6.5.dist-info}/WHEEL +0 -0
- {laketower-0.5.1.dist-info → laketower-0.6.5.dist-info}/licenses/LICENSE +0 -0
laketower/__about__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.5
|
|
1
|
+
__version__ = "0.6.5"
|
laketower/cli.py
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
import argparse
|
|
2
2
|
import os
|
|
3
|
+
import time
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
import rich.jupyter
|
|
6
7
|
import rich.panel
|
|
8
|
+
import rich.style
|
|
7
9
|
import rich.table
|
|
8
10
|
import rich.text
|
|
9
11
|
import rich.tree
|
|
10
12
|
import uvicorn
|
|
13
|
+
import pyarrow.csv as pacsv
|
|
11
14
|
|
|
12
15
|
from laketower.__about__ import __version__
|
|
13
16
|
from laketower.config import load_yaml_config
|
|
14
17
|
from laketower.tables import (
|
|
18
|
+
ImportFileFormatEnum,
|
|
19
|
+
ImportModeEnum,
|
|
15
20
|
execute_query,
|
|
21
|
+
extract_query_parameter_names,
|
|
16
22
|
generate_table_query,
|
|
17
23
|
generate_table_statistics_query,
|
|
24
|
+
import_file_to_table,
|
|
25
|
+
limit_query,
|
|
26
|
+
load_datasets,
|
|
18
27
|
load_table,
|
|
19
28
|
)
|
|
20
29
|
|
|
@@ -47,77 +56,95 @@ def list_tables(config_path: Path) -> None:
|
|
|
47
56
|
|
|
48
57
|
|
|
49
58
|
def table_metadata(config_path: Path, table_name: str) -> None:
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
out: rich.jupyter.JupyterMixin
|
|
60
|
+
try:
|
|
61
|
+
config = load_yaml_config(config_path)
|
|
62
|
+
table_config = next(filter(lambda x: x.name == table_name, config.tables))
|
|
63
|
+
table = load_table(table_config)
|
|
64
|
+
metadata = table.metadata()
|
|
65
|
+
|
|
66
|
+
out = rich.tree.Tree(table_name)
|
|
67
|
+
out.add(f"name: {metadata.name}")
|
|
68
|
+
out.add(f"description: {metadata.description}")
|
|
69
|
+
out.add(f"format: {metadata.table_format.value}")
|
|
70
|
+
out.add(f"uri: {metadata.uri}")
|
|
71
|
+
out.add(f"id: {metadata.id}")
|
|
72
|
+
out.add(f"version: {metadata.version}")
|
|
73
|
+
out.add(f"created at: {metadata.created_at}")
|
|
74
|
+
out.add(f"partitions: {', '.join(metadata.partitions)}")
|
|
75
|
+
out.add(f"configuration: {metadata.configuration}")
|
|
76
|
+
except Exception as e:
|
|
77
|
+
out = rich.panel.Panel.fit(f"[red]{e}")
|
|
78
|
+
|
|
65
79
|
console = rich.get_console()
|
|
66
|
-
console.print(
|
|
80
|
+
console.print(out)
|
|
67
81
|
|
|
68
82
|
|
|
69
83
|
def table_schema(config_path: Path, table_name: str) -> None:
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
84
|
+
out: rich.jupyter.JupyterMixin
|
|
85
|
+
try:
|
|
86
|
+
config = load_yaml_config(config_path)
|
|
87
|
+
table_config = next(filter(lambda x: x.name == table_name, config.tables))
|
|
88
|
+
table = load_table(table_config)
|
|
89
|
+
schema = table.schema()
|
|
90
|
+
|
|
91
|
+
out = rich.tree.Tree(table_name)
|
|
92
|
+
for field in schema:
|
|
93
|
+
nullable = "" if field.nullable else " not null"
|
|
94
|
+
out.add(f"{field.name}: {field.type}{nullable}")
|
|
95
|
+
except Exception as e:
|
|
96
|
+
out = rich.panel.Panel.fit(f"[red]{e}")
|
|
97
|
+
|
|
79
98
|
console = rich.get_console()
|
|
80
|
-
console.print(
|
|
99
|
+
console.print(out, markup=False) # disable markup to allow bracket characters
|
|
81
100
|
|
|
82
101
|
|
|
83
102
|
def table_history(config_path: Path, table_name: str) -> None:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
tree_op_params.add(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
tree_op_metrics.add(
|
|
103
|
+
out: rich.jupyter.JupyterMixin
|
|
104
|
+
try:
|
|
105
|
+
config = load_yaml_config(config_path)
|
|
106
|
+
table_config = next(filter(lambda x: x.name == table_name, config.tables))
|
|
107
|
+
table = load_table(table_config)
|
|
108
|
+
history = table.history()
|
|
109
|
+
|
|
110
|
+
out = rich.tree.Tree(table_name)
|
|
111
|
+
for rev in history.revisions:
|
|
112
|
+
tree_version = out.add(f"version: {rev.version}")
|
|
113
|
+
tree_version.add(f"timestamp: {rev.timestamp}")
|
|
114
|
+
tree_version.add(f"client version: {rev.client_version}")
|
|
115
|
+
tree_version.add(f"operation: {rev.operation}")
|
|
116
|
+
tree_op_params = tree_version.add("operation parameters")
|
|
117
|
+
for param_key, param_val in rev.operation_parameters.items():
|
|
118
|
+
tree_op_params.add(f"{param_key}: {param_val}")
|
|
119
|
+
tree_op_metrics = tree_version.add("operation metrics")
|
|
120
|
+
for metric_key, metric_val in rev.operation_metrics.items():
|
|
121
|
+
tree_op_metrics.add(f"{metric_key}: {metric_val}")
|
|
122
|
+
except Exception as e:
|
|
123
|
+
out = rich.panel.Panel.fit(f"[red]{e}")
|
|
124
|
+
|
|
101
125
|
console = rich.get_console()
|
|
102
|
-
console.print(
|
|
126
|
+
console.print(out, markup=False)
|
|
103
127
|
|
|
104
128
|
|
|
105
129
|
def table_statistics(
|
|
106
130
|
config_path: Path, table_name: str, version: int | None = None
|
|
107
131
|
) -> None:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
out.
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
132
|
+
out: rich.jupyter.JupyterMixin
|
|
133
|
+
try:
|
|
134
|
+
config = load_yaml_config(config_path)
|
|
135
|
+
table_config = next(filter(lambda x: x.name == table_name, config.tables))
|
|
136
|
+
table = load_table(table_config)
|
|
137
|
+
table_dataset = table.dataset(version=version)
|
|
138
|
+
sql_query = generate_table_statistics_query(table_name)
|
|
139
|
+
results = execute_query({table_name: table_dataset}, sql_query)
|
|
140
|
+
|
|
141
|
+
out = rich.table.Table()
|
|
142
|
+
for column in results.column_names:
|
|
143
|
+
out.add_column(column)
|
|
144
|
+
for row_dict in results.to_pylist():
|
|
145
|
+
out.add_row(*[str(row_dict[col]) for col in results.column_names])
|
|
146
|
+
except Exception as e:
|
|
147
|
+
out = rich.panel.Panel.fit(f"[red]{e}")
|
|
121
148
|
|
|
122
149
|
console = rich.get_console()
|
|
123
150
|
console.print(out, markup=False) # disable markup to allow bracket characters
|
|
@@ -132,42 +159,77 @@ def view_table(
|
|
|
132
159
|
sort_desc: str | None = None,
|
|
133
160
|
version: int | None = None,
|
|
134
161
|
) -> None:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
162
|
+
out: rich.jupyter.JupyterMixin
|
|
163
|
+
try:
|
|
164
|
+
config = load_yaml_config(config_path)
|
|
165
|
+
table_config = next(filter(lambda x: x.name == table_name, config.tables))
|
|
166
|
+
table = load_table(table_config)
|
|
167
|
+
table_dataset = table.dataset(version=version)
|
|
168
|
+
sql_query = generate_table_query(
|
|
169
|
+
table_name, limit=limit, cols=cols, sort_asc=sort_asc, sort_desc=sort_desc
|
|
170
|
+
)
|
|
171
|
+
results = execute_query({table_name: table_dataset}, sql_query)
|
|
143
172
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
173
|
+
out = rich.table.Table()
|
|
174
|
+
for column in results.column_names:
|
|
175
|
+
out.add_column(column)
|
|
176
|
+
for row_dict in results.to_pylist():
|
|
177
|
+
out.add_row(*[str(row_dict[col]) for col in results.column_names])
|
|
178
|
+
except Exception as e:
|
|
179
|
+
out = rich.panel.Panel.fit(f"[red]{e}")
|
|
150
180
|
|
|
151
181
|
console = rich.get_console()
|
|
152
182
|
console.print(out)
|
|
153
183
|
|
|
154
184
|
|
|
155
|
-
def query_table(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
185
|
+
def query_table(
|
|
186
|
+
config_path: Path,
|
|
187
|
+
sql_query: str,
|
|
188
|
+
sql_params: list[list[str]] = [],
|
|
189
|
+
output_path: Path | None = None,
|
|
190
|
+
) -> None:
|
|
162
191
|
out: rich.jupyter.JupyterMixin
|
|
163
192
|
try:
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
for
|
|
193
|
+
config = load_yaml_config(config_path)
|
|
194
|
+
tables_dataset = load_datasets(config.tables)
|
|
195
|
+
sql_params_dict = {param[0]: param[1] for param in sql_params}
|
|
196
|
+
query_param_names = extract_query_parameter_names(sql_query)
|
|
197
|
+
query_params = {
|
|
198
|
+
name: sql_params_dict.get(name) or "" for name in query_param_names
|
|
199
|
+
}
|
|
200
|
+
limited_sql_query = limit_query(sql_query, config.settings.max_query_rows + 1)
|
|
201
|
+
|
|
202
|
+
start_time = time.perf_counter()
|
|
203
|
+
results = execute_query(
|
|
204
|
+
tables_dataset, limited_sql_query, sql_params=query_params
|
|
205
|
+
)
|
|
206
|
+
execution_time_ms = (time.perf_counter() - start_time) * 1000
|
|
207
|
+
|
|
208
|
+
truncated = results.num_rows > config.settings.max_query_rows
|
|
209
|
+
results = results.slice(
|
|
210
|
+
0, min(results.num_rows, config.settings.max_query_rows)
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
out = rich.table.Table(
|
|
214
|
+
caption=(
|
|
215
|
+
f"{results.num_rows} rows returned{' (truncated)' if truncated else ''}"
|
|
216
|
+
f"\nExecution time: {execution_time_ms:.2f}ms"
|
|
217
|
+
),
|
|
218
|
+
caption_justify="left",
|
|
219
|
+
caption_style=rich.style.Style(dim=True),
|
|
220
|
+
)
|
|
221
|
+
for column in results.column_names:
|
|
167
222
|
out.add_column(column)
|
|
168
|
-
for
|
|
169
|
-
|
|
170
|
-
|
|
223
|
+
for row_dict in results.to_pylist():
|
|
224
|
+
out.add_row(*[str(row_dict[col]) for col in results.column_names])
|
|
225
|
+
|
|
226
|
+
if output_path is not None:
|
|
227
|
+
pacsv.write_csv(
|
|
228
|
+
results,
|
|
229
|
+
output_path,
|
|
230
|
+
pacsv.WriteOptions(include_header=True, delimiter=","),
|
|
231
|
+
)
|
|
232
|
+
out = rich.text.Text(f"Query results written to: {output_path}")
|
|
171
233
|
except ValueError as e:
|
|
172
234
|
out = rich.panel.Panel.fit(f"[red]{e}")
|
|
173
235
|
|
|
@@ -175,6 +237,33 @@ def query_table(config_path: Path, sql_query: str) -> None:
|
|
|
175
237
|
console.print(out)
|
|
176
238
|
|
|
177
239
|
|
|
240
|
+
def import_table(
|
|
241
|
+
config_path: Path,
|
|
242
|
+
table_name: str,
|
|
243
|
+
file_path: Path,
|
|
244
|
+
mode: ImportModeEnum,
|
|
245
|
+
file_format: ImportFileFormatEnum,
|
|
246
|
+
delimiter: str,
|
|
247
|
+
encoding: str,
|
|
248
|
+
) -> None:
|
|
249
|
+
out: rich.jupyter.JupyterMixin
|
|
250
|
+
try:
|
|
251
|
+
config = load_yaml_config(config_path)
|
|
252
|
+
table_config = next(filter(lambda x: x.name == table_name, config.tables))
|
|
253
|
+
with open(file_path, "rb") as file_content:
|
|
254
|
+
rows_imported = import_file_to_table(
|
|
255
|
+
table_config, file_content, mode, file_format, delimiter, encoding
|
|
256
|
+
)
|
|
257
|
+
out = rich.text.Text(
|
|
258
|
+
f"Successfully imported {rows_imported} rows into table '{table_name}' in '{mode.value}' mode"
|
|
259
|
+
)
|
|
260
|
+
except Exception as e:
|
|
261
|
+
out = rich.panel.Panel.fit(f"[red]{e}")
|
|
262
|
+
|
|
263
|
+
console = rich.get_console()
|
|
264
|
+
console.print(out)
|
|
265
|
+
|
|
266
|
+
|
|
178
267
|
def list_queries(config_path: Path) -> None:
|
|
179
268
|
config = load_yaml_config(config_path)
|
|
180
269
|
tree = rich.tree.Tree("queries")
|
|
@@ -184,24 +273,47 @@ def list_queries(config_path: Path) -> None:
|
|
|
184
273
|
console.print(tree)
|
|
185
274
|
|
|
186
275
|
|
|
187
|
-
def view_query(
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
sql_query = query_config.sql
|
|
191
|
-
tables_dataset = {
|
|
192
|
-
table_config.name: load_table(table_config).dataset()
|
|
193
|
-
for table_config in config.tables
|
|
194
|
-
}
|
|
195
|
-
|
|
276
|
+
def view_query(
|
|
277
|
+
config_path: Path, query_name: str, query_params: list[list[str]] = []
|
|
278
|
+
) -> None:
|
|
196
279
|
out: rich.jupyter.JupyterMixin
|
|
197
280
|
try:
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
281
|
+
config = load_yaml_config(config_path)
|
|
282
|
+
tables_dataset = load_datasets(config.tables)
|
|
283
|
+
query_config = next(filter(lambda x: x.name == query_name, config.queries))
|
|
284
|
+
default_parameters = {k: v.default for k, v in query_config.parameters.items()}
|
|
285
|
+
sql_query = query_config.sql
|
|
286
|
+
query_params_dict = {param[0]: param[1] for param in query_params}
|
|
287
|
+
sql_param_names = extract_query_parameter_names(sql_query)
|
|
288
|
+
sql_params = {
|
|
289
|
+
name: query_params_dict.get(name) or default_parameters.get(name) or ""
|
|
290
|
+
for name in sql_param_names
|
|
291
|
+
}
|
|
292
|
+
limited_sql_query = limit_query(sql_query, config.settings.max_query_rows + 1)
|
|
293
|
+
|
|
294
|
+
start_time = time.perf_counter()
|
|
295
|
+
results = execute_query(
|
|
296
|
+
tables_dataset, limited_sql_query, sql_params=sql_params
|
|
297
|
+
)
|
|
298
|
+
execution_time_ms = (time.perf_counter() - start_time) * 1000
|
|
299
|
+
|
|
300
|
+
truncated = results.num_rows > config.settings.max_query_rows
|
|
301
|
+
results = results.slice(
|
|
302
|
+
0, min(results.num_rows, config.settings.max_query_rows)
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
out = rich.table.Table(
|
|
306
|
+
caption=(
|
|
307
|
+
f"{results.num_rows} rows returned{' (truncated)' if truncated else ''}"
|
|
308
|
+
f"\nExecution time: {execution_time_ms:.2f}ms"
|
|
309
|
+
),
|
|
310
|
+
caption_justify="left",
|
|
311
|
+
caption_style=rich.style.Style(dim=True),
|
|
312
|
+
)
|
|
313
|
+
for column in results.column_names:
|
|
201
314
|
out.add_column(column)
|
|
202
|
-
for
|
|
203
|
-
|
|
204
|
-
out.add_row(*row)
|
|
315
|
+
for row_dict in results.to_pylist():
|
|
316
|
+
out.add_row(*[str(row_dict[col]) for col in results.column_names])
|
|
205
317
|
except ValueError as e:
|
|
206
318
|
out = rich.panel.Panel.fit(f"[red]{e}")
|
|
207
319
|
|
|
@@ -310,8 +422,54 @@ def cli() -> None:
|
|
|
310
422
|
parser_tables_query = subsparsers_tables.add_parser(
|
|
311
423
|
"query", help="Query registered tables"
|
|
312
424
|
)
|
|
425
|
+
parser_tables_query.add_argument(
|
|
426
|
+
"--output", help="Output query results to a file (default format: CSV)"
|
|
427
|
+
)
|
|
428
|
+
parser_tables_query.add_argument(
|
|
429
|
+
"--param",
|
|
430
|
+
"-p",
|
|
431
|
+
nargs=2,
|
|
432
|
+
action="append",
|
|
433
|
+
default=[],
|
|
434
|
+
help="Inject query named parameters values",
|
|
435
|
+
)
|
|
313
436
|
parser_tables_query.add_argument("sql", help="SQL query to execute")
|
|
314
|
-
parser_tables_query.set_defaults(
|
|
437
|
+
parser_tables_query.set_defaults(
|
|
438
|
+
func=lambda x: query_table(x.config, x.sql, x.param, x.output)
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
parser_tables_import = subsparsers_tables.add_parser(
|
|
442
|
+
"import", help="Import data into a table"
|
|
443
|
+
)
|
|
444
|
+
parser_tables_import.add_argument("table", help="Name of the table")
|
|
445
|
+
parser_tables_import.add_argument(
|
|
446
|
+
"--file", type=Path, required=True, help="Path to file to import"
|
|
447
|
+
)
|
|
448
|
+
parser_tables_import.add_argument(
|
|
449
|
+
"--mode",
|
|
450
|
+
choices=[mode.value for mode in ImportModeEnum],
|
|
451
|
+
default=ImportModeEnum.append.value,
|
|
452
|
+
type=ImportModeEnum,
|
|
453
|
+
help=f"Import mode (default: {ImportModeEnum.append.value})",
|
|
454
|
+
)
|
|
455
|
+
parser_tables_import.add_argument(
|
|
456
|
+
"--format",
|
|
457
|
+
choices=[file_format.value for file_format in ImportFileFormatEnum],
|
|
458
|
+
default=ImportFileFormatEnum.csv.value,
|
|
459
|
+
type=ImportFileFormatEnum,
|
|
460
|
+
help=f"File format (default: {ImportFileFormatEnum.csv.value})",
|
|
461
|
+
)
|
|
462
|
+
parser_tables_import.add_argument(
|
|
463
|
+
"--delimiter", default=",", help="Column delimiter to use (default: ',')"
|
|
464
|
+
)
|
|
465
|
+
parser_tables_import.add_argument(
|
|
466
|
+
"--encoding", default="utf-8", help="File encoding to use (default: 'utf-8')"
|
|
467
|
+
)
|
|
468
|
+
parser_tables_import.set_defaults(
|
|
469
|
+
func=lambda x: import_table(
|
|
470
|
+
x.config, x.table, x.file, x.mode, x.format, x.delimiter, x.encoding
|
|
471
|
+
)
|
|
472
|
+
)
|
|
315
473
|
|
|
316
474
|
parser_queries = subparsers.add_parser("queries", help="Work with queries")
|
|
317
475
|
subsparsers_queries = parser_queries.add_subparsers(required=True)
|
|
@@ -325,7 +483,17 @@ def cli() -> None:
|
|
|
325
483
|
"view", help="View a given query"
|
|
326
484
|
)
|
|
327
485
|
parser_queries_view.add_argument("query", help="Name of the query")
|
|
328
|
-
parser_queries_view.
|
|
486
|
+
parser_queries_view.add_argument(
|
|
487
|
+
"--param",
|
|
488
|
+
"-p",
|
|
489
|
+
nargs=2,
|
|
490
|
+
action="append",
|
|
491
|
+
default=[],
|
|
492
|
+
help="Inject query named parameters values",
|
|
493
|
+
)
|
|
494
|
+
parser_queries_view.set_defaults(
|
|
495
|
+
func=lambda x: view_query(x.config, x.query, x.param)
|
|
496
|
+
)
|
|
329
497
|
|
|
330
498
|
args = parser.parse_args()
|
|
331
499
|
args.func(args)
|
laketower/config.py
CHANGED
|
@@ -1,47 +1,129 @@
|
|
|
1
1
|
import enum
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
2
4
|
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
3
6
|
|
|
4
|
-
import deltalake
|
|
5
7
|
import pydantic
|
|
6
8
|
import yaml
|
|
7
9
|
|
|
8
10
|
|
|
11
|
+
def substitute_env_vars(config_data: Any) -> Any:
|
|
12
|
+
"""
|
|
13
|
+
Substitute environment variables within the input payload.
|
|
14
|
+
|
|
15
|
+
Only allowed format:
|
|
16
|
+
```python
|
|
17
|
+
{
|
|
18
|
+
"some_key": {"env": "VAR_NAME"}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
If the "env" key MUST BE the only key in the dict to be processed.
|
|
22
|
+
|
|
23
|
+
The content of the environment variable will be loaded with a JSON parser,
|
|
24
|
+
so it can contain complex and nested structures (default is a string).
|
|
25
|
+
```
|
|
26
|
+
"""
|
|
27
|
+
match config_data:
|
|
28
|
+
case {"env": str(var_name)} if len(config_data) == 1:
|
|
29
|
+
# Handle environment variable substitution
|
|
30
|
+
env_value = os.getenv(var_name)
|
|
31
|
+
if env_value is None:
|
|
32
|
+
raise ValueError(f"environment variable '{var_name}' is not set")
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
return json.loads(env_value)
|
|
36
|
+
except json.JSONDecodeError:
|
|
37
|
+
return env_value
|
|
38
|
+
|
|
39
|
+
case dict() as config_dict:
|
|
40
|
+
# Process dictionary recursively
|
|
41
|
+
return {
|
|
42
|
+
key: substitute_env_vars(value) for key, value in config_dict.items()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
case list() as config_list:
|
|
46
|
+
# Process list recursively
|
|
47
|
+
return [substitute_env_vars(item) for item in config_list]
|
|
48
|
+
|
|
49
|
+
case _:
|
|
50
|
+
# Return primitive values unchanged
|
|
51
|
+
return config_data
|
|
52
|
+
|
|
53
|
+
|
|
9
54
|
class TableFormats(str, enum.Enum):
|
|
10
55
|
delta = "delta"
|
|
11
56
|
|
|
12
57
|
|
|
58
|
+
class ConfigTableConnectionS3(pydantic.BaseModel):
|
|
59
|
+
s3_access_key_id: str
|
|
60
|
+
s3_secret_access_key: pydantic.SecretStr
|
|
61
|
+
s3_region: str | None = None
|
|
62
|
+
s3_endpoint_url: pydantic.AnyHttpUrl | None = None
|
|
63
|
+
s3_allow_http: bool = False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ConfigTableConnectionADLS(pydantic.BaseModel):
|
|
67
|
+
adls_account_name: str
|
|
68
|
+
adls_access_key: pydantic.SecretStr | None = None
|
|
69
|
+
adls_sas_key: pydantic.SecretStr | None = None
|
|
70
|
+
adls_tenant_id: str | None = None
|
|
71
|
+
adls_client_id: str | None = None
|
|
72
|
+
adls_client_secret: pydantic.SecretStr | None = None
|
|
73
|
+
azure_msi_endpoint: pydantic.AnyHttpUrl | None = None
|
|
74
|
+
use_azure_cli: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ConfigTableConnection(pydantic.BaseModel):
|
|
78
|
+
s3: ConfigTableConnectionS3 | None = None
|
|
79
|
+
adls: ConfigTableConnectionADLS | None = None
|
|
80
|
+
|
|
81
|
+
@pydantic.model_validator(mode="after")
|
|
82
|
+
def mutually_exclusive_connectors(self) -> "ConfigTableConnection":
|
|
83
|
+
connectors = [self.s3, self.adls]
|
|
84
|
+
non_null_connectors = list(filter(None, connectors))
|
|
85
|
+
if len(non_null_connectors) > 1:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"only one connection type can be specified among: 's3', 'adls'"
|
|
88
|
+
)
|
|
89
|
+
return self
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ConfigSettingsWeb(pydantic.BaseModel):
|
|
93
|
+
hide_tables: bool = False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class ConfigSettings(pydantic.BaseModel):
|
|
97
|
+
max_query_rows: int = 1_000
|
|
98
|
+
web: ConfigSettingsWeb = ConfigSettingsWeb()
|
|
99
|
+
|
|
100
|
+
|
|
13
101
|
class ConfigTable(pydantic.BaseModel):
|
|
14
102
|
name: str
|
|
15
103
|
uri: str
|
|
16
104
|
table_format: TableFormats = pydantic.Field(alias="format")
|
|
105
|
+
connection: ConfigTableConnection | None = None
|
|
17
106
|
|
|
18
|
-
@pydantic.model_validator(mode="after")
|
|
19
|
-
def check_table(self) -> "ConfigTable":
|
|
20
|
-
def check_delta_table(table_uri: str) -> None:
|
|
21
|
-
if not deltalake.DeltaTable.is_deltatable(table_uri):
|
|
22
|
-
raise ValueError(f"{table_uri} is not a valid Delta table")
|
|
23
|
-
|
|
24
|
-
format_check = {TableFormats.delta: check_delta_table}
|
|
25
|
-
format_check[self.table_format](self.uri)
|
|
26
107
|
|
|
27
|
-
|
|
108
|
+
class ConfigQueryParameter(pydantic.BaseModel):
|
|
109
|
+
default: str
|
|
28
110
|
|
|
29
111
|
|
|
30
112
|
class ConfigQuery(pydantic.BaseModel):
|
|
31
113
|
name: str
|
|
32
114
|
title: str
|
|
115
|
+
description: str | None = None
|
|
116
|
+
parameters: dict[str, ConfigQueryParameter] = {}
|
|
33
117
|
sql: str
|
|
34
118
|
|
|
35
119
|
|
|
36
|
-
class ConfigDashboard(pydantic.BaseModel):
|
|
37
|
-
name: str
|
|
38
|
-
|
|
39
|
-
|
|
40
120
|
class Config(pydantic.BaseModel):
|
|
121
|
+
settings: ConfigSettings = ConfigSettings()
|
|
41
122
|
tables: list[ConfigTable] = []
|
|
42
123
|
queries: list[ConfigQuery] = []
|
|
43
124
|
|
|
44
125
|
|
|
45
126
|
def load_yaml_config(config_path: Path) -> Config:
|
|
46
127
|
config_dict = yaml.safe_load(config_path.read_text())
|
|
128
|
+
config_dict = substitute_env_vars(config_dict)
|
|
47
129
|
return Config.model_validate(config_dict)
|