laketower 0.5.1__py3-none-any.whl → 0.6.1__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.

@@ -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 %}
@@ -8,9 +8,21 @@
8
8
 
9
9
  <form action="{{ request.url.path }}" method="get">
10
10
  <div class="mb-3">
11
- <textarea name="sql" rows="5" class="form-control">{{ sql_query }}</textarea>
11
+ <textarea id="sql-editor" name="sql" rows="5" class="form-control">{{ sql_query }}</textarea>
12
12
  </div>
13
13
 
14
+ {% if sql_params|length %}
15
+ <h3 class="mb-3">Parameters</h3>
16
+ {% for param_name, param_value in sql_params.items() %}
17
+ <div class="row mb-3">
18
+ <label for="param-{{ param_name }}" class="col-form-label col-sm-2">{{ param_name }}</label>
19
+ <div class="col-sm-4">
20
+ <input id="param-{{ param_name }}" class="form-control" name="{{ param_name }}" value="{{ param_value }}">
21
+ </div>
22
+ </div>
23
+ {% endfor %}
24
+ {% endif %}
25
+
14
26
  <div class="mb-3">
15
27
  <div class="d-flex justify-content-end">
16
28
  <button type="submit" class="btn btn-primary">
@@ -25,6 +37,12 @@
25
37
  {{ error.message }}
26
38
  </div>
27
39
  {% else %}
40
+ <div class="d-flex justify-content-between align-items-center mb-2">
41
+ <h3>Results</h3>
42
+ <a href="/tables/query/csv?sql={{ sql_query | urlencode }}" class="btn btn-outline-secondary btn-sm">
43
+ <i class="bi-download" aria-hidden="true"></i> Export CSV
44
+ </a>
45
+ </div>
28
46
  <div class="table-responsive">
29
47
  <table class="table table-sm table-bordered table-striped table-hover">
30
48
  <thead>
@@ -48,4 +66,16 @@
48
66
  {% endif %}
49
67
  </div>
50
68
  </div>
