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.

Files changed (33) hide show
  1. laketower/__about__.py +1 -1
  2. laketower/cli.py +269 -101
  3. laketower/config.py +96 -14
  4. laketower/static/datatables.bundle.js +27931 -0
  5. laketower/static/datatables.js +55 -0
  6. laketower/static/editor.bundle.js +27433 -0
  7. laketower/static/editor.js +74 -0
  8. laketower/static/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
  9. laketower/static/vendor/bootstrap-icons/bootstrap-icons.min.css +5 -0
  10. laketower/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
  11. laketower/static/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
  12. laketower/static/vendor/datatables.net-bs5/dataTables.bootstrap5.css +610 -0
  13. laketower/static/vendor/datatables.net-columncontrol-bs5/columnControl.bootstrap5.min.css +1 -0
  14. laketower/static/vendor/halfmoon/halfmoon.min.css +22 -0
  15. laketower/static/vendor/halfmoon/halfmoon.modern.css +282 -0
  16. laketower/tables.py +218 -16
  17. laketower/templates/_base.html +99 -20
  18. laketower/templates/queries/view.html +50 -8
  19. laketower/templates/tables/_macros.html +3 -0
  20. laketower/templates/tables/history.html +6 -0
  21. laketower/templates/tables/import.html +71 -0
  22. laketower/templates/tables/index.html +6 -0
  23. laketower/templates/tables/query.html +53 -7
  24. laketower/templates/tables/statistics.html +10 -4
  25. laketower/templates/tables/view.html +48 -42
  26. laketower/web.py +253 -30
  27. {laketower-0.5.1.dist-info → laketower-0.6.5.dist-info}/METADATA +189 -5
  28. laketower-0.6.5.dist-info/RECORD +35 -0
  29. laketower-0.6.5.dist-info/entry_points.txt +2 -0
  30. laketower-0.5.1.dist-info/RECORD +0 -22
  31. laketower-0.5.1.dist-info/entry_points.txt +0 -2
  32. {laketower-0.5.1.dist-info → laketower-0.6.5.dist-info}/WHEEL +0 -0
  33. {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"
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
- config = load_yaml_config(config_path)
51
- table_config = next(filter(lambda x: x.name == table_name, config.tables))
52
- table = load_table(table_config)
53
- metadata = table.metadata()
54
-
55
- tree = rich.tree.Tree(table_name)
56
- tree.add(f"name: {metadata.name}")
57
- tree.add(f"description: {metadata.description}")
58
- tree.add(f"format: {metadata.table_format.value}")
59
- tree.add(f"uri: {metadata.uri}")
60
- tree.add(f"id: {metadata.id}")
61
- tree.add(f"version: {metadata.version}")
62
- tree.add(f"created at: {metadata.created_at}")
63
- tree.add(f"partitions: {', '.join(metadata.partitions)}")
64
- tree.add(f"configuration: {metadata.configuration}")
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(tree)
80
+ console.print(out)
67
81
 
68
82
 
69
83
  def table_schema(config_path: Path, table_name: str) -> None:
70
- config = load_yaml_config(config_path)
71
- table_config = next(filter(lambda x: x.name == table_name, config.tables))
72
- table = load_table(table_config)
73
- schema = table.schema()
74
-
75
- tree = rich.tree.Tree(table_name)
76
- for field in schema:
77
- nullable = "" if field.nullable else " not null"
78
- tree.add(f"{field.name}: {field.type}{nullable}")
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(tree, markup=False) # disable markup to allow bracket characters
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
- config = load_yaml_config(config_path)
85
- table_config = next(filter(lambda x: x.name == table_name, config.tables))
86
- table = load_table(table_config)
87
- history = table.history()
88
-
89
- tree = rich.tree.Tree(table_name)
90
- for rev in history.revisions:
91
- tree_version = tree.add(f"version: {rev.version}")
92
- tree_version.add(f"timestamp: {rev.timestamp}")
93
- tree_version.add(f"client version: {rev.client_version}")
94
- tree_version.add(f"operation: {rev.operation}")
95
- tree_op_params = tree_version.add("operation parameters")
96
- for param_key, param_val in rev.operation_parameters.items():
97
- tree_op_params.add(f"{param_key}: {param_val}")
98
- tree_op_metrics = tree_version.add("operation metrics")
99
- for metric_key, metric_val in rev.operation_metrics.items():
100
- tree_op_metrics.add(f"{metric_key}: {metric_val}")
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(tree, markup=False)
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
- config = load_yaml_config(config_path)
109
- table_config = next(filter(lambda x: x.name == table_name, config.tables))
110
- table = load_table(table_config)
111
- table_dataset = table.dataset(version=version)
112
- sql_query = generate_table_statistics_query(table_name)
113
- results = execute_query({table_name: table_dataset}, sql_query)
114
-
115
- out = rich.table.Table()
116
- for column in results.columns:
117
- out.add_column(column)
118
- for value_list in results.to_numpy().tolist():
119
- row = [str(x) for x in value_list]
120
- out.add_row(*row)
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
- config = load_yaml_config(config_path)
136
- table_config = next(filter(lambda x: x.name == table_name, config.tables))
137
- table = load_table(table_config)
138
- table_dataset = table.dataset(version=version)
139
- sql_query = generate_table_query(
140
- table_name, limit=limit, cols=cols, sort_asc=sort_asc, sort_desc=sort_desc
141
- )
142
- results = execute_query({table_name: table_dataset}, sql_query)
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
- out = rich.table.Table()
145
- for column in results.columns:
146
- out.add_column(column)
147
- for value_list in results.to_numpy().tolist():
148
- row = [str(x) for x in value_list]
149
- out.add_row(*row)
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(config_path: Path, sql_query: str) -> None:
156
- config = load_yaml_config(config_path)
157
- tables_dataset = {
158
- table_config.name: load_table(table_config).dataset()
159
- for table_config in config.tables
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
- results = execute_query(tables_dataset, sql_query)
165
- out = rich.table.Table()
166
- for column in results.columns:
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 value_list in results.values.tolist():
169
- row = [str(x) for x in value_list]
170
- out.add_row(*row)
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(config_path: Path, query_name: str) -> None:
188
- config = load_yaml_config(config_path)
189
- query_config = next(filter(lambda x: x.name == query_name, config.queries))
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
- results = execute_query(tables_dataset, sql_query)
199
- out = rich.table.Table()
200
- for column in results.columns:
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 value_list in results.values.tolist():
203
- row = [str(x) for x in value_list]
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(func=lambda x: query_table(x.config, x.sql))
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.set_defaults(func=lambda x: view_query(x.config, x.query))
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
- return self
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)