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/web.py CHANGED
@@ -1,19 +1,32 @@
1
+ import io
2
+ import time
1
3
  import urllib.parse
4
+ from dataclasses import dataclass
2
5
  from pathlib import Path
3
6
  from typing import Annotated
4
7
 
8
+ import bleach
9
+ import markdown
10
+ import pyarrow.csv as pacsv
5
11
  import pydantic_settings
6
- from fastapi import APIRouter, FastAPI, Query, Request
7
- from fastapi.responses import HTMLResponse
12
+ from fastapi import APIRouter, FastAPI, File, Form, Query, Request, UploadFile
13
+ from fastapi.responses import HTMLResponse, RedirectResponse, Response
8
14
  from fastapi.staticfiles import StaticFiles
9
15
  from fastapi.templating import Jinja2Templates
10
16
 
17
+ from laketower import __about__
11
18
  from laketower.config import Config, load_yaml_config
12
19
  from laketower.tables import (
13
20
  DEFAULT_LIMIT,
21
+ ImportFileFormatEnum,
22
+ ImportModeEnum,
14
23
  execute_query,
24
+ extract_query_parameter_names,
15
25
  generate_table_statistics_query,
16
26
  generate_table_query,
27
+ import_file_to_table,
28
+ limit_query,
29
+ load_datasets,
17
30
  load_table,
18
31
  )
19
32
 
@@ -22,6 +35,12 @@ class Settings(pydantic_settings.BaseSettings):
22
35
  laketower_config_path: Path
23
36
 
24
37
 
38
+ @dataclass(frozen=True)
39
+ class AppMetadata:
40
+ app_name: str
41
+ app_version: str
42
+
43
+
25
44
  def current_path_with_args(request: Request, args: list[tuple[str, str]]) -> str:
26
45
  keys_to_update = set(arg[0] for arg in args)
27
46
  query_params = request.query_params.multi_items()
@@ -33,19 +52,28 @@ def current_path_with_args(request: Request, args: list[tuple[str, str]]) -> str
33
52
  return f"{request.url.path}?{query_string}"
34
53
 
35
54
 
55
+ def render_markdown(md_text: str) -> str:
56
+ return bleach.clean(
57
+ markdown.markdown(md_text), tags=bleach.sanitizer.ALLOWED_TAGS | {"p"}
58
+ )
59
+
60
+
36
61
  templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
37
62
  templates.env.filters["current_path_with_args"] = current_path_with_args
63
+ templates.env.filters["render_markdown"] = render_markdown
38
64
 
39
65
  router = APIRouter()
40
66
 
41
67
 
42
68
  @router.get("/", response_class=HTMLResponse)
43
69
  def index(request: Request) -> HTMLResponse:
70
+ app_metadata: AppMetadata = request.app.state.app_metadata
44
71
  config: Config = request.app.state.config