69
+ {% endblock %}
70
+
71
+ {% block extra_scripts %}
72
+ <script src="{{ url_for('static', path='/editor.bundle.js') }}"></script>
73
+ <script>
74
+ window.addEventListener("DOMContentLoaded", () => {
75
+ const textArea = document.querySelector("textarea#sql-editor")
76
+ textArea.style.display = "none";
77
+ const sqlSchema = {{ sql_schema | tojson }}
78
+ const sqlEditor = editor.createEditor(textArea, { readOnly: false, dialect: 'duckdb', schema: sqlSchema})
79
+ })
80
+ </script>
51
81
  {% 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, '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
@@ -1,19 +1,28 @@
1
1
  import urllib.parse
2
+ from dataclasses import dataclass
2
3
  from pathlib import Path
3
4
  from typing import Annotated
4
5
 
6
+ import bleach
7
+ import markdown
5
8
  import pydantic_settings
6
- from fastapi import APIRouter, FastAPI, Query, Request
7
- from fastapi.responses import HTMLResponse
9
+ from fastapi import APIRouter, FastAPI, File, Form, Query, Request, UploadFile
10
+ from fastapi.responses import HTMLResponse, RedirectResponse, Response
8
11
  from fastapi.staticfiles import StaticFiles
9
12
  from fastapi.templating import Jinja2Templates
10
13
 
14
+ from laketower import __about__
11
15
  from laketower.config import Config, load_yaml_config
12
16
  from laketower.tables import (
13
17
  DEFAULT_LIMIT,
18
+ ImportFileFormatEnum,
19
+ ImportModeEnum,
14
20
  execute_query,
21
+ extract_query_parameter_names,
15
22
  generate_table_statistics_query,
16
23
  generate_table_query,
24
+ import_file_to_table,
25
+ load_datasets,
17
26
  load_table,
18
27
  )
19
28
 
@@ -22,6 +31,12 @@ class Settings(pydantic_settings.BaseSettings):
22
31
  laketower_config_path: Path
23
32
 
24
33
 
34
+ @dataclass(frozen=True)
35
+ class AppMetadata:
36
+ app_name: str
37
+ app_version: str
38
+
39
+
25
40
  def current_path_with_args(request: Request, args: list[tuple[str, str]]) -> str:
26
41
  keys_to_update = set(arg[0] for arg in args)
27
42
  query_params = request.query_params.multi_items()
@@ -33,19 +48,28 @@ def current_path_with_args(request: Request, args: list[tuple[str, str]]) -> str
33
48
  return f"{request.url.path}?{query_string}"
34
49
 
35
50
 
51
+ def render_markdown(md_text: str) -> str:
52
+ return bleach.clean(
53
+ markdown.markdown(md_text), tags=bleach.sanitizer.ALLOWED_TAGS | {"p"}
54
+ )
55
+
56
+
36
57
  templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
37
58
  templates.env.filters["current_path_with_args"] = current_path_with_args
59
+ templates.env.filters["render_markdown"] = render_markdown
38
60
 
39
61
  router = APIRouter()
40
62
 
41
63
 
42
64
  @router.get("/", response_class=HTMLResponse)
43
65
  def index(request: Request) -> HTMLResponse:
66
+ app_metadata: AppMetadata = request.app.state.app_metadata
44
67
  config: Config = request.app.state.config
45
68
  return templates.TemplateResponse(
46
69
  request=request,
47
70
  name="index.html",
48
71
  context={
72
+ "app_metadata": app_metadata,
49
73
  "tables": config.tables,
50
74
  "queries": config.queries,
51
75
  },
@@ -54,14 +78,20 @@ def index(request: Request) -> HTMLResponse:
54
78
 
55
79
  @router.get("/tables/query", response_class=HTMLResponse)
56
80
  def get_tables_query(request: Request, sql: str) -> HTMLResponse:
81
+ app_metadata: AppMetadata = request.app.state.app_metadata
57
82
  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
83
+ tables_dataset = load_datasets(config.tables)
84
+ sql_schema = {
85
+ table_name: dataset.schema.names
86
+ for table_name, dataset in tables_dataset.items()
87
+ }
88
+ sql_param_names = extract_query_parameter_names(sql)
89
+ sql_params = {
90
+ name: request.query_params.get(name) or "" for name in sql_param_names
61
91
  }
62
92
 
63
93
  try:
64
- results = execute_query(tables_dataset, sql)
94
+ results = execute_query(tables_dataset, sql, sql_params=sql_params)
65
95
  error = None
66
96
  except ValueError as e:
67
97
  error = {"message": str(e)}
@@ -71,52 +101,90 @@ def get_tables_query(request: Request, sql: str) -> HTMLResponse:
71
101
  request=request,
72
102
  name="tables/query.html",
73
103
  context={
104
+ "app_metadata": app_metadata,
74
105
  "tables": config.tables,
75
106
  "queries": config.queries,
76
107
  "table_results": results,
77
108
  "sql_query": sql,
109
+ "sql_schema": sql_schema,
110
+ "sql_params": sql_params,
78
111
  "error": error,
79
112
  },
80
113
  )
81
114
 
82
115
 
116
+ @router.get("/tables/query/csv")
117
+ def export_tables_query_csv(request: Request, sql: str) -> Response:
118
+ config: Config = request.app.state.config
119
+ tables_dataset = load_datasets(config.tables)
120
+
121
+ results = execute_query(tables_dataset, sql)
122
+ csv_content = results.to_csv(header=True, index=False, sep=",")
123
+
124
+ return Response(
125
+ content=csv_content,
126
+ media_type="text/csv",
127
+ headers={"Content-Disposition": "attachment; filename=query_results.csv"},
128
+ )
129
+
130
+
83
131
  @router.get("/tables/{table_id}", response_class=HTMLResponse)
84
132
  def get_table_index(request: Request, table_id: str) -> HTMLResponse:
133
+ app_metadata: AppMetadata = request.app.state.app_metadata
85
134
  config: Config = request.app.state.config
86
135
  table_config = next(
87
136
  filter(lambda table_config: table_config.name == table_id, config.tables)
88
137
  )
89
- table = load_table(table_config)
138
+ try:
139
+ table = load_table(table_config)
140
+ table_metadata = table.metadata()
141
+ table_schema = table.schema()
142
+ error = None
143
+ except ValueError as e:
144
+ error = {"message": str(e)}
145
+ table_metadata = None
146
+ table_schema = None
90
147
 
91
148
  return templates.TemplateResponse(
92
149
  request=request,
93
150
  name="tables/index.html",
94
151
  context={
152
+ "app_metadata": app_metadata,
95
153
  "tables": config.tables,
96
154
  "queries": config.queries,
97
155
  "table_id": table_id,
98
- "table_metadata": table.metadata(),
99
- "table_schema": table.schema(),
156
+ "table_metadata": table_metadata,
157
+ "table_schema": table_schema,
158
+ "error": error,
100
159
  },
101
160
  )
102
161
 
103
162
 
104
163
  @router.get("/tables/{table_id}/history", response_class=HTMLResponse)
105
164
  def get_table_history(request: Request, table_id: str) -> HTMLResponse:
165
+ app_metadata: AppMetadata = request.app.state.app_metadata
106
166
  config: Config = request.app.state.config
107
167
  table_config = next(
108
168
  filter(lambda table_config: table_config.name == table_id, config.tables)
109
169
  )
110
- table = load_table(table_config)
170
+ try:
171
+ table = load_table(table_config)
172
+ table_history = table.history()
173
+ error = None
174
+ except ValueError as e:
175
+ error = {"message": str(e)}
176
+ table_history = None
111
177
 
112
178
  return templates.TemplateResponse(
113
179
  request=request,
114
180
  name="tables/history.html",
115
181
  context={
182
+ "app_metadata": app_metadata,
116
183
  "tables": config.tables,
117
184
  "queries": config.queries,
118
185
  "table_id": table_id,
119
- "table_history": table.history(),
186
+ "table_history": table_history,
187
+ "error": error,
120
188
  },
121
189
  )
122
190
 
@@ -127,26 +195,35 @@ def get_table_statistics(
127
195
  table_id: str,
128
196
  version: int | None = None,
129
197
  ) -> HTMLResponse:
198
+ app_metadata: AppMetadata = request.app.state.app_metadata
130
199
  config: Config = request.app.state.config
131
200
  table_config = next(
132
201
  filter(lambda table_config: table_config.name == table_id, config.tables)
133
202
  )
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)
203
+ try:
204
+ table = load_table(table_config)
205
+ table_name = table_config.name
206
+ table_metadata = table.metadata()
207
+ table_dataset = table.dataset(version=version)
208
+ sql_query = generate_table_statistics_query(table_name)
209
+ query_results = execute_query({table_name: table_dataset}, sql_query)
210
+ error = None
211
+ except ValueError as e:
212
+ error = {"message": str(e)}
213
+ table_metadata = None
214
+ query_results = None
140
215
 
