laketower 0.5.1__py3-none-any.whl → 0.6.0__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/tables.py CHANGED
@@ -1,5 +1,6 @@
1
+ import enum
1
2
  from datetime import datetime, timezone
2
- from typing import Any, Protocol
3
+ from typing import Any, BinaryIO, Protocol, TextIO
3
4
 
4
5
  import deltalake
5
6
  import duckdb
@@ -17,6 +18,15 @@ from laketower.config import ConfigTable, TableFormats
17
18
  DEFAULT_LIMIT = 10
18
19
 
19
20
 
21
+ class ImportModeEnum(str, enum.Enum):
22
+ append = "append"
23
+ overwrite = "overwrite"
24
+
25
+
26
+ class ImportFileFormatEnum(str, enum.Enum):
27
+ csv = "csv"
28
+
29
+
20
30
  class TableMetadata(pydantic.BaseModel):
21
31
  table_format: TableFormats
22
32
  name: str | None = None
@@ -43,17 +53,112 @@ class TableHistory(pydantic.BaseModel):
43
53
 
44
54
 
45
55
  class TableProtocol(Protocol): # pragma: no cover
56
+ @classmethod
57
+ def is_valid(cls, table_config: ConfigTable) -> bool: ...
58
+ def __init__(self, table_config: ConfigTable) -> None: ...
46
59
  def metadata(self) -> TableMetadata: ...
47
60
  def schema(self) -> pa.Schema: ...
48
61
  def history(self) -> TableHistory: ...
49
62
  def dataset(self, version: int | str | None = None) -> padataset.Dataset: ...
63
+ def import_data(
64
+ self, data: pd.DataFrame, mode: ImportModeEnum = ImportModeEnum.append
65
+ ) -> None: ...
50
66
 
51
67
 
52
68
  class DeltaTable:
53
69
  def __init__(self, table_config: ConfigTable):
54
70
  super().__init__()
55
71
  self.table_config = table_config
56
- self._impl = deltalake.DeltaTable(table_config.uri)
72
+ storage_options = self._generate_storage_options(table_config)
73
+ self._impl = deltalake.DeltaTable(
74
+ table_config.uri, storage_options=storage_options
75
+ )
76
+
77
+ @classmethod
78
+ def _generate_storage_options(
79
+ cls, table_config: ConfigTable
80
+ ) -> dict[str, str] | None:
81
+ # documentation from `object-store` Rust crate:
82
+ # - s3: https://docs.rs/object_store/latest/object_store/aws/enum.AmazonS3ConfigKey.html
83
+ # - adls: https://docs.rs/object_store/latest/object_store/azure/enum.AzureConfigKey.html
84
+ storage_options = None
85
+ conn_s3 = (
86
+ table_config.connection.s3
87
+ if table_config.connection and table_config.connection.s3
88
+ else None
89
+ )
90
+ conn_adls = (
91
+ table_config.connection.adls
92
+ if table_config.connection and table_config.connection.adls
93
+ else None
94
+ )
95
+ if conn_s3:
96
+ storage_options = (
97
+ {
98
+ "aws_access_key_id": conn_s3.s3_access_key_id,
99
+ "aws_secret_access_key": conn_s3.s3_secret_access_key.get_secret_value(),
100
+ "aws_allow_http": str(conn_s3.s3_allow_http).lower(),
101
+ }
102
+ | ({"aws_region": conn_s3.s3_region} if conn_s3.s3_region else {})
103
+ | (
104
+ {"aws_endpoint_url": str(conn_s3.s3_endpoint_url).rstrip("/")}
105
+ if conn_s3.s3_endpoint_url
106
+ else {}
107
+ )
108
+ )
109
+ elif conn_adls:
110
+ storage_options = (
111
+ {
112
+ "azure_storage_account_name": conn_adls.adls_account_name,
113
+ "azure_use_azure_cli": str(conn_adls.use_azure_cli).lower(),
114
+ }
115
+ | (
116
+ {
117
+ "azure_storage_access_key": conn_adls.adls_access_key.get_secret_value()
118
+ }
119
+ if conn_adls.adls_access_key
120
+ else {}
121
+ )
122
+ | (
123
+ {"azure_storage_sas_key": conn_adls.adls_sas_key.get_secret_value()}
124
+ if conn_adls.adls_sas_key
125
+ else {}
126
+ )
127
+ | (
128
+ {"azure_storage_tenant_id": conn_adls.adls_tenant_id}
129
+ if conn_adls.adls_tenant_id
130
+ else {}
131
+ )
132
+ | (
133
+ {"azure_storage_client_id": conn_adls.adls_client_id}
134
+ if conn_adls.adls_client_id
135
+ else {}
136
+ )
137
+ | (
138
+ {
139
+ "azure_storage_client_secret": conn_adls.adls_client_secret.get_secret_value()
140
+ }
141
+ if conn_adls.adls_client_secret
142
+ else {}
143
+ )
144
+ | (
145
+ {
146
+ "azure_msi_endpoint": str(conn_adls.azure_msi_endpoint).rstrip(
147
+ "/"
148
+ )
149
+ }
150
+ if conn_adls.azure_msi_endpoint
151
+ else {}
152
+ )
153
+ )
154
+ return storage_options
155
+
156
+ @classmethod
157
+ def is_valid(cls, table_config: ConfigTable) -> bool:
158
+ storage_options = cls._generate_storage_options(table_config)
159
+ return deltalake.DeltaTable.is_deltatable(
160
+ table_config.uri, storage_options=storage_options
161
+ )
57
162
 