45
72
  return templates.TemplateResponse(
46
73
  request=request,
47
74
  name="index.html",
48
75
  context={
76
+ "app_metadata": app_metadata,
49
77
  "tables": config.tables,
50
78
  "queries": config.queries,
51
79
  },
@@ -54,69 +82,133 @@ def index(request: Request) -> HTMLResponse:
54
82
 
55
83
  @router.get("/tables/query", response_class=HTMLResponse)
56
84
  def get_tables_query(request: Request, sql: str) -> HTMLResponse:
85
+ app_metadata: AppMetadata = request.app.state.app_metadata
57
86
  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
87
+ tables_dataset = load_datasets(config.tables)
88
+ sql_schema = {
89
+ table_name: dataset.schema.names
90
+ for table_name, dataset in tables_dataset.items()
61
91
  }
62
92
 
63
93
  try:
64
- results = execute_query(tables_dataset, sql)
94
+ sql_param_names = extract_query_parameter_names(sql)
95
+ sql_params = {
96
+ name: request.query_params.get(name) or "" for name in sql_param_names
97
+ }
98
+ except ValueError:
99
+ sql_params = {}
100
+
101
+ try:
102
+ sql_query = limit_query(sql, config.settings.max_query_rows + 1)
103
+
104
+ start_time = time.perf_counter()
105
+ results = execute_query(tables_dataset, sql_query, sql_params=sql_params)
106
+ execution_time_ms = (time.perf_counter() - start_time) * 1000
107
+
108
+ truncated = results.num_rows > config.settings.max_query_rows
109
+ results = results.slice(
110
+ 0, min(results.num_rows, config.settings.max_query_rows)
111
+ )
65
112
  error = None
66
113
  except ValueError as e:
67
114
  error = {"message": str(e)}
68
115
  results = None
116
+ truncated = False
117
+ execution_time_ms = None
69
118
 
70
119
  return templates.TemplateResponse(
71
120
  request=request,
72
121
  name="tables/query.html",
73
122
  context={
123
+ "app_metadata": app_metadata,
74
124
  "tables": config.tables,
75
125
  "queries": config.queries,
76
126
  "table_results": results,
127
+ "truncated_results": truncated,
128
+ "execution_time_ms": execution_time_ms,
77
129
  "sql_query": sql,
130
+ "sql_schema": sql_schema,
131
+ "sql_params": sql_params,
78
132
  "error": error,
79
133
  },
80
134
  )
81
135
 
82
136
 
137
+ @router.get("/tables/query/csv")
138
+ def export_tables_query_csv(request: Request, sql: str) -> Response:
139
+ config: Config = request.app.state.config
140
+ tables_dataset = load_datasets(config.tables)
141
+
142
+ results = execute_query(tables_dataset, sql)
143
+ csv_content = io.BytesIO()
144
+ pacsv.write_csv(
145
+ results, csv_content, pacsv.WriteOptions(include_header=True, delimiter=",")
146
+ )
147
+
148
+ return Response(
149
+ content=csv_content.getvalue(),
150
+ media_type="text/csv",
151
+ headers={"Content-Disposition": "attachment; filename=query_results.csv"},
152
+ )
153
+
154
+
83
155
  @router.get("/tables/{table_id}", response_class=HTMLResponse)
84
156
  def get_table_index(request: Request, table_id: str) -> HTMLResponse:
157
+ app_metadata: AppMetadata = request.app.state.app_metadata
85
158
  config: Config = request.app.state.config
86
159
  table_config = next(
87
160
  filter(lambda table_config: table_config.name == table_id, config.tables)
88
161
  )
89
- table = load_table(table_config)
162
+ try:
163
+ table = load_table(table_config)
164
+ table_metadata = table.metadata()
165
+ table_schema = table.schema()
166
+ error = None
167
+ except ValueError as e:
168
+ error = {"message": str(e)}
169
+ table_metadata = None
170
+ table_schema = None
90
171
 
91
172
  return templates.TemplateResponse(
92
173
  request=request,
93
174
  name="tables/index.html",
94
175
  context={
176
+ "app_metadata": app_metadata,
95
177
  "tables": config.tables,
96
178
  "queries": config.queries,
97
179
  "table_id": table_id,
98
- "table_metadata": table.metadata(),
99
- "table_schema": table.schema(),
180
+ "table_metadata": table_metadata,
181
+ "table_schema": table_schema,
182
+ "error": error,
100
183
  },
101
184
  )
102
185
 
103
186
 
104
187
  @router.get("/tables/{table_id}/history", response_class=HTMLResponse)
105
188
  def get_table_history(request: Request, table_id: str) -> HTMLResponse:
189
+ app_metadata: AppMetadata = request.app.state.app_metadata
106
190
  config: Config = request.app.state.config
107
191
  table_config = next(
108
192
  filter(lambda table_config: table_config.name == table_id, config.tables)
109
193
  )
110
- table = load_table(table_config)
194
+ try:
195
+ table = load_table(table_config)
196
+ table_history = table.history()
197
+ error = None
198
+ except ValueError as e:
199
+ error = {"message": str(e)}
200
+ table_history = None
111
201
 
112
202
  return templates.TemplateResponse(
113
203
  request=request,
114
204
  name="tables/history.html",
115
205
  context={
206
+ "app_metadata": app_metadata,
116
207
  "tables": config.tables,
117
208
  "queries": config.queries,
118
209
  "table_id": table_id,
119
- "table_history": table.history(),
210
+ "table_history": table_history,
211
+ "error": error,
120
212
  },
121
213
  )
122
214
 
@@ -127,26 +219,35 @@ def get_table_statistics(
127
219
  table_id: str,
128
220
  version: int | None = None,
129
221
  ) -> HTMLResponse:
222
+ app_metadata: AppMetadata = request.app.state.app_metadata
130
223
  config: Config = request.app.state.config
131
224
  table_config = next(
132
225
  filter(lambda table_config: table_config.name == table_id, config.tables)
133
226
  )
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)
227
+ try:
228
+ table = load_table(table_config)
229
+ table_name = table_config.name
230
+ table_metadata = table.metadata()
231
+ table_dataset = table.dataset(version=version)
232
+ sql_query = generate_table_statistics_query(table_name)
233
+ query_results = execute_query({table_name: table_dataset}, sql_query)
234
+ error = None
235
+ except ValueError as e:
236
+ error = {"message": str(e)}
237
+ table_metadata = None
238
+ query_results = None
140
239
 