141
216
  return templates.TemplateResponse(
142
217
  request=request,
143
218
  name="tables/statistics.html",
144
219
  context={
220
+ "app_metadata": app_metadata,
145
221
  "tables": config.tables,
146
222
  "queries": config.queries,
147
223
  "table_id": table_id,
148
224
  "table_metadata": table_metadata,
149
225
  "table_results": query_results,
226
+ "error": error,
150
227
  },
151
228
  )
152
229
 
@@ -161,23 +238,32 @@ def get_table_view(
161
238
  sort_desc: str | None = None,
162
239
  version: int | None = None,
163
240
  ) -> HTMLResponse:
241
+ app_metadata: AppMetadata = request.app.state.app_metadata
164
242
  config: Config = request.app.state.config
165
243
  table_config = next(
166
244
  filter(lambda table_config: table_config.name == table_id, config.tables)
167
245
  )
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)
246
+ try:
247
+ table = load_table(table_config)
248
+ table_name = table_config.name
249
+ table_metadata = table.metadata()
250
+ table_dataset = table.dataset(version=version)
251
+ sql_query = generate_table_query(
252
+ table_name, limit=limit, cols=cols, sort_asc=sort_asc, sort_desc=sort_desc
253
+ )
254
+ results = execute_query({table_name: table_dataset}, sql_query)
255
+ error = None
256
+ except ValueError as e:
257
+ error = {"message": str(e)}
258
+ table_metadata = None
259
+ sql_query = None
260
+ results = None
176
261
 