58
163
  def metadata(self) -> TableMetadata:
59
164
  metadata = self._impl.metadata()
@@ -96,10 +201,32 @@ class DeltaTable:
96
201
  self._impl.load_as_version(version)
97
202
  return self._impl.to_pyarrow_dataset()
98
203
 
204
+ def import_data(
205
+ self, data: pd.DataFrame, mode: ImportModeEnum = ImportModeEnum.append
206
+ ) -> None:
207
+ deltalake.write_deltalake(
208
+ self.table_config.uri, data, mode=mode.value, schema_mode="merge"
209
+ )
210
+
99
211
 
100
212
  def load_table(table_config: ConfigTable) -> TableProtocol:
101
- format_handler = {TableFormats.delta: DeltaTable}
102
- return format_handler[table_config.table_format](table_config)
213
+ format_handler: dict[TableFormats, type[TableProtocol]] = {
214
+ TableFormats.delta: DeltaTable
215
+ }
216
+ table_handler = format_handler[table_config.table_format]
217
+ if not table_handler.is_valid(table_config):
218
+ raise ValueError(f"Invalid table: {table_config.uri}")
219
+ return table_handler(table_config)
220
+
221
+
222
+ def load_datasets(table_configs: list[ConfigTable]) -> dict[str, padataset.Dataset]:
223
+ tables_dataset = {}
224
+ for table_config in table_configs:
225
+ try:
226
+ tables_dataset[table_config.name] = load_table(table_config).dataset()
227
+ except ValueError:
228
+ pass
229
+ return tables_dataset
103
230
 
104
231
 