141
240
  return templates.TemplateResponse(
142
241
  request=request,
143
242
  name="tables/statistics.html",
144
243
  context={
244
+ "app_metadata": app_metadata,
145
245
  "tables": config.tables,
146
246
  "queries": config.queries,
147
247
  "table_id": table_id,
148
248
  "table_metadata": table_metadata,
149
249
  "table_results": query_results,
250
+ "error": error,
150
251
  },
151
252
  )
152
253
 
@@ -161,23 +262,32 @@ def get_table_view(
161
262
  sort_desc: str | None = None,
162
263
  version: int | None = None,
163
264
  ) -> HTMLResponse:
265
+ app_metadata: AppMetadata = request.app.state.app_metadata
164
266
  config: Config = request.app.state.config
165
267
  table_config = next(
166
268
  filter(lambda table_config: table_config.name == table_id, config.tables)
167
269
  )
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)
270
+ try:
271
+ table = load_table(table_config)
272
+ table_name = table_config.name
273
+ table_metadata = table.metadata()
274
+ table_dataset = table.dataset(version=version)
275
+ sql_query = generate_table_query(
276
+ table_name, limit=limit, cols=cols, sort_asc=sort_asc, sort_desc=sort_desc
277
+ )
278
+ results = execute_query({table_name: table_dataset}, sql_query)
279
+ error = None
280
+ except ValueError as e:
281
+ error = {"message": str(e)}
282
+ table_metadata = None
283
+ sql_query = None
284
+ results = None
176
285
 