177
262
  return templates.TemplateResponse(
178
263
  request=request,
179
264
  name="tables/view.html",
180
265
  context={
266
+ "app_metadata": app_metadata,
181
267
  "tables": config.tables,
182
268
  "queries": config.queries,
183
269
  "table_id": table_id,
@@ -185,23 +271,118 @@ def get_table_view(
185
271
  "table_results": results,
186
272
  "sql_query": sql_query,
187
273
  "default_limit": DEFAULT_LIMIT,
274
+ "error": error,
275
+ },
276
+ )
277
+
278
+
279
+ @router.get("/tables/{table_id}/import", response_class=HTMLResponse)
280
+ def get_table_import(
281
+ request: Request,
282
+ table_id: str,
283
+ ) -> HTMLResponse:
284
+ app_metadata: AppMetadata = request.app.state.app_metadata
285
+ config: Config = request.app.state.config
286
+ table_config = next(
287
+ filter(lambda table_config: table_config.name == table_id, config.tables)
288
+ )
289
+ try:
290
+ table = load_table(table_config)
291
+ table_metadata = table.metadata()
292
+ message = None
293
+ except ValueError 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
+ "app_metadata": app_metadata,
302
+ "tables": config.tables,
303
+ "queries": config.queries,
304
+ "table_id": table_id,
305
+ "table_metadata": table_metadata,
306
+ "message": message,
307
+ },
308
+ )
309
+
310
+
311
+ @router.post("/tables/{table_id}/import", response_class=HTMLResponse)
312
+ def post_table_import(
313
+ request: Request,
314
+ table_id: str,
315
+ input_file: Annotated[UploadFile, File()],
316
+ mode: Annotated[ImportModeEnum, Form()],
317
+ file_format: Annotated[ImportFileFormatEnum, Form()],
318
+ delimiter: Annotated[str, Form()],
319
+ encoding: Annotated[str, Form()],
320
+ ) -> HTMLResponse:
321
+ app_metadata: AppMetadata = request.app.state.app_metadata
322
+ config: Config = request.app.state.config
323
+ table_config = next(
324
+ filter(lambda table_config: table_config.name == table_id, config.tables)
325
+ )
326
+ try:
327
+ table = load_table(table_config)
328
+ table_metadata = table.metadata()
329
+ rows_imported = import_file_to_table(
330
+ table_config, input_file.file, mode, file_format, delimiter, encoding
331
+ )
332
+ message = {
333
+ "type": "success",
334
+ "body": f"Successfully imported {rows_imported} rows",
335
+ }
336
+ except Exception as e:
337
+ message = {"type": "error", "body": str(e)}
338
+ table_metadata = None
339
+
340
+ return templates.TemplateResponse(
341
+ request=request,
342
+ name="tables/import.html",
343
+ context={
344
+ "app_metadata": app_metadata,
345
+ "tables": config.tables,
346
+ "queries": config.queries,
347
+ "table_id": table_id,
348
+ "table_metadata": table_metadata,
349
+ "message": message,
188
350
  },
189
351
  )
190
352
 
191
353
 
192
354
  @router.get("/queries/{query_id}/view", response_class=HTMLResponse)
193
- def get_query_view(request: Request, query_id: str) -> HTMLResponse:
355
+ def get_query_view(request: Request, query_id: str) -> Response:
356
+ app_metadata: AppMetadata = request.app.state.app_metadata
194
357
  config: Config = request.app.state.config
195
358
  query_config = next(
196
359
  filter(lambda query_config: query_config.name == query_id, config.queries)
197
360
  )
198
- tables_dataset = {
199
- table_config.name: load_table(table_config).dataset()
200
- for table_config in config.tables
361
+
362
+ if (
363
+ len(request.query_params.keys()) == 0
364
+ and len(query_config.parameters.keys()) > 0
365
+ ):
366
+ default_parameters = {k: v.default for k, v in query_config.parameters.items()}
367
+ url = request.url_for("get_query_view", query_id=query_id)
368
+ query_params = urllib.parse.urlencode(default_parameters)
369
+ return RedirectResponse(f"{url}?{query_params}")
370
+
371
+ tables_dataset = load_datasets(config.tables)
372
+ sql_param_names = extract_query_parameter_names(query_config.sql)
373
+ sql_params = {
374
+ name: request.query_params.get(name)
375
+ or (
376
+ query_param.default
377
+ if (query_param := query_config.parameters.get(name))
378
+ else None
379
+ )
380
+ or ""
381
+ for name in sql_param_names
201
382
  }
202
383
 
203
384
  try:
204
- results = execute_query(tables_dataset, query_config.sql)
385
+ results = execute_query(tables_dataset, query_config.sql, sql_params=sql_params)
205
386
  error = None
206
387
  except ValueError as e:
207
388
  error = {"message": str(e)}
@@ -211,10 +392,12 @@ def get_query_view(request: Request, query_id: str) -> HTMLResponse:
211
392
  request=request,
212
393
  name="queries/view.html",
213
394
  context={
395
+ "app_metadata": app_metadata,
214
396
  "tables": config.tables,
215
397
  "queries": config.queries,
216
398
  "query": query_config,
217
399
  "query_results": results,
400
+ "sql_params": sql_params,
218
401
  "error": error,
219
402
  },
220
403
  )
@@ -231,6 +414,9 @@ def create_app() -> FastAPI:
231
414
  name="static",
232
415
  )
233
416
  app.include_router(router)
417
+ app.state.app_metadata = AppMetadata(
418
+ app_name="Laketower", app_version=__about__.__version__
419
+ )
234
420
  app.state.config = config
235
421
 
236
422
  return app