105
232
  def generate_table_query(
@@ -110,21 +237,26 @@ def generate_table_query(
110
237
  sort_desc: str | None = None,
111
238
  ) -> str:
112
239
  query_expr = (
113
- sqlglot.select(*(cols or ["*"])).from_(table_name).limit(limit or DEFAULT_LIMIT)
240
+ sqlglot.select(*([f'"{col}"' for col in cols] if cols else ["*"]))
241
+ .from_(f'"{table_name}"')
242
+ .limit(limit or DEFAULT_LIMIT)
114
243
  )
115
244
  if sort_asc:
116
245
  query_expr = query_expr.order_by(f"{sort_asc} asc")
117
246
  elif sort_desc:
118
247
  query_expr = query_expr.order_by(f"{sort_desc} desc")
119
- return sqlglot.Generator(dialect=sqlglot.dialects.duckdb.DuckDB).generate(
120
- query_expr
121
- )
248
+ return query_expr.sql(dialect=sqlglot.dialects.duckdb.DuckDB, identify="always")
122
249
 
123
250
 
124
251
  def generate_table_statistics_query(table_name: str) -> str:
125
- return (
126
- f"SELECT column_name, count, avg, std, min, max FROM (SUMMARIZE {table_name})" # nosec B608
252
+ summarize_expr = sqlglot.expressions.Summarize(
253
+ this=sqlglot.expressions.Table(this=f'"{table_name}"')
127
254
  )
255
+ subquery_expr = sqlglot.expressions.Subquery(this=summarize_expr)
256
+ query_expr = sqlglot.select(
257
+ "column_name", "count", "avg", "std", "min", "max"
258
+ ).from_(subquery_expr)
259
+ return query_expr.sql(dialect=sqlglot.dialects.duckdb.DuckDB, identify="always")
128
260
 
129
261
 
130
262
  def execute_query(
@@ -133,9 +265,31 @@ def execute_query(
133
265
  try:
134
266
  conn = duckdb.connect()
135
267
  for table_name, table_dataset in tables_datasets.items():
268
+ # ATTACH IF NOT EXISTS ':memory:' AS {catalog.name};
269
+ # CREATE SCHEMA IF NOT EXISTS {catalog.name}.{database.name};
270
+ # USE {catalog.name}.{database.name};
271
+ # CREATE VIEW IF NOT EXISTS {table.name} AS FROM {table.name}_dataset;
272
+
136
273
  view_name = f"{table_name}_view"
137
274
  conn.register(view_name, table_dataset)
138
- conn.execute(f"create table {table_name} as select * from {view_name}") # nosec B608
275
+ conn.execute(f'create table "{table_name}" as select * from "{view_name}"') # nosec B608
139
276
  return conn.execute(sql_query).df()
140
277
  except duckdb.Error as e:
141
278
  raise ValueError(str(e)) from e
279
+
280
+
281
+ def import_file_to_table(
282
+ table_config: ConfigTable,
283
+ file_path: BinaryIO | TextIO,
284
+ mode: ImportModeEnum = ImportModeEnum.append,
285
+ file_format: ImportFileFormatEnum = ImportFileFormatEnum.csv,
286
+ delimiter: str = ",",
287
+ encoding: str = "utf-8",
288
+ ) -> int:
289
+ file_format_handler = {
290
+ ImportFileFormatEnum.csv: lambda f, d, e: pd.read_csv(f, sep=d, encoding=e)
291
+ }
292
+ table = load_table(table_config)
293
+ df = file_format_handler[file_format](file_path, delimiter, encoding)
294
+ table.import_data(df, mode=mode)
295
+ return len(df)
@@ -34,6 +34,12 @@
34
34
  {{ error.message }}
35
35
  </div>
36
36
  {% else %}
37
+ <div class="d-flex justify-content-between align-items-center mb-2">
38
+ <h3>Results</h3>
39
+ <a href="/tables/query/csv?sql={{ query.sql | urlencode }}" class="btn btn-outline-secondary btn-sm">
40
+ <i class="bi-download" aria-hidden="true"></i> Export CSV
41
+ </a>
42
+ </div>
37
43
  <div class="table-responsive">
38
44
  <table class="table table-sm table-bordered table-striped table-hover">
39
45
  <thead>
@@ -12,5 +12,8 @@
12
12
  <li class="nav-item">
13
13
  <a class="nav-link{% if current == 'history' %} active{% endif %}"{% if current == 'history' %} aria-current="true"{% endif %} href="/tables/{{ table_id }}/history">History</a>
14
14
  </li>
15
+ <li class="nav-item">
16
+ <a class="nav-link{% if current == 'import' %} active{% endif %}"{% if current == 'import' %} aria-current="true"{% endif %} href="/tables/{{ table_id }}/import">Import</a>
17
+ </li>
15
18
  </ul>
16
19
  {%- endmacro %}
@@ -2,6 +2,11 @@
2
2
  {% import 'tables/_macros.html' as table_macros %}
3
3
 
4
4
  {% block body %}
5
+ {% if error %}
6
+ <div class="alert alert-danger" role="alert">
7
+ {{ error.message }}
8
+ </div>
9
+ {% else %}
5
10
  {{ table_macros.table_nav(table_id, 'history') }}
6
11
 
7
12
  <div class="row">
@@ -39,4 +44,5 @@
39
44
  {% endfor %}
40
45
  </div>
41
46
  </div>
47
+ {% endif %}
42
48
  {% endblock %}
@@ -0,0 +1,71 @@
1
+ {% extends "_base.html" %}
2
+ {% import 'tables/_macros.html' as table_macros %}
3
+
4
+ {% block body %}
5
+ {{ table_macros.table_nav(table_id, 'import') }}
6
+
7
+ {% if message %}
8
+ {% if message.type == 'success' %}{% set alert_type = 'success' %}
9
+ {% elif message.type == 'error' %}{% set alert_type = 'danger' %}
10
+ {% else %}{% set alert_type = 'primary' %}
11
+ {% endif %}
12
+
13
+ <div class="alert alert-{{ alert_type }}" role="alert">
14
+ {{ message.body }}
15
+ </div>
16
+ {% endif %}
17
+
18
+ <div class="row justify-content-center">
19
+ <div class="col-12 col-md-8 col-lg-4">
20
+ <form action="{{ request.url.path }}" method="post" enctype="multipart/form-data">
21
+ <div class="mb-3">
22
+ <label for="import-file-input" class="form-label">Input file</label>
23
+ <input id="import-file-input" class="form-control" name="input_file" type="file" accept=".csv" required>
24
+ </div>
25
+
26
+ <div class="mb-3">
27
+ <label class="form-label">Mode</label>
28
+
29
+ <div class="form-check">
30
+ <input id="import-mode-append" class="form-check-input" name="mode" type="radio" value="append" checked>
31
+ <label for="import-mode-append" class="form-check-label">Append</label>
32
+ </div>
33
+ <div class="form-check">
34
+ <input id="import-mode-overwrite" class="form-check-input" name="mode" type="radio" value="overwrite">
35
+ <label for="import-mode-overwrite" class="form-check-label">Overwrite</label>
36
+ </div>
37
+ </div>
38
+
39
+ <div class="mb-3">
40
+ <label for="import-file-format" class="form-label">File format</label>
41
+ <select id="import-file-format" class="form-select" name="file_format">
42
+ <option value="csv" selected>CSV</option>
43
+ </select>
44
+ </div>
45
+
46
+ <div class="mb-3">
47
+ <label for="import-delimiter" class="form-label">Delimiter</label>
48
+ <input id="import-delimiter" class="form-control" name="delimiter" value="," required>
49
+ </div>
50
+
51
+ <div class="mb-3">
52
+ <label for="import-encoding" class="form-label">Encoding</label>
53
+ <select id="import-encoding" class="form-select" name="encoding">
54
+ <option value="utf-8" selected>UTF-8</option>
55
+ <option value="utf-16">UTF-16</option>
56
+ <option value="utf-32">UTF-32</option>
57
+ <option value="latin-1">Latin-1</option>
58
+ </select>
59
+ </div>
60
+
61
+ <div class="mb-3">
62
+ <div class="d-flex justify-content-end">
63
+ <button type="submit" class="btn btn-primary">
64
+ <i class="bi-upload" aria-hidden="true"></i> Import Data
65
+ </button>
66
+ </div>
67
+ </div>
68
+ </form>
69
+ </div>
70
+ </div>
71
+ {% endblock %}
@@ -2,6 +2,11 @@
2
2
  {% import 'tables/_macros.html' as table_macros %}
3
3
 
4
4
  {% block body %}
5
+ {% if error %}
6
+ <div class="alert alert-danger" role="alert">
7
+ {{ error.message }}
8
+ </div>
9
+ {% else %}
5
10
  {{ table_macros.table_nav(table_id, 'overview') }}
6
11
 
7
12
  <div class="row row-cols-1 row-cols-md-2 g-4">
@@ -81,4 +86,5 @@
81
86
  </div>
82
87
  </div>
83
88
  </div>
89
+ {% endif %}
84
90
  {% endblock %}
@@ -25,6 +25,12 @@
25
25
  {{ error.message }}
26
26
  </div>
27
27
  {% else %}
28
+ <div class="d-flex justify-content-between align-items-center mb-2">
29
+ <h3>Results</h3>
30
+ <a href="/tables/query/csv?sql={{ sql_query | urlencode }}" class="btn btn-outline-secondary btn-sm">
31
+ <i class="bi-download" aria-hidden="true"></i> Export CSV
32
+ </a>
33
+ </div>
28
34
  <div class="table-responsive">
29
35
  <table class="table table-sm table-bordered table-striped table-hover">
30
36
  <thead>
@@ -2,6 +2,11 @@
2
2
  {% import 'tables/_macros.html' as table_macros %}
3
3
 
4
4
  {% block body %}
5
+ {% if error %}
6
+ <div class="alert alert-danger" role="alert">
7
+ {{ error.message }}
8
+ </div>
9
+ {% else %}
5
10
  {{ table_macros.table_nav(table_id, 'statistics') }}
6
11
 
7
12
  <div class="row">
@@ -53,4 +58,5 @@
53
58
  </div>
54
59
  </div>
55
60
  </div>
61
+ {% endif %}
56
62
  {% endblock %}
@@ -2,6 +2,11 @@
2
2
  {% import 'tables/_macros.html' as table_macros %}
3
3
 
4
4
  {% block body %}
5
+ {% if error %}
6
+ <div class="alert alert-danger" role="alert">
7
+ {{ error.message }}
8
+ </div>
9
+ {% else %}
5
10
  {{ table_macros.table_nav(table_id, 'view') }}
6
11
 
7
12
  <div class="row">
@@ -93,4 +98,5 @@
93
98
  </div>
94
99
  </div>
95
100
  </div>
101
+ {% endif %}
96
102
  {% endblock %}
laketower/web.py CHANGED
@@ -3,17 +3,21 @@ from pathlib import Path
3
3
  from typing import Annotated
4
4
 
5
5
  import pydantic_settings
6
- from fastapi import APIRouter, FastAPI, Query, Request
7
- from fastapi.responses import HTMLResponse
6
+ from fastapi import APIRouter, FastAPI, File, Form, Query, Request, UploadFile
7
+ from fastapi.responses import HTMLResponse, Response
8
8
  from fastapi.staticfiles import StaticFiles
9
9
  from fastapi.templating import Jinja2Templates
10
10
 
11
11
  from laketower.config import Config, load_yaml_config
12
12
  from laketower.tables import (
13
13
  DEFAULT_LIMIT,
14
+ ImportFileFormatEnum,
15
+ ImportModeEnum,
14
16
  execute_query,
15
17
  generate_table_statistics_query,
16
18
  generate_table_query,
19
+ import_file_to_table,
20
+ load_datasets,
17
21
  load_table,
18
22
  )
19
23
 
@@ -55,10 +59,7 @@ def index(request: Request) -> HTMLResponse:
55
59
  @router.get("/tables/query", response_class=HTMLResponse)
56
60
  def get_tables_query(request: Request, sql: str) -> HTMLResponse:
57
61
  config: Config = request.app.state.config
58
- tables_dataset = {
59
- table_config.name: load_table(table_config).dataset()
60
- for table_config in config.tables
61
- }
62
+ tables_dataset = load_datasets(config.tables)
62
63
 
63
64
  try:
64
65
  results = execute_query(tables_dataset, sql)
@@ -80,13 +81,36 @@ def get_tables_query(request: Request, sql: str) -> HTMLResponse:
80
81
  )
81
82
 
82
83
 
84
+ @router.get("/tables/query/csv")
85
+ def export_tables_query_csv(request: Request, sql: str) -> Response:
86
+ config: Config = request.app.state.config
87
+ tables_dataset = load_datasets(config.tables)
88
+
89
+ results = execute_query(tables_dataset, sql)
90
+ csv_content = results.to_csv(header=True, index=False, sep=",")
91
+
92
+ return Response(
93
+ content=csv_content,
94
+ media_type="text/csv",
95
+ headers={"Content-Disposition": "attachment; filename=query_results.csv"},
96
+ )
97
+
98
+
83
99
  @router.get("/tables/{table_id}", response_class=HTMLResponse)
84
100
  def get_table_index(request: Request, table_id: str) -> HTMLResponse:
85
101
  config: Config = request.app.state.config
86
102
  table_config = next(
87
103
  filter(lambda table_config: table_config.name == table_id, config.tables)
88
104
  )
89
- table = load_table(table_config)
105
+ try:
106
+ table = load_table(table_config)
107
+ table_metadata = table.metadata()
108
+ table_schema = table.schema()
109
+ error = None
110
+ except ValueError as e:
111
+ error = {"message": str(e)}
112
+ table_metadata = None
113
+ table_schema = None
90
114
 
91
115
  return templates.TemplateResponse(
92
116
  request=request,
@@ -95,8 +119,9 @@ def get_table_index(request: Request, table_id: str) -> HTMLResponse:
95
119
  "tables": config.tables,
96
120
  "queries": config.queries,
97
121
  "table_id": table_id,
98
- "table_metadata": table.metadata(),
99
- "table_schema": table.schema(),
122
+ "table_metadata": table_metadata,
123
+ "table_schema": table_schema,
124
+ "error": error,
100
125
  },
101
126
  )
102
127
 
@@ -107,7 +132,13 @@ def get_table_history(request: Request, table_id: str) -> HTMLResponse:
107
132
  table_config = next(
108
133
  filter(lambda table_config: table_config.name == table_id, config.tables)
109
134
  )
110
- table = load_table(table_config)
135
+ try:
136
+ table = load_table(table_config)
137
+ table_history = table.history()
138
+ error = None
139
+ except ValueError as e:
140
+ error = {"message": str(e)}
141
+ table_history = None
111
142
 
112
143
  return templates.TemplateResponse(
113
144
  request=request,
@@ -116,7 +147,8 @@ def get_table_history(request: Request, table_id: str) -> HTMLResponse:
116
147
  "tables": config.tables,
117
148
  "queries": config.queries,
118
149
  "table_id": table_id,
119
- "table_history": table.history(),
150
+ "table_history": table_history,
151
+ "error": error,
120
152
  },
121
153
  )
122
154
 
@@ -131,12 +163,18 @@ def get_table_statistics(
131
163
  table_config = next(
132
164
  filter(lambda table_config: table_config.name == table_id, config.tables)
133
165
  )
134
- table = load_table(table_config)
135
- table_name = table_config.name
136
- table_metadata = table.metadata()
137
- table_dataset = table.dataset(version=version)
138
- sql_query = generate_table_statistics_query(table_name)
139
- query_results = execute_query({table_name: table_dataset}, sql_query)
166
+ try:
167
+ table = load_table(table_config)
168
+ table_name = table_config.name
169
+ table_metadata = table.metadata()
170
+ table_dataset = table.dataset(version=version)
171
+ sql_query = generate_table_statistics_query(table_name)
172
+ query_results = execute_query({table_name: table_dataset}, sql_query)
173
+ error = None
174
+ except ValueError as e:
175
+ error = {"message": str(e)}
176
+ table_metadata = None
177
+ query_results = None
140
178
 
141
179
  return templates.TemplateResponse(
142
180
  request=request,
@@ -147,6 +185,7 @@ def get_table_statistics(
147
185
  "table_id": table_id,
148
186
  "table_metadata": table_metadata,
149
187
  "table_results": query_results,
188
+ "error": error,
150
189
  },
151
190
  )
152
191
 
@@ -165,14 +204,21 @@ def get_table_view(
165
204
  table_config = next(
166
205
  filter(lambda table_config: table_config.name == table_id, config.tables)
167
206
  )
168
- table = load_table(table_config)
169
- table_name = table_config.name
170
- table_metadata = table.metadata()
171
- table_dataset = table.dataset(version=version)
172
- sql_query = generate_table_query(
173
- table_name, limit=limit, cols=cols, sort_asc=sort_asc, sort_desc=sort_desc
174
- )
175
- results = execute_query({table_name: table_dataset}, sql_query)
207
+ try:
208
+ table = load_table(table_config)
209
+ table_name = table_config.name
210
+ table_metadata = table.metadata()
211
+ table_dataset = table.dataset(version=version)
212
+ sql_query = generate_table_query(
213
+ table_name, limit=limit, cols=cols, sort_asc=sort_asc, sort_desc=sort_desc
214
+ )
215
+ results = execute_query({table_name: table_dataset}, sql_query)
216
+ error = None
217
+ except ValueError as e:
218
+ error = {"message": str(e)}
219
+ table_metadata = None
220
+ sql_query = None
221
+ results = None
176
222
 
177
223
  return templates.TemplateResponse(
178
224
  request=request,
@@ -185,6 +231,78 @@ def get_table_view(
185
231
  "table_results": results,
186
232
  "sql_query": sql_query,
187
233
  "default_limit": DEFAULT_LIMIT,
234
+ "error": error,
235
+ },
236
+ )
237
+
238
+
239
+ @router.get("/tables/{table_id}/import", response_class=HTMLResponse)
240
+ def get_table_import(
241
+ request: Request,
242
+ table_id: str,
243
+ ) -> HTMLResponse:
244
+ config: Config = request.app.state.config
245
+ table_config = next(
246
+ filter(lambda table_config: table_config.name == table_id, config.tables)
247
+ )
248
+ try:
249
+ table = load_table(table_config)
250
+ table_metadata = table.metadata()
251
+ message = None
252
+ except ValueError as e:
253
+ message = {"type": "error", "body": str(e)}
254
+ table_metadata = None
255
+
256
+ return templates.TemplateResponse(
257
+ request=request,
258
+ name="tables/import.html",
259
+ context={
260
+ "tables": config.tables,
261
+ "queries": config.queries,
262
+ "table_id": table_id,
263
+ "table_metadata": table_metadata,
264
+ "message": message,
265
+ },
266
+ )
267
+
268
+
269
+ @router.post("/tables/{table_id}/import", response_class=HTMLResponse)
270
+ def post_table_import(
271
+ request: Request,
272
+ table_id: str,
273
+ input_file: Annotated[UploadFile, File()],
274
+ mode: Annotated[ImportModeEnum, Form()],
275
+ file_format: Annotated[ImportFileFormatEnum, Form()],
276
+ delimiter: Annotated[str, Form()],
277
+ encoding: Annotated[str, Form()],
278
+ ) -> HTMLResponse:
279
+ config: Config = request.app.state.config
280
+ table_config = next(
281
+ filter(lambda table_config: table_config.name == table_id, config.tables)
282
+ )
283
+ try:
284
+ table = load_table(table_config)
285
+ table_metadata = table.metadata()
286
+ rows_imported = import_file_to_table(
287
+ table_config, input_file.file, mode, file_format, delimiter, encoding
288
+ )
289
+ message = {
290
+ "type": "success",
291
+ "body": f"Successfully imported {rows_imported} rows",
292
+ }
293
+ except Exception as e:
294
+ message = {"type": "error", "body": str(e)}
295
+ table_metadata = None
296
+
297
+ return templates.TemplateResponse(
298
+ request=request,
299
+ name="tables/import.html",
300
+ context={
301
+ "tables": config.tables,
302
+ "queries": config.queries,
303
+ "table_id": table_id,
304
+ "table_metadata": table_metadata,
305
+ "message": message,
188
306
  },
189
307
  )
190
308
 
@@ -195,10 +313,7 @@ def get_query_view(request: Request, query_id: str) -> HTMLResponse:
195
313
  query_config = next(
196
314
  filter(lambda query_config: query_config.name == query_id, config.queries)
197
315
  )
198
- tables_dataset = {
199
- table_config.name: load_table(table_config).dataset()
200
- for table_config in config.tables
201
- }
316
+ tables_dataset = load_datasets(config.tables)
202
317
 
203
318
  try:
204
319
  results = execute_query(tables_dataset, query_config.sql)