177
286
  return templates.TemplateResponse(
178
287
  request=request,
179
288
  name="tables/view.html",
180
289
  context={
290
+ "app_metadata": app_metadata,
181
291
  "tables": config.tables,
182
292
  "queries": config.queries,
183
293
  "table_id": table_id,
@@ -185,36 +295,146 @@ def get_table_view(
185
295
  "table_results": results,
186
296
  "sql_query": sql_query,
187
297
  "default_limit": DEFAULT_LIMIT,
298
+ "error": error,
299
+ },
300
+ )
301
+
302
+
303
+ @router.get("/tables/{table_id}/import", response_class=HTMLResponse)
304
+ def get_table_import(
305
+ request: Request,
306
+ table_id: str,
307
+ ) -> HTMLResponse:
308
+ app_metadata: AppMetadata = request.app.state.app_metadata
309
+ config: Config = request.app.state.config
310
+ table_config = next(
311
+ filter(lambda table_config: table_config.name == table_id, config.tables)
312
+ )
313
+ try:
314
+ table = load_table(table_config)
315
+ table_metadata = table.metadata()
316
+ message = None
317
+ except ValueError as e:
318
+ message = {"type": "error", "body": str(e)}
319
+ table_metadata = None
320
+
321
+ return templates.TemplateResponse(
322
+ request=request,
323
+ name="tables/import.html",
324
+ context={
325
+ "app_metadata": app_metadata,
326
+ "tables": config.tables,
327
+ "queries": config.queries,
328
+ "table_id": table_id,
329
+ "table_metadata": table_metadata,
330
+ "message": message,
331
+ },
332
+ )
333
+
334
+
335
+ @router.post("/tables/{table_id}/import", response_class=HTMLResponse)
336
+ def post_table_import(
337
+ request: Request,
338
+ table_id: str,
339
+ input_file: Annotated[UploadFile, File()],
340
+ mode: Annotated[ImportModeEnum, Form()],
341
+ file_format: Annotated[ImportFileFormatEnum, Form()],
342
+ delimiter: Annotated[str, Form()],
343
+ encoding: Annotated[str, Form()],
344
+ ) -> HTMLResponse:
345
+ app_metadata: AppMetadata = request.app.state.app_metadata
346
+ config: Config = request.app.state.config
347
+ table_config = next(
348
+ filter(lambda table_config: table_config.name == table_id, config.tables)
349
+ )
350
+ try:
351
+ table = load_table(table_config)
352
+ table_metadata = table.metadata()
353
+ rows_imported = import_file_to_table(
354
+ table_config, input_file.file, mode, file_format, delimiter, encoding
355
+ )
356
+ message = {
357
+ "type": "success",
358
+ "body": f"Successfully imported {rows_imported} rows",
359
+ }
360
+ except Exception as e:
361
+ message = {"type": "error", "body": str(e)}
362
+ table_metadata = None
363
+
364
+ return templates.TemplateResponse(
365
+ request=request,
366
+ name="tables/import.html",
367
+ context={
368
+ "app_metadata": app_metadata,
369
+ "tables": config.tables,
370
+ "queries": config.queries,
371
+ "table_id": table_id,
372
+ "table_metadata": table_metadata,
373
+ "message": message,
188
374
  },
189
375
  )
190
376
 
191
377
 
192
378
  @router.get("/queries/{query_id}/view", response_class=HTMLResponse)
193
- def get_query_view(request: Request, query_id: str) -> HTMLResponse:
379
+ def get_query_view(request: Request, query_id: str) -> Response:
380
+ app_metadata: AppMetadata = request.app.state.app_metadata
194
381
  config: Config = request.app.state.config
195
382
  query_config = next(
196
383
  filter(lambda query_config: query_config.name == query_id, config.queries)
197
384
  )
198
- tables_dataset = {
199
- table_config.name: load_table(table_config).dataset()
200
- for table_config in config.tables
385
+
386
+ if (
387
+ len(request.query_params.keys()) == 0
388
+ and len(query_config.parameters.keys()) > 0
389
+ ):
390
+ default_parameters = {k: v.default for k, v in query_config.parameters.items()}
391
+ url = request.url_for("get_query_view", query_id=query_id)
392
+ query_params = urllib.parse.urlencode(default_parameters)
393
+ return RedirectResponse(f"{url}?{query_params}")
394
+
395
+ tables_dataset = load_datasets(config.tables)
396
+ sql_param_names = extract_query_parameter_names(query_config.sql)
397
+ sql_params = {
398
+ name: request.query_params.get(name)
399
+ or (
400
+ query_param.default
401
+ if (query_param := query_config.parameters.get(name))
402
+ else None
403
+ )
404
+ or ""
405
+ for name in sql_param_names
201
406
  }
202
407
 
203
408
  try:
204
- results = execute_query(tables_dataset, query_config.sql)
409
+ sql_query = limit_query(query_config.sql, config.settings.max_query_rows + 1)
410
+
411
+ start_time = time.perf_counter()
412
+ results = execute_query(tables_dataset, sql_query, sql_params=sql_params)
413
+ execution_time_ms = (time.perf_counter() - start_time) * 1000
414
+
415
+ truncated = results.num_rows > config.settings.max_query_rows
416
+ results = results.slice(
417
+ 0, min(results.num_rows, config.settings.max_query_rows)
418
+ )
205
419
  error = None
206
420
  except ValueError as e:
207
421
  error = {"message": str(e)}
208
422
  results = None
423
+ truncated = False
424
+ execution_time_ms = None
209
425
 
210
426
  return templates.TemplateResponse(
211
427
  request=request,
212
428
  name="queries/view.html",
213
429
  context={
430
+ "app_metadata": app_metadata,
214
431
  "tables": config.tables,
215
432
  "queries": config.queries,
216
433
  "query": query_config,
217
434
  "query_results": results,
435
+ "truncated_results": truncated,
436
+ "execution_time_ms": execution_time_ms,
437
+ "sql_params": sql_params,
218
438
  "error": error,
219
439
  },
220
440
  )
@@ -231,6 +451,9 @@ def create_app() -> FastAPI:
231
451
  name="static",
232
452
  )
233
453
  app.include_router(router)
454
+ app.state.app_metadata = AppMetadata(
455
+ app_name="🗼 Laketower", app_version=__about__.__version__
456
+ )
234
457
  app.state.config = config
235
458
 
236
459
  return app