laketower 0.1.0__py3-none-any.whl → 0.3.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/__about__.py +1 -1
- laketower/cli.py +45 -179
- laketower/config.py +45 -0
- laketower/static/.gitkeep +0 -0
- laketower/tables.py +134 -0
- laketower/templates/_base.html +72 -0
- laketower/templates/index.html +4 -0
- laketower/templates/tables/_macros.html +13 -0
- laketower/templates/tables/history.html +42 -0
- laketower/templates/tables/index.html +84 -0
- laketower/templates/tables/query.html +47 -0
- laketower/templates/tables/view.html +96 -0
- laketower/web.py +167 -0
- {laketower-0.1.0.dist-info → laketower-0.3.0.dist-info}/METADATA +55 -8
- laketower-0.3.0.dist-info/RECORD +20 -0
- laketower-0.1.0.dist-info/RECORD +0 -9
- {laketower-0.1.0.dist-info → laketower-0.3.0.dist-info}/WHEEL +0 -0
- {laketower-0.1.0.dist-info → laketower-0.3.0.dist-info}/entry_points.txt +0 -0
- {laketower-0.1.0.dist-info → laketower-0.3.0.dist-info}/licenses/LICENSE.md +0 -0
laketower/__about__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.
|
|
1
|
+
__version__ = "0.3.0"
|
laketower/cli.py
CHANGED
|
@@ -1,178 +1,22 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
1
|
import argparse
|
|
4
|
-
import
|
|
5
|
-
from datetime import datetime, timezone
|
|
2
|
+
import os
|
|
6
3
|
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
8
4
|
|
|
9
|
-
import
|
|
10
|
-
import duckdb
|
|
11
|
-
import pandas as pd
|
|
12
|
-
import pyarrow as pa
|
|
13
|
-
import pydantic
|
|
5
|
+
import rich.jupyter
|
|
14
6
|
import rich.panel
|
|
15
7
|
import rich.table
|
|
16
8
|
import rich.text
|
|
17
9
|
import rich.tree
|
|
18
|
-
import
|
|
19
|
-
import sqlglot.dialects
|
|
20
|
-
import sqlglot.dialects.duckdb
|
|
21
|
-
import sqlglot.generator
|
|
22
|
-
import yaml
|
|
10
|
+
import uvicorn
|
|
23
11
|
|
|
24
12
|
from laketower.__about__ import __version__
|
|
13
|
+
from laketower.config import load_yaml_config
|
|
14
|
+
from laketower.tables import execute_query, generate_table_query, load_table
|
|
25
15
|
|
|
26
16
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
class ConfigTable(pydantic.BaseModel):
|
|
32
|
-
name: str
|
|
33
|
-
uri: str
|
|
34
|
-
table_format: TableFormats = pydantic.Field(alias="format")
|
|
35
|
-
|
|
36
|
-
@pydantic.model_validator(mode="after")
|
|
37
|
-
def check_table(self) -> "ConfigTable":
|
|
38
|
-
def check_delta_table(table_uri: str) -> None:
|
|
39
|
-
if not deltalake.DeltaTable.is_deltatable(table_uri):
|
|
40
|
-
raise ValueError(f"{table_uri} is not a valid Delta table")
|
|
41
|
-
|
|
42
|
-
format_check = {TableFormats.delta: check_delta_table}
|
|
43
|
-
format_check[self.table_format](self.uri)
|
|
44
|
-
|
|
45
|
-
return self
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
class ConfigQuery(pydantic.BaseModel):
|
|
49
|
-
name: str
|
|
50
|
-
sql: str
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class ConfigDashboard(pydantic.BaseModel):
|
|
54
|
-
name: str
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
class Config(pydantic.BaseModel):
|
|
58
|
-
tables: list[ConfigTable] = []
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def load_yaml_config(config_path: Path) -> Config:
|
|
62
|
-
config_dict = yaml.safe_load(config_path.read_text())
|
|
63
|
-
return Config.model_validate(config_dict)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
class TableMetadata(pydantic.BaseModel):
|
|
67
|
-
table_format: TableFormats
|
|
68
|
-
name: str
|
|
69
|
-
description: str
|
|
70
|
-
uri: str
|
|
71
|
-
id: str
|
|
72
|
-
version: int
|
|
73
|
-
created_at: datetime
|
|
74
|
-
partitions: list[str]
|
|
75
|
-
configuration: dict[str, str]
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
class TableRevision(pydantic.BaseModel):
|
|
79
|
-
version: int
|
|
80
|
-
timestamp: datetime
|
|
81
|
-
client_version: str
|
|
82
|
-
operation: str
|
|
83
|
-
operation_parameters: dict[str, Any]
|
|
84
|
-
operation_metrics: dict[str, Any]
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
class TableHistory(pydantic.BaseModel):
|
|
88
|
-
revisions: list[TableRevision]
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def load_table_metadata(table_config: ConfigTable) -> TableMetadata:
|
|
92
|
-
def load_delta_table_metadata(table_config: ConfigTable) -> TableMetadata:
|
|
93
|
-
delta_table = deltalake.DeltaTable(table_config.uri)
|
|
94
|
-
metadata = delta_table.metadata()
|
|
95
|
-
return TableMetadata(
|
|
96
|
-
table_format=table_config.table_format,
|
|
97
|
-
name=metadata.name,
|
|
98
|
-
description=metadata.description,
|
|
99
|
-
uri=delta_table.table_uri,
|
|
100
|
-
id=str(metadata.id),
|
|
101
|
-
version=delta_table.version(),
|
|
102
|
-
created_at=datetime.fromtimestamp(
|
|
103
|
-
metadata.created_time / 1000, tz=timezone.utc
|
|
104
|
-
),
|
|
105
|
-
partitions=metadata.partition_columns,
|
|
106
|
-
configuration=metadata.configuration,
|
|
107
|
-
)
|
|
108
|
-
|
|
109
|
-
format_handler = {TableFormats.delta: load_delta_table_metadata}
|
|
110
|
-
return format_handler[table_config.table_format](table_config)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def load_table_schema(table_config: ConfigTable) -> pa.Schema:
|
|
114
|
-
def load_delta_table_schema(table_config: ConfigTable) -> pa.Schema:
|
|
115
|
-
delta_table = deltalake.DeltaTable(table_config.uri)
|
|
116
|
-
return delta_table.schema().to_pyarrow()
|
|
117
|
-
|
|
118
|
-
format_handler = {TableFormats.delta: load_delta_table_schema}
|
|
119
|
-
return format_handler[table_config.table_format](table_config)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def load_table_history(table_config: ConfigTable) -> TableHistory:
|
|
123
|
-
def load_delta_table_history(table_config: ConfigTable) -> TableHistory:
|
|
124
|
-
delta_table = deltalake.DeltaTable(table_config.uri)
|
|
125
|
-
delta_history = delta_table.history()
|
|
126
|
-
revisions = [
|
|
127
|
-
TableRevision(
|
|
128
|
-
version=event["version"],
|
|
129
|
-
timestamp=datetime.fromtimestamp(
|
|
130
|
-
event["timestamp"] / 1000, tz=timezone.utc
|
|
131
|
-
),
|
|
132
|
-
client_version=event["clientVersion"],
|
|
133
|
-
operation=event["operation"],
|
|
134
|
-
operation_parameters=event["operationParameters"],
|
|
135
|
-
operation_metrics=event.get("operationMetrics") or {},
|
|
136
|
-
)
|
|
137
|
-
for event in delta_history
|
|
138
|
-
]
|
|
139
|
-
return TableHistory(revisions=revisions)
|
|
140
|
-
|
|
141
|
-
format_handler = {TableFormats.delta: load_delta_table_history}
|
|
142
|
-
return format_handler[table_config.table_format](table_config)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
def load_table_dataset(table_config: ConfigTable) -> pa.dataset.Dataset:
|
|
146
|
-
def load_delta_table_metadata(table_config: ConfigTable) -> pa.dataset.Dataset:
|
|
147
|
-
delta_table = deltalake.DeltaTable(table_config.uri)
|
|
148
|
-
return delta_table.to_pyarrow_dataset()
|
|
149
|
-
|
|
150
|
-
format_handler = {TableFormats.delta: load_delta_table_metadata}
|
|
151
|
-
return format_handler[table_config.table_format](table_config)
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
def execute_query_table(table_config: ConfigTable, sql_query: str) -> pd.DataFrame:
|
|
155
|
-
table_dataset = load_table_dataset(table_config)
|
|
156
|
-
table_name = table_config.name
|
|
157
|
-
view_name = f"{table_name}_view"
|
|
158
|
-
conn = duckdb.connect()
|
|
159
|
-
conn.register(view_name, table_dataset)
|
|
160
|
-
conn.execute(f"create table {table_name} as select * from {view_name}") # nosec B608
|
|
161
|
-
return conn.execute(sql_query).df()
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def execute_query(tables_config: list[ConfigTable], sql_query: str) -> pd.DataFrame:
|
|
165
|
-
try:
|
|
166
|
-
conn = duckdb.connect()
|
|
167
|
-
for table_config in tables_config:
|
|
168
|
-
table_dataset = load_table_dataset(table_config)
|
|
169
|
-
table_name = table_config.name
|
|
170
|
-
view_name = f"{table_name}_view"
|
|
171
|
-
conn.register(view_name, table_dataset)
|
|
172
|
-
conn.execute(f"create table {table_name} as select * from {view_name}") # nosec B608
|
|
173
|
-
return conn.execute(sql_query).df()
|
|
174
|
-
except duckdb.Error as e:
|
|
175
|
-
raise ValueError(str(e)) from e
|
|
17
|
+
def run_web(config_path: Path, reload: bool) -> None: # pragma: no cover
|
|
18
|
+
os.environ["LAKETOWER_CONFIG_PATH"] = str(config_path.absolute())
|
|
19
|
+
uvicorn.run("laketower.web:create_app", factory=True, reload=reload)
|
|
176
20
|
|
|
177
21
|
|
|
178
22
|
def validate_config(config_path: Path) -> None:
|
|
@@ -200,7 +44,8 @@ def list_tables(config_path: Path) -> None:
|
|
|
200
44
|
def table_metadata(config_path: Path, table_name: str) -> None:
|
|
201
45
|
config = load_yaml_config(config_path)
|
|
202
46
|
table_config = next(filter(lambda x: x.name == table_name, config.tables))
|
|
203
|
-
|
|
47
|
+
table = load_table(table_config)
|
|
48
|
+
metadata = table.metadata()
|
|
204
49
|
|
|
205
50
|
tree = rich.tree.Tree(table_name)
|
|
206
51
|
tree.add(f"name: {metadata.name}")
|
|
@@ -219,7 +64,8 @@ def table_metadata(config_path: Path, table_name: str) -> None:
|
|
|
219
64
|
def table_schema(config_path: Path, table_name: str) -> None:
|
|
220
65
|
config = load_yaml_config(config_path)
|
|
221
66
|
table_config = next(filter(lambda x: x.name == table_name, config.tables))
|
|
222
|
-
|
|
67
|
+
table = load_table(table_config)
|
|
68
|
+
schema = table.schema()
|
|
223
69
|
|
|
224
70
|
tree = rich.tree.Tree(table_name)
|
|
225
71
|
for field in schema:
|
|
@@ -232,7 +78,8 @@ def table_schema(config_path: Path, table_name: str) -> None:
|
|
|
232
78
|
def table_history(config_path: Path, table_name: str) -> None:
|
|
233
79
|
config = load_yaml_config(config_path)
|
|
234
80
|
table_config = next(filter(lambda x: x.name == table_name, config.tables))
|
|
235
|
-
|
|
81
|
+
table = load_table(table_config)
|
|
82
|
+
history = table.history()
|
|
236
83
|
|
|
237
84
|
tree = rich.tree.Tree(table_name)
|
|
238
85
|
for rev in history.revisions:
|
|
@@ -257,22 +104,21 @@ def view_table(
|
|
|
257
104
|
cols: list[str] | None = None,
|
|
258
105
|
sort_asc: str | None = None,
|
|
259
106
|
sort_desc: str | None = None,
|
|
107
|
+
version: int | None = None,
|
|
260
108
|
) -> None:
|
|
261
109
|
config = load_yaml_config(config_path)
|
|
262
110
|
table_config = next(filter(lambda x: x.name == table_name, config.tables))
|
|
111
|
+
table = load_table(table_config)
|
|
112
|
+
table_dataset = table.dataset(version=version)
|
|
113
|
+
sql_query = generate_table_query(
|
|
114
|
+
table_name, limit=limit, cols=cols, sort_asc=sort_asc, sort_desc=sort_desc
|
|
115
|
+
)
|
|
116
|
+
results = execute_query({table_name: table_dataset}, sql_query)
|
|
263
117
|
|
|
264
|
-
query_expr = sqlglot.select(*(cols or ["*"])).from_(table_name).limit(limit or 10)
|
|
265
|
-
if sort_asc:
|
|
266
|
-
query_expr = query_expr.order_by(f"{sort_asc} asc")
|
|
267
|
-
elif sort_desc:
|
|
268
|
-
query_expr = query_expr.order_by(f"{sort_desc} desc")
|
|
269
|
-
sql_query = sqlglot.Generator(dialect=sqlglot.dialects.DuckDB).generate(query_expr)
|
|
270
|
-
|
|
271
|
-
results = execute_query_table(table_config, sql_query)
|
|
272
118
|
out = rich.table.Table()
|
|
273
119
|
for column in results.columns:
|
|
274
120
|
out.add_column(column)
|
|
275
|
-
for value_list in results.
|
|
121
|
+
for value_list in results.to_numpy().tolist():
|
|
276
122
|
row = [str(x) for x in value_list]
|
|
277
123
|
out.add_row(*row)
|
|
278
124
|
|
|
@@ -282,10 +128,14 @@ def view_table(
|
|
|
282
128
|
|
|
283
129
|
def query_table(config_path: Path, sql_query: str) -> None:
|
|
284
130
|
config = load_yaml_config(config_path)
|
|
131
|
+
tables_dataset = {
|
|
132
|
+
table_config.name: load_table(table_config).dataset()
|
|
133
|
+
for table_config in config.tables
|
|
134
|
+
}
|
|
285
135
|
|
|
286
136
|
out: rich.jupyter.JupyterMixin
|
|
287
137
|
try:
|
|
288
|
-
results = execute_query(
|
|
138
|
+
results = execute_query(tables_dataset, sql_query)
|
|
289
139
|
out = rich.table.Table()
|
|
290
140
|
for column in results.columns:
|
|
291
141
|
out.add_column(column)
|
|
@@ -300,7 +150,9 @@ def query_table(config_path: Path, sql_query: str) -> None:
|
|
|
300
150
|
|
|
301
151
|
|
|
302
152
|
def cli() -> None:
|
|
303
|
-
parser = argparse.ArgumentParser(
|
|
153
|
+
parser = argparse.ArgumentParser(
|
|
154
|
+
"laketower", formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
|
155
|
+
)
|
|
304
156
|
parser.add_argument("--version", action="version", version=__version__)
|
|
305
157
|
parser.add_argument(
|
|
306
158
|
"--config",
|
|
@@ -311,6 +163,17 @@ def cli() -> None:
|
|
|
311
163
|
)
|
|
312
164
|
subparsers = parser.add_subparsers(title="commands", required=True)
|
|
313
165
|
|
|
166
|
+
parser_web = subparsers.add_parser(
|
|
167
|
+
"web", help="Launch the web application", add_help=True
|
|
168
|
+
)
|
|
169
|
+
parser_web.add_argument(
|
|
170
|
+
"--reload",
|
|
171
|
+
help="Reload the web server on changes",
|
|
172
|
+
action="store_true",
|
|
173
|
+
required=False,
|
|
174
|
+
)
|
|
175
|
+
parser_web.set_defaults(func=lambda x: run_web(x.config, x.reload))
|
|
176
|
+
|
|
314
177
|
parser_config = subparsers.add_parser(
|
|
315
178
|
"config", help="Work with configuration", add_help=True
|
|
316
179
|
)
|
|
@@ -364,9 +227,12 @@ def cli() -> None:
|
|
|
364
227
|
parser_tables_view_sort_group.add_argument(
|
|
365
228
|
"--sort-desc", help="Sort by given column in descending order"
|
|
366
229
|
)
|
|
230
|
+
parser_tables_view.add_argument(
|
|
231
|
+
"--version", type=int, help="Time-travel to table revision number"
|
|
232
|
+
)
|
|
367
233
|
parser_tables_view.set_defaults(
|
|
368
234
|
func=lambda x: view_table(
|
|
369
|
-
x.config, x.table, x.limit, x.cols, x.sort_asc, x.sort_desc
|
|
235
|
+
x.config, x.table, x.limit, x.cols, x.sort_asc, x.sort_desc, x.version
|
|
370
236
|
)
|
|
371
237
|
)
|
|
372
238
|
|
laketower/config.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import deltalake
|
|
5
|
+
import pydantic
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TableFormats(str, enum.Enum):
|
|
10
|
+
delta = "delta"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigTable(pydantic.BaseModel):
|
|
14
|
+
name: str
|
|
15
|
+
uri: str
|
|
16
|
+
table_format: TableFormats = pydantic.Field(alias="format")
|
|
17
|
+
|
|
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
|
+
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ConfigQuery(pydantic.BaseModel):
|
|
31
|
+
name: str
|
|
32
|
+
sql: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ConfigDashboard(pydantic.BaseModel):
|
|
36
|
+
name: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Config(pydantic.BaseModel):
|
|
40
|
+
tables: list[ConfigTable] = []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_yaml_config(config_path: Path) -> Config:
|
|
44
|
+
config_dict = yaml.safe_load(config_path.read_text())
|
|
45
|
+
return Config.model_validate(config_dict)
|
|
File without changes
|
laketower/tables.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from typing import Any, Protocol
|
|
3
|
+
|
|
4
|
+
import deltalake
|
|
5
|
+
import duckdb
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import pyarrow as pa
|
|
8
|
+
import pyarrow.dataset as padataset
|
|
9
|
+
import pydantic
|
|
10
|
+
import sqlglot
|
|
11
|
+
import sqlglot.dialects.duckdb
|
|
12
|
+
|
|
13
|
+
from laketower.config import ConfigTable, TableFormats
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_LIMIT = 10
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TableMetadata(pydantic.BaseModel):
|
|
20
|
+
table_format: TableFormats
|
|
21
|
+
name: str | None = None
|
|
22
|
+
description: str | None = None
|
|
23
|
+
uri: str
|
|
24
|
+
id: str
|
|
25
|
+
version: int
|
|
26
|
+
created_at: datetime
|
|
27
|
+
partitions: list[str]
|
|
28
|
+
configuration: dict[str, str]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TableRevision(pydantic.BaseModel):
|
|
32
|
+
version: int
|
|
33
|
+
timestamp: datetime
|
|
34
|
+
client_version: str | None = None
|
|
35
|
+
operation: str
|
|
36
|
+
operation_parameters: dict[str, Any]
|
|
37
|
+
operation_metrics: dict[str, Any]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TableHistory(pydantic.BaseModel):
|
|
41
|
+
revisions: list[TableRevision]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TableProtocol(Protocol): # pragma: no cover
|
|
45
|
+
def metadata(self) -> TableMetadata: ...
|
|
46
|
+
def schema(self) -> pa.Schema: ...
|
|
47
|
+
def history(self) -> TableHistory: ...
|
|
48
|
+
def dataset(self, version: int | str | None = None) -> padataset.Dataset: ...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DeltaTable:
|
|
52
|
+
def __init__(self, table_config: ConfigTable):
|
|
53
|
+
super().__init__()
|
|
54
|
+
self.table_config = table_config
|
|
55
|
+
self._impl = deltalake.DeltaTable(table_config.uri)
|
|
56
|
+
|
|
57
|
+
def metadata(self) -> TableMetadata:
|
|
58
|
+
metadata = self._impl.metadata()
|
|
59
|
+
return TableMetadata(
|
|
60
|
+
table_format=self.table_config.table_format,
|
|
61
|
+
name=metadata.name,
|
|
62
|
+
description=metadata.description,
|
|
63
|
+
uri=self._impl.table_uri,
|
|
64
|
+
id=str(metadata.id),
|
|
65
|
+
version=self._impl.version(),
|
|
66
|
+
created_at=datetime.fromtimestamp(
|
|
67
|
+
metadata.created_time / 1000, tz=timezone.utc
|
|
68
|
+
),
|
|
69
|
+
partitions=metadata.partition_columns,
|
|
70
|
+
configuration=metadata.configuration,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def schema(self) -> pa.Schema:
|
|
74
|
+
return self._impl.schema().to_pyarrow()
|
|
75
|
+
|
|
76
|
+
def history(self) -> TableHistory:
|
|
77
|
+
delta_history = self._impl.history()
|
|
78
|
+
revisions = [
|
|
79
|
+
TableRevision(
|
|
80
|
+
version=event["version"],
|
|
81
|
+
timestamp=datetime.fromtimestamp(
|
|
82
|
+
event["timestamp"] / 1000, tz=timezone.utc
|
|
83
|
+
),
|
|
84
|
+
client_version=event.get("clientVersion") or event.get("engineInfo"),
|
|
85
|
+
operation=event["operation"],
|
|
86
|
+
operation_parameters=event["operationParameters"],
|
|
87
|
+
operation_metrics=event.get("operationMetrics") or {},
|
|
88
|
+
)
|
|
89
|
+
for event in delta_history
|
|
90
|
+
]
|
|
91
|
+
return TableHistory(revisions=revisions)
|
|
92
|
+
|
|
93
|
+
def dataset(self, version: int | str | None = None) -> padataset.Dataset:
|
|
94
|
+
if version is not None:
|
|
95
|
+
self._impl.load_as_version(version)
|
|
96
|
+
return self._impl.to_pyarrow_dataset()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_table(table_config: ConfigTable) -> TableProtocol:
|
|
100
|
+
format_handler = {TableFormats.delta: DeltaTable}
|
|
101
|
+
return format_handler[table_config.table_format](table_config)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def generate_table_query(
|
|
105
|
+
table_name: str,
|
|
106
|
+
limit: int | None = None,
|
|
107
|
+
cols: list[str] | None = None,
|
|
108
|
+
sort_asc: str | None = None,
|
|
109
|
+
sort_desc: str | None = None,
|
|
110
|
+
) -> str:
|
|
111
|
+
query_expr = (
|
|
112
|
+
sqlglot.select(*(cols or ["*"])).from_(table_name).limit(limit or DEFAULT_LIMIT)
|
|
113
|
+
)
|
|
114
|
+
if sort_asc:
|
|
115
|
+
query_expr = query_expr.order_by(f"{sort_asc} asc")
|
|
116
|
+
elif sort_desc:
|
|
117
|
+
query_expr = query_expr.order_by(f"{sort_desc} desc")
|
|
118
|
+
return sqlglot.Generator(dialect=sqlglot.dialects.duckdb.DuckDB).generate(
|
|
119
|
+
query_expr
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def execute_query(
|
|
124
|
+
tables_datasets: dict[str, padataset.Dataset], sql_query: str
|
|
125
|
+
) -> pd.DataFrame:
|
|
126
|
+
try:
|
|
127
|
+
conn = duckdb.connect()
|
|
128
|
+
for table_name, table_dataset in tables_datasets.items():
|
|
129
|
+
view_name = f"{table_name}_view"
|
|
130
|
+
conn.register(view_name, table_dataset)
|
|
131
|
+
conn.execute(f"create table {table_name} as select * from {view_name}") # nosec B608
|
|
132
|
+
return conn.execute(sql_query).df()
|
|
133
|
+
except duckdb.Error as e:
|
|
134
|
+
raise ValueError(str(e)) from e
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html data-bs-theme="dark" data-bs-core="modern">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="utf-8">
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
+
<title>Laketower</title>
|
|
8
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
|
9
|
+
<link href="https://cdn.jsdelivr.net/npm/halfmoon@2.0.2/css/halfmoon.min.css" rel="stylesheet"
|
|
10
|
+
integrity="sha256-RjeFzczeuZHCyS+Gvz+kleETzBF/o84ZRHukze/yv6o=" crossorigin="anonymous">
|
|
11
|
+
<link href="https://cdn.jsdelivr.net/npm/halfmoon@2.0.2/css/cores/halfmoon.modern.css" rel="stylesheet"
|
|
12
|
+
integrity="sha256-DD6elX+jPmbFYPsGvzodUv2+9FHkxHlVtQi0/RJVULs=" crossorigin="anonymous">
|
|
13
|
+
</head>
|
|
14
|
+
|
|
15
|
+
<body class="ps-md-sbwidth">
|
|
16
|
+
<header>
|
|
17
|
+
<nav class="sidebar offcanvas-start offcanvas-md" tabindex="-1" id="sidebar">
|
|
18
|
+
<div class="offcanvas-header border-bottom">
|
|
19
|
+
<a class="sidebar-brand" href="/">
|
|
20
|
+
Laketower
|
|
21
|
+
</a>
|
|
22
|
+
<button type="button" class="btn-close d-md-none" data-bs-dismiss="offcanvas" aria-label="Close" data-bs-target="#sidebar"></button>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="offcanvas-body">
|
|
25
|
+
<ul class="sidebar-nav">
|
|
26
|
+
<li>
|
|
27
|
+
<h6 class="sidebar-header">Tables</h6>
|
|
28
|
+
</li>
|
|
29
|
+
<li><hr class="sidebar-divider"></li>
|
|
30
|
+
{% for table in tables %}
|
|
31
|
+
{% set table_url = '/tables/' + table.name %}
|
|
32
|
+
<li class="nav-item">
|
|
33
|
+
<a class="text-truncate nav-link{% if request.url.path.startswith(table_url) %} active{% endif %}" href="{{ table_url }}" aria-current="page">
|
|
34
|
+
<i class="bi-table" aria-hidden="true"></i>
|
|
35
|
+
{{ table.name }}
|
|
36
|
+
</a>
|
|
37
|
+
</li>
|
|
38
|
+
{% endfor %}
|
|
39
|
+
</ul>
|
|
40
|
+
</div>
|
|
41
|
+
</nav>
|
|
42
|
+
|
|
43
|
+
<nav class="navbar navbar-expand-md sticky-top" style="border-bottom: var(--bs-border-width) solid var(--bs-content-border-color); min-height: 60px;">
|
|
44
|
+
<div class="container-md justify-content-start">
|
|
45
|
+
<button type="button" data-bs-toggle="offcanvas" data-bs-target="#sidebar" class="btn btn-secondary d-md-none">
|
|
46
|
+
<i class="bi-layout-sidebar" aria-hidden="true"></i>
|
|
47
|
+
</button>
|
|
48
|
+
|
|
49
|
+
<a href="/" class="navbar-brand d-flex align-items-center d-md-none ms-auto ms-md-0">
|
|
50
|
+
Laketower
|
|
51
|
+
</a>
|
|
52
|
+
</div>
|
|
53
|
+
</nav>
|
|
54
|
+
</header>
|
|
55
|
+
|
|
56
|
+
<main>
|
|
57
|
+
<div class="container py-3 py-sm-4 px-3 px-sm-4">
|
|
58
|
+
{% block body %}{% endblock %}
|
|
59
|
+
</div>
|
|
60
|
+
</main>
|
|
61
|
+
|
|
62
|
+
<footer></footer>
|
|
63
|
+
|
|
64
|
+
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.8/dist/umd/popper.min.js"
|
|
65
|
+
integrity="sha384-I7E8VVD/ismYTF4hNIPjVp/Zjvgyol6VFvRkX/vR+Vc4jQkC+hVqc2pM8ODewa9r"
|
|
66
|
+
crossorigin="anonymous"></script>
|
|
67
|
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.min.js"
|
|
68
|
+
integrity="sha384-0pUGZvbkm6XF6gxjEnlmuGrJXVbNuzT9qBBavbLwCsOGabYfZo0T0to5eqruptLy"
|
|
69
|
+
crossorigin="anonymous"></script>
|
|
70
|
+
</body>
|
|
71
|
+
|
|
72
|
+
</html>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{% macro table_nav(table_id, current) -%}
|
|
2
|
+
<ul class="nav nav-pills justify-content-center mb-3">
|
|
3
|
+
<li class="nav-item">
|
|
4
|
+
<a class="nav-link{% if current == 'overview' %} active{% endif %}"{% if current == 'overview' %} aria-current="true"{% endif %} href="/tables/{{ table_id }}">Overview</a>
|
|
5
|
+
</li>
|
|
6
|
+
<li class="nav-item">
|
|
7
|
+
<a class="nav-link{% if current == 'view' %} active{% endif %}"{% if current == 'view' %} aria-current="true"{% endif %} href="/tables/{{ table_id }}/view">Data</a>
|
|
8
|
+
</li>
|
|
9
|
+
<li class="nav-item">
|
|
10
|
+
<a class="nav-link{% if current == 'history' %} active{% endif %}"{% if current == 'history' %} aria-current="true"{% endif %} href="/tables/{{ table_id }}/history">History</a>
|
|
11
|
+
</li>
|
|
12
|
+
</ul>
|
|
13
|
+
{%- endmacro %}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{% extends "_base.html" %}
|
|
2
|
+
{% import 'tables/_macros.html' as table_macros %}
|
|
3
|
+
|
|
4
|
+
{% block body %}
|
|
5
|
+
{{ table_macros.table_nav(table_id, 'history') }}
|
|
6
|
+
|
|
7
|
+
<div class="row">
|
|
8
|
+
<div class="col">
|
|
9
|
+
<div class="accordion" id="accordion-history">
|
|
10
|
+
{% for revision in table_history.revisions %}
|
|
11
|
+
<div class="accordion-item">
|
|
12
|
+
<h2 class="accordion-header">
|
|
13
|
+
<button class="accordion-button{% if not loop.first %} collapsed{% endif %}" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-{{ loop.index }}" aria-expanded="{% if loop.first %}true{% else %}false{% endif %}" aria-controls="collapse-{{ loop.index }}">
|
|
14
|
+
version: {{ revision.version }}
|
|
15
|
+
</button>
|
|
16
|
+
</h2>
|
|
17
|
+
<div id="collapse-{{ loop.index }}" class="accordion-collapse collapse{% if loop.first %} show{% endif %}" data-bs-parent="#accordion-history">
|
|
18
|
+
<div class="accordion-body">
|
|
19
|
+
<ul>
|
|
20
|
+
<li>timestamp: {{ revision.timestamp }}</li>
|
|
21
|
+
<li>operation: {{ revision.operation }}</li>
|
|
22
|
+
<li>operation parameters</li>
|
|
23
|
+
<ul>
|
|
24
|
+
{% for param_key, param_val in revision.operation_parameters.items() %}
|
|
25
|
+
<li>{{ param_key }}: {{ param_val }}</li>
|
|
26
|
+
{% endfor %}
|
|
27
|
+
</ul>
|
|
28
|
+
<li>operation metrics</li>
|
|
29
|
+
<ul>
|
|
30
|
+
{% for metric_key, metric_val in revision.operation_metrics.items() %}
|
|
31
|
+
<li>{{ metric_key }}: {{ metric_val }}</li>
|
|
32
|
+
{% endfor %}
|
|
33
|
+
</ul>
|
|
34
|
+
<li>client version: {{ revision.client_version }}</li>
|
|
35
|
+
</ul>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
{% endfor %}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
{% endblock %}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
{% extends "_base.html" %}
|
|
2
|
+
{% import 'tables/_macros.html' as table_macros %}
|
|
3
|
+
|
|
4
|
+
{% block body %}
|
|
5
|
+
{{ table_macros.table_nav(table_id, 'overview') }}
|
|
6
|
+
|
|
7
|
+
<div class="row row-cols-1 row-cols-md-2 g-4">
|
|
8
|
+
<div class="col">
|
|
9
|
+
<div class="card h-100">
|
|
10
|
+
<div class="card-header">Schema</div>
|
|
11
|
+
<div class="card-body">
|
|
12
|
+
<div class="table-responsive">
|
|
13
|
+
<table class="table">
|
|
14
|
+
<thead>
|
|
15
|
+
<th>Column</th>
|
|
16
|
+
<th>Type</th>
|
|
17
|
+
<th>Nullable</th>
|
|
18
|
+
</thead>
|
|
19
|
+
<tbody>
|
|
20
|
+
{% for field in table_schema %}
|
|
21
|
+
<tr>
|
|
22
|
+
<td>{{ field.name }}</td>
|
|
23
|
+
<td><code>{{ field.type }}</code></td>
|
|
24
|
+
<td>{% if field.nullable %}True{% else %}False{% endif %}</td>
|
|
25
|
+
</tr>
|
|
26
|
+
{% endfor %}
|
|
27
|
+
</tbody>
|
|
28
|
+
</table>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div class="col">
|
|
35
|
+
<div class="card h-100">
|
|
36
|
+
<div class="card-header">Metadata</div>
|
|
37
|
+
<div class="card-body">
|
|
38
|
+
<div class="table-responsive">
|
|
39
|
+
<table class="table">
|
|
40
|
+
<tbody>
|
|
41
|
+
<tr>
|
|
42
|
+
<th>URI</th>
|
|
43
|
+
<td>{{ table_metadata.uri }}</td>
|
|
44
|
+
</tr>
|
|
45
|
+
<tr>
|
|
46
|
+
<th>Format</th>
|
|
47
|
+
<td>{{ table_metadata.table_format.value }}</td>
|
|
48
|
+
</tr>
|
|
49
|
+
<tr>
|
|
50
|
+
<th>ID</th>
|
|
51
|
+
<td>{{ table_metadata.id }}</td>
|
|
52
|
+
</tr>
|
|
53
|
+
<tr>
|
|
54
|
+
<th>Version</th>
|
|
55
|
+
<td>{{ table_metadata.version }}</td>
|
|
56
|
+
</tr>
|
|
57
|
+
<tr>
|
|
58
|
+
<th>Name</th>
|
|
59
|
+
<td>{{ table_metadata.name }}</td>
|
|
60
|
+
</tr>
|
|
61
|
+
<tr>
|
|
62
|
+
<th>Description</th>
|
|
63
|
+
<td>{{ table_metadata.description }}</td>
|
|
64
|
+
</tr>
|
|
65
|
+
<tr>
|
|
66
|
+
<th>Created at</th>
|
|
67
|
+
<td>{{ table_metadata.created_at }}</td>
|
|
68
|
+
</tr>
|
|
69
|
+
<tr>
|
|
70
|
+
<th>Partitions</th>
|
|
71
|
+
<td>{{ table_metadata.partitions }}</td>
|
|
72
|
+
</tr>
|
|
73
|
+
<tr>
|
|
74
|
+
<th>Configuration</th>
|
|
75
|
+
<td>{{ table_metadata.configuration }}</td>
|
|
76
|
+
</tr>
|
|
77
|
+
</tbody>
|
|
78
|
+
</table>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
{% endblock %}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{% extends "_base.html" %}
|
|
2
|
+
{% import 'tables/_macros.html' as table_macros %}
|
|
3
|
+
|
|
4
|
+
{% block body %}
|
|
5
|
+
<div class="row">
|
|
6
|
+
<div class="col">
|
|
7
|
+
<form>
|
|
8
|
+
<div class="mb-3">
|
|
9
|
+
<textarea name="sql" rows="5" class="form-control">{{ sql_query }}</textarea>
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
<div class="mb-3">
|
|
13
|
+
<div class="d-flex justify-content-end">
|
|
14
|
+
<button type="submit" class="btn btn-primary">Execute</button>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
</form>
|
|
18
|
+
|
|
19
|
+
{% if error %}
|
|
20
|
+
<div class="alert alert-danger" role="alert">
|
|
21
|
+
{{ error.message }}
|
|
22
|
+
</div>
|
|
23
|
+
{% else %}
|
|
24
|
+
<div class="table-responsive">
|
|
25
|
+
<table class="table table-sm table-bordered table-striped table-hover">
|
|
26
|
+
<thead>
|
|
27
|
+
<tr>
|
|
28
|
+
{% for column in table_results.columns %}
|
|
29
|
+
<th>{{ column }}</th>
|
|
30
|
+
{% endfor %}
|
|
31
|
+
</tr>
|
|
32
|
+
</thead>
|
|
33
|
+
<tbody class="table-group-divider">
|
|
34
|
+
{% for row in table_results.to_numpy().tolist() %}
|
|
35
|
+
<tr>
|
|
36
|
+
{% for col in row %}
|
|
37
|
+
<td>{{ col }}</td>
|
|
38
|
+
{% endfor %}
|
|
39
|
+
</tr>
|
|
40
|
+
{% endfor %}
|
|
41
|
+
</tbody>
|
|
42
|
+
</table>
|
|
43
|
+
</div>
|
|
44
|
+
{% endif %}
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
{% endblock %}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{% extends "_base.html" %}
|
|
2
|
+
{% import 'tables/_macros.html' as table_macros %}
|
|
3
|
+
|
|
4
|
+
{% block body %}
|
|
5
|
+
{{ table_macros.table_nav(table_id, 'view') }}
|
|
6
|
+
|
|
7
|
+
<div class="row">
|
|
8
|
+
<div class="col">
|
|
9
|
+
<div class="table-responsive">
|
|
10
|
+
<table class="table table-sm table-bordered table-striped table-hover">
|
|
11
|
+
<thead>
|
|
12
|
+
<tr>
|
|
13
|
+
{% for column in table_results.columns %}
|
|
14
|
+
<th>
|
|
15
|
+
{{ column }}
|
|
16
|
+
{% if column == request.query_params.sort_asc %}
|
|
17
|
+
<a href="{{ request | current_path_with_args([('sort_asc', None), ('sort_desc', column)]) }}" style="text-decoration: none;">
|
|
18
|
+
<i class="bi-sort-up" aria-hidden="true"></i>
|
|
19
|
+
</a>
|
|
20
|
+
{% elif column == request.query_params.sort_desc %}
|
|
21
|
+
<a href="{{ request | current_path_with_args([('sort_asc', column), ('sort_desc', None)]) }}" style="text-decoration: none;">
|
|
22
|
+
<i class="bi-sort-down" aria-hidden="true"></i>
|
|
23
|
+
</a>
|
|
24
|
+
{% else %}
|
|
25
|
+
<a href="{{ request | current_path_with_args([('sort_asc', column), ('sort_desc', None)]) }}" class="text-decoration-none">
|
|
26
|
+
<i class="bi-arrow-down-up" aria-hidden="true"></i>
|
|
27
|
+
</a>
|
|
28
|
+
{% endif %}
|
|
29
|
+
{% set other_cols = table_results.columns | list | reject('equalto', column) | list %}
|
|
30
|
+
{% set cols_args = [] %}
|
|
31
|
+
{% for col in other_cols %}
|
|
32
|
+
{% set tmp = cols_args.append(('cols', col)) %}
|
|
33
|
+
{% endfor %}
|
|
34
|
+
<a href='{{ request | current_path_with_args(cols_args) }}' class='text-decoration-none'>
|
|
35
|
+
<i class='bi-eye-slash' aria-hidden='true'></i>
|
|
36
|
+
</a>
|
|
37
|
+
</th>
|
|
38
|
+
{% endfor %}
|
|
39
|
+
</tr>
|
|
40
|
+
</thead>
|
|
41
|
+
<tbody class="table-group-divider">
|
|
42
|
+
{% for row in table_results.to_numpy().tolist() %}
|
|
43
|
+
<tr>
|
|
44
|
+
{% for col in row %}
|
|
45
|
+
<td>{{ col }}</td>
|
|
46
|
+
{% endfor %}
|
|
47
|
+
</tr>
|
|
48
|
+
{% endfor %}
|
|
49
|
+
</tbody>
|
|
50
|
+
</table>
|
|
51
|
+
|
|
52
|
+
<div class="d-flex justify-content-between">
|
|
53
|
+
<div class="row">
|
|
54
|
+
<form class="col" action="{{ request.url.path }}" method="get">
|
|
55
|
+
{% for param_name, param_val in request.query_params.multi_items() %}
|
|
56
|
+
{% if param_name != 'limit' %}
|
|
57
|
+
<input type="hidden" name="{{ param_name }}" value="{{ param_val }}">
|
|
58
|
+
{% endif %}
|
|
59
|
+
{% endfor %}
|
|
60
|
+
|
|
61
|
+
<div class="input-group">
|
|
62
|
+
<input id="limit-input" name="limit" type="number" class="form-control" min="1" max="10000" value="{{ request.query_params.limit or default_limit }}">
|
|
63
|
+
<button type="submit" class="btn btn-primary">Limit</button>
|
|
64
|
+
</div>
|
|
65
|
+
</form>
|
|
66
|
+
|
|
67
|
+
<form class="col" ="{{ request.url.path }}" method="get">
|
|
68
|
+
{% for param_name, param_val in request.query_params.multi_items() %}
|
|
69
|
+
{% if param_name != 'version' %}
|
|
70
|
+
<input type="hidden" name="{{ param_name }}" value="{{ param_val }}">
|
|
71
|
+
{% endif %}
|
|
72
|
+
{% endfor %}
|
|
73
|
+
|
|
74
|
+
<div class="input-group">
|
|
75
|
+
<input
|
|
76
|
+
id="version-input"
|
|
77
|
+
name="version"
|
|
78
|
+
type="number"
|
|
79
|
+
class="form-control"
|
|
80
|
+
min="0"
|
|
81
|
+
max="{{ table_metadata.version }}"
|
|
82
|
+
value="{{ request.query_params.version or table_metadata.version }}"
|
|
83
|
+
>
|
|
84
|
+
<button type="submit" class="btn btn-primary">Version</button>
|
|
85
|
+
</div>
|
|
86
|
+
</form>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<a class="btn btn-primary" href="/tables/query?sql={{ sql_query }}" role="button">
|
|
90
|
+
<i class="bi-code" aria-hidden="true"></i> SQL Query
|
|
91
|
+
</a>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
{% endblock %}
|
laketower/web.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import urllib.parse
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
|
|
5
|
+
import pydantic_settings
|
|
6
|
+
from fastapi import APIRouter, FastAPI, Query, Request
|
|
7
|
+
from fastapi.responses import HTMLResponse
|
|
8
|
+
from fastapi.staticfiles import StaticFiles
|
|
9
|
+
from fastapi.templating import Jinja2Templates
|
|
10
|
+
|
|
11
|
+
from laketower.config import Config, load_yaml_config
|
|
12
|
+
from laketower.tables import (
|
|
13
|
+
DEFAULT_LIMIT,
|
|
14
|
+
execute_query,
|
|
15
|
+
generate_table_query,
|
|
16
|
+
load_table,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Settings(pydantic_settings.BaseSettings):
|
|
21
|
+
laketower_config_path: Path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def current_path_with_args(request: Request, args: list[tuple[str, str]]) -> str:
|
|
25
|
+
keys_to_update = set(arg[0] for arg in args)
|
|
26
|
+
query_params = request.query_params.multi_items()
|
|
27
|
+
new_query_params = list(
|
|
28
|
+
filter(lambda param: param[0] not in keys_to_update, query_params)
|
|
29
|
+
)
|
|
30
|
+
new_query_params.extend((k, v) for k, v in args if v is not None)
|
|
31
|
+
query_string = urllib.parse.urlencode(new_query_params)
|
|
32
|
+
return f"{request.url.path}?{query_string}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
|
|
36
|
+
templates.env.filters["current_path_with_args"] = current_path_with_args
|
|
37
|
+
|
|
38
|
+
router = APIRouter()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@router.get("/", response_class=HTMLResponse)
|
|
42
|
+
def index(request: Request) -> HTMLResponse:
|
|
43
|
+
config: Config = request.app.state.config
|
|
44
|
+
return templates.TemplateResponse(
|
|
45
|
+
request=request,
|
|
46
|
+
name="index.html",
|
|
47
|
+
context={"tables": config.tables},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@router.get("/tables/query", response_class=HTMLResponse)
|
|
52
|
+
def get_tables_query(request: Request, sql: str) -> HTMLResponse:
|
|
53
|
+
config: Config = request.app.state.config
|
|
54
|
+
tables_dataset = {
|
|
55
|
+
table_config.name: load_table(table_config).dataset()
|
|
56
|
+
for table_config in config.tables
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
results = execute_query(tables_dataset, sql)
|
|
61
|
+
error = None
|
|
62
|
+
except ValueError as e:
|
|
63
|
+
error = {"message": str(e)}
|
|
64
|
+
results = None
|
|
65
|
+
|
|
66
|
+
return templates.TemplateResponse(
|
|
67
|
+
request=request,
|
|
68
|
+
name="tables/query.html",
|
|
69
|
+
context={
|
|
70
|
+
"tables": config.tables,
|
|
71
|
+
"table_results": results,
|
|
72
|
+
"sql_query": sql,
|
|
73
|
+
"error": error,
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.get("/tables/{table_id}", response_class=HTMLResponse)
|
|
79
|
+
def get_table_index(request: Request, table_id: str) -> HTMLResponse:
|
|
80
|
+
config: Config = request.app.state.config
|
|
81
|
+
table_config = next(
|
|
82
|
+
filter(lambda table_config: table_config.name == table_id, config.tables)
|
|
83
|
+
)
|
|
84
|
+
table = load_table(table_config)
|
|
85
|
+
|
|
86
|
+
return templates.TemplateResponse(
|
|
87
|
+
request=request,
|
|
88
|
+
name="tables/index.html",
|
|
89
|
+
context={
|
|
90
|
+
"tables": config.tables,
|
|
91
|
+
"table_id": table_id,
|
|
92
|
+
"table_metadata": table.metadata(),
|
|
93
|
+
"table_schema": table.schema(),
|
|
94
|
+
},
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@router.get("/tables/{table_id}/history", response_class=HTMLResponse)
|
|
99
|
+
def get_table_history(request: Request, table_id: str) -> HTMLResponse:
|
|
100
|
+
config: Config = request.app.state.config
|
|
101
|
+
table_config = next(
|
|
102
|
+
filter(lambda table_config: table_config.name == table_id, config.tables)
|
|
103
|
+
)
|
|
104
|
+
table = load_table(table_config)
|
|
105
|
+
|
|
106
|
+
return templates.TemplateResponse(
|
|
107
|
+
request=request,
|
|
108
|
+
name="tables/history.html",
|
|
109
|
+
context={
|
|
110
|
+
"tables": config.tables,
|
|
111
|
+
"table_id": table_id,
|
|
112
|
+
"table_history": table.history(),
|
|
113
|
+
},
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@router.get("/tables/{table_id}/view", response_class=HTMLResponse)
|
|
118
|
+
def get_table_view(
|
|
119
|
+
request: Request,
|
|
120
|
+
table_id: str,
|
|
121
|
+
limit: int | None = None,
|
|
122
|
+
cols: Annotated[list[str] | None, Query()] = None,
|
|
123
|
+
sort_asc: str | None = None,
|
|
124
|
+
sort_desc: str | None = None,
|
|
125
|
+
version: int | None = None,
|
|
126
|
+
) -> HTMLResponse:
|
|
127
|
+
config: Config = request.app.state.config
|
|
128
|
+
table_config = next(
|
|
129
|
+
filter(lambda table_config: table_config.name == table_id, config.tables)
|
|
130
|
+
)
|
|
131
|
+
table = load_table(table_config)
|
|
132
|
+
table_name = table_config.name
|
|
133
|
+
table_metadata = table.metadata()
|
|
134
|
+
table_dataset = table.dataset(version=version)
|
|
135
|
+
sql_query = generate_table_query(
|
|
136
|
+
table_name, limit=limit, cols=cols, sort_asc=sort_asc, sort_desc=sort_desc
|
|
137
|
+
)
|
|
138
|
+
results = execute_query({table_name: table_dataset}, sql_query)
|
|
139
|
+
|
|
140
|
+
return templates.TemplateResponse(
|
|
141
|
+
request=request,
|
|
142
|
+
name="tables/view.html",
|
|
143
|
+
context={
|
|
144
|
+
"tables": config.tables,
|
|
145
|
+
"table_id": table_id,
|
|
146
|
+
"table_metadata": table_metadata,
|
|
147
|
+
"table_results": results,
|
|
148
|
+
"sql_query": sql_query,
|
|
149
|
+
"default_limit": DEFAULT_LIMIT,
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def create_app() -> FastAPI:
|
|
155
|
+
settings = Settings() # type: ignore[call-arg]
|
|
156
|
+
config = load_yaml_config(settings.laketower_config_path)
|
|
157
|
+
|
|
158
|
+
app = FastAPI(title="laketower")
|
|
159
|
+
app.mount(
|
|
160
|
+
"/static",
|
|
161
|
+
StaticFiles(directory=Path(__file__).parent / "static"),
|
|
162
|
+
name="static",
|
|
163
|
+
)
|
|
164
|
+
app.include_router(router)
|
|
165
|
+
app.state.config = config
|
|
166
|
+
|
|
167
|
+
return app
|
|
@@ -1,24 +1,39 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: laketower
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Oversee your lakehouse
|
|
5
|
+
Project-URL: Repository, https://github.com/datalpia/laketower
|
|
6
|
+
Project-URL: Issues, https://github.com/datalpia/laketower/issues
|
|
7
|
+
Project-URL: Changelog, https://github.com/datalpia/laketower/blob/master/CHANGELOG.md
|
|
5
8
|
Author-email: Romain Clement <git@romain-clement.net>
|
|
6
9
|
License: AGPL-3.0-or-later
|
|
7
10
|
License-File: LICENSE.md
|
|
11
|
+
Keywords: data,delta-lake,lakehouse,sql
|
|
8
12
|
Classifier: Development Status :: 2 - Pre-Alpha
|
|
9
13
|
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
10
15
|
Classifier: Intended Audience :: Information Technology
|
|
16
|
+
Classifier: Intended Audience :: Other Audience
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Database
|
|
11
22
|
Classifier: Topic :: Software Development
|
|
12
23
|
Classifier: Topic :: Utilities
|
|
13
|
-
Requires-Python: <3.14,>=3.
|
|
24
|
+
Requires-Python: <3.14,>=3.10
|
|
14
25
|
Requires-Dist: deltalake
|
|
15
26
|
Requires-Dist: duckdb
|
|
27
|
+
Requires-Dist: fastapi
|
|
28
|
+
Requires-Dist: jinja2>=3
|
|
16
29
|
Requires-Dist: pandas
|
|
17
|
-
Requires-Dist: pyarrow
|
|
18
|
-
Requires-Dist: pydantic
|
|
30
|
+
Requires-Dist: pyarrow!=19.0.0
|
|
31
|
+
Requires-Dist: pydantic-settings>=2
|
|
32
|
+
Requires-Dist: pydantic>=2
|
|
19
33
|
Requires-Dist: pyyaml
|
|
20
34
|
Requires-Dist: rich
|
|
21
35
|
Requires-Dist: sqlglot
|
|
36
|
+
Requires-Dist: uvicorn
|
|
22
37
|
Description-Content-Type: text/markdown
|
|
23
38
|
|
|
24
39
|
# Laketower
|
|
@@ -26,8 +41,9 @@ Description-Content-Type: text/markdown
|
|
|
26
41
|
> Oversee your lakehouse
|
|
27
42
|
|
|
28
43
|
[](https://pypi.org/project/laketower/)
|
|
44
|
+
[](https://pypi.org/project/laketower/)
|
|
29
45
|
[](https://github.com/datalpia/laketower/actions/workflows/ci-cd.yml)
|
|
30
|
-
[](https://github.com/datalpia/laketower/blob/main/LICENSE)
|
|
46
|
+
[](https://github.com/datalpia/laketower/blob/main/LICENSE.md)
|
|
31
47
|
|
|
32
48
|
Utility application to explore and manage tables in your data lakehouse, especially tailored for data pipelines local development.
|
|
33
49
|
|
|
@@ -40,6 +56,7 @@ Utility application to explore and manage tables in your data lakehouse, especia
|
|
|
40
56
|
- View table content with a simple query builder
|
|
41
57
|
- Query all registered tables with DuckDB SQL dialect
|
|
42
58
|
- Static and versionable YAML configuration
|
|
59
|
+
- Web application
|
|
43
60
|
- CLI application
|
|
44
61
|
|
|
45
62
|
## Installation
|
|
@@ -90,21 +107,30 @@ tables:
|
|
|
90
107
|
format: delta
|
|
91
108
|
```
|
|
92
109
|
|
|
110
|
+
### Web Application
|
|
111
|
+
|
|
112
|
+
The easiest way to get started is to launch the Laketower web application:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
$ laketower -c demo/laketower.yml web
|
|
116
|
+
```
|
|
117
|
+
|
|
93
118
|
### CLI
|
|
94
119
|
|
|
95
120
|
Laketower provides a CLI interface:
|
|
96
121
|
|
|
97
122
|
```bash
|
|
98
123
|
$ laketower --help
|
|
99
|
-
usage: laketower [-h] [--version] [--config CONFIG] {config,tables} ...
|
|
124
|
+
usage: laketower [-h] [--version] [--config CONFIG] {web,config,tables} ...
|
|
100
125
|
|
|
101
126
|
options:
|
|
102
127
|
-h, --help show this help message and exit
|
|
103
128
|
--version show program's version number and exit
|
|
104
|
-
--config, -c CONFIG Path to the Laketower YAML configuration file
|
|
129
|
+
--config, -c CONFIG Path to the Laketower YAML configuration file (default: laketower.yml)
|
|
105
130
|
|
|
106
131
|
commands:
|
|
107
|
-
{config,tables}
|
|
132
|
+
{web,config,tables}
|
|
133
|
+
web Launch the web application
|
|
108
134
|
config Work with configuration
|
|
109
135
|
tables Work with tables
|
|
110
136
|
```
|
|
@@ -228,6 +254,7 @@ Optional arguments:
|
|
|
228
254
|
- `--sort-asc <col>`: sort by a column name in ascending order
|
|
229
255
|
- `--sort-desc <col>`: sort by a column name in descending order
|
|
230
256
|
- `--limit <num>` (default 10): limit the number of rows
|
|
257
|
+
- `--version`: time-travel to table revision number
|
|
231
258
|
|
|
232
259
|
```bash
|
|
233
260
|
$ laketower -c demo/laketower.yml tables view weather
|
|
@@ -262,6 +289,26 @@ $ laketower -c demo/laketower.yml tables view weather --cols time city temperatu
|
|
|
262
289
|
└───────────────────────────┴──────────┴───────────────────┘
|
|
263
290
|
```
|
|
264
291
|
|
|
292
|
+
```bash
|
|
293
|
+
$ laketower -c demo/laketower.yml tables view weather --version 1
|
|
294
|
+
|
|
295
|
+
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓
|
|
296
|
+
┃ time ┃ city ┃ temperature_2m ┃ relative_humidity_2m ┃ wind_speed_10m ┃
|
|
297
|
+
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩
|
|
298
|
+
│ 2025-01-26 01:00:00+01:00 │ Grenoble │ 7.0 │ 87.0 │ 8.899999618530273 │
|
|
299
|
+
│ 2025-01-26 02:00:00+01:00 │ Grenoble │ 6.099999904632568 │ 87.0 │ 6.199999809265137 │
|
|
300
|
+
│ 2025-01-26 03:00:00+01:00 │ Grenoble │ 6.0 │ 86.0 │ 2.700000047683716 │
|
|
301
|
+
│ 2025-01-26 04:00:00+01:00 │ Grenoble │ 6.099999904632568 │ 82.0 │ 3.0999999046325684 │
|
|
302
|
+
│ 2025-01-26 05:00:00+01:00 │ Grenoble │ 5.5 │ 87.0 │ 3.299999952316284 │
|
|
303
|
+
│ 2025-01-26 06:00:00+01:00 │ Grenoble │ 5.199999809265137 │ 91.0 │ 2.200000047683716 │
|
|
304
|
+
│ 2025-01-26 07:00:00+01:00 │ Grenoble │ 4.800000190734863 │ 86.0 │ 3.0 │
|
|
305
|
+
│ 2025-01-26 08:00:00+01:00 │ Grenoble │ 4.900000095367432 │ 83.0 │ 1.100000023841858 │
|
|
306
|
+
│ 2025-01-26 09:00:00+01:00 │ Grenoble │ 4.0 │ 92.0 │ 3.0999999046325684 │
|
|
307
|
+
│ 2025-01-26 10:00:00+01:00 │ Grenoble │ 5.0 │ 86.0 │ 6.400000095367432 │
|
|
308
|
+
└───────────────────────────┴──────────┴───────────────────┴──────────────────────┴────────────────────┘
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
|
|
265
312
|
#### Query all registered tables
|
|
266
313
|
|
|
267
314
|
Query any registered tables using DuckDB SQL dialect!
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
laketower/__about__.py,sha256=VrXpHDu3erkzwl_WXrqINBm9xWkcyUy53IQOj042dOs,22
|
|
2
|
+
laketower/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
laketower/__main__.py,sha256=czKxJKG8OfncnxWmpaOWx7b1JBwFnZNQi7wKSTncB4M,108
|
|
4
|
+
laketower/cli.py,sha256=_2DM_TrrgZ4qOJhMz0f3g5sKrCwqMe0rIpzQTAwy4p8,8991
|
|
5
|
+
laketower/config.py,sha256=cJ7KKWd2Pv9T_MbpK7QxqD8PdPZ9I38i3-tofcU0KeI,1049
|
|
6
|
+
laketower/tables.py,sha256=1hEkegorMPG-aZ69UbvWcL4QwOgGYe7DvMc1u-i5pUI,4192
|
|
7
|
+
laketower/web.py,sha256=YnwpjiqizmhkoXGBm8ylATwJniIpKTl6RJDB9YMAIz0,5035
|
|
8
|
+
laketower/static/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
laketower/templates/_base.html,sha256=i60vd7da_iKm4o9DulPWD1Io8dbljNYVf9hcEGHAegI,2927
|
|
10
|
+
laketower/templates/index.html,sha256=dLF2Og0qgzBkvGyVRidRNzTv0u4o97ifOx1jVeig8Kg,59
|
|
11
|
+
laketower/templates/tables/_macros.html,sha256=O-D57cTZDyWOCOQxzM1ZJkrSXdMJ7bhKy_znSsN8FX8,740
|
|
12
|
+
laketower/templates/tables/history.html,sha256=yAW0xw9_Uxp0QZYKje6qhcbpeznxI3fb740hfNyILZ8,1740
|
|
13
|
+
laketower/templates/tables/index.html,sha256=oY13l_p8qozlLONanLpga1WhEo4oTP92pRf9sBSuFZI,2394
|
|
14
|
+
laketower/templates/tables/query.html,sha256=9NFWJDE50AE-P6BPRw9M4TTNqPCjysWsSRHhcw_sqvc,1206
|
|
15
|
+
laketower/templates/tables/view.html,sha256=sFCQlEzIODc-M6VuHIqGI5PnPkGPaYPg21WAzQg_af0,3860
|
|
16
|
+
laketower-0.3.0.dist-info/METADATA,sha256=TcdpEndo5GaQWcFphFBj8oHN6zILFAMeQ9SkoXaWUCk,14963
|
|
17
|
+
laketower-0.3.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
18
|
+
laketower-0.3.0.dist-info/entry_points.txt,sha256=OL_4klopvyEzasJOFJ-sKu54lv24Jvomni32h1WVUjk,48
|
|
19
|
+
laketower-0.3.0.dist-info/licenses/LICENSE.md,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
|
|
20
|
+
laketower-0.3.0.dist-info/RECORD,,
|
laketower-0.1.0.dist-info/RECORD
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
laketower/__about__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
-
laketower/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
-
laketower/__main__.py,sha256=czKxJKG8OfncnxWmpaOWx7b1JBwFnZNQi7wKSTncB4M,108
|
|
4
|
-
laketower/cli.py,sha256=W0fmtYwprapBCvIyZS12Sdfxs82WWznLEdftX6kJbRE,13385
|
|
5
|
-
laketower-0.1.0.dist-info/METADATA,sha256=JGVuSka5mnQJg2PVbtvNxGMEeVKtBOC86qCt5SDSw5o,11511
|
|
6
|
-
laketower-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
7
|
-
laketower-0.1.0.dist-info/entry_points.txt,sha256=OL_4klopvyEzasJOFJ-sKu54lv24Jvomni32h1WVUjk,48
|
|
8
|
-
laketower-0.1.0.dist-info/licenses/LICENSE.md,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
|
|
9
|
-
laketower-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|