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/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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
99
|
-
"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
|
-
|
|
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":
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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) ->
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|