laketower 0.1.0__py3-none-any.whl → 0.2.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 -177
- laketower/config.py +45 -0
- laketower/static/.gitkeep +0 -0
- laketower/tables.py +136 -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 +41 -0
- laketower/templates/tables/view.html +96 -0
- laketower/web.py +160 -0
- {laketower-0.1.0.dist-info → laketower-0.2.0.dist-info}/METADATA +55 -7
- laketower-0.2.0.dist-info/RECORD +20 -0
- laketower-0.1.0.dist-info/RECORD +0 -9
- {laketower-0.1.0.dist-info → laketower-0.2.0.dist-info}/WHEEL +0 -0
- {laketower-0.1.0.dist-info → laketower-0.2.0.dist-info}/entry_points.txt +0 -0
- {laketower-0.1.0.dist-info → laketower-0.2.0.dist-info}/licenses/LICENSE.md +0 -0
laketower/__about__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.
|
|
1
|
+
__version__ = "0.2.0"
|
laketower/cli.py
CHANGED
|
@@ -1,178 +1,24 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
-
import
|
|
5
|
-
from datetime import datetime, timezone
|
|
4
|
+
import os
|
|
6
5
|
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
8
6
|
|
|
9
|
-
import
|
|
10
|
-
import duckdb
|
|
11
|
-
import pandas as pd
|
|
12
|
-
import pyarrow as pa
|
|
13
|
-
import pydantic
|
|
7
|
+
import rich.jupyter
|
|
14
8
|
import rich.panel
|
|
15
9
|
import rich.table
|
|
16
10
|
import rich.text
|
|
17
11
|
import rich.tree
|
|
18
|
-
import
|
|
19
|
-
import sqlglot.dialects
|
|
20
|
-
import sqlglot.dialects.duckdb
|
|
21
|
-
import sqlglot.generator
|
|
22
|
-
import yaml
|
|
12
|
+
import uvicorn
|
|
23
13
|
|
|
24
14
|
from laketower.__about__ import __version__
|
|
15
|
+
from laketower.config import load_yaml_config
|
|
16
|
+
from laketower.tables import execute_query, generate_table_query, load_table
|
|
25
17
|
|
|
26
18
|
|
|
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
|
|
19
|
+
def run_web(config_path: Path, reload: bool) -> None: # pragma: no cover
|
|
20
|
+
os.environ["LAKETOWER_CONFIG_PATH"] = str(config_path.absolute())
|
|
21
|
+
uvicorn.run("laketower.web:create_app", factory=True, reload=reload)
|
|
176
22
|
|
|
177
23
|
|
|
178
24
|
def validate_config(config_path: Path) -> None:
|
|
@@ -200,7 +46,8 @@ def list_tables(config_path: Path) -> None:
|
|
|
200
46
|
def table_metadata(config_path: Path, table_name: str) -> None:
|
|
201
47
|
config = load_yaml_config(config_path)
|
|
202
48
|
table_config = next(filter(lambda x: x.name == table_name, config.tables))
|
|
203
|
-
|
|
49
|
+
table = load_table(table_config)
|
|
50
|
+
metadata = table.metadata()
|
|
204
51
|
|
|
205
52
|
tree = rich.tree.Tree(table_name)
|
|
206
53
|
tree.add(f"name: {metadata.name}")
|
|
@@ -219,7 +66,8 @@ def table_metadata(config_path: Path, table_name: str) -> None:
|
|
|
219
66
|
def table_schema(config_path: Path, table_name: str) -> None:
|
|
220
67
|
config = load_yaml_config(config_path)
|
|
221
68
|
table_config = next(filter(lambda x: x.name == table_name, config.tables))
|
|
222
|
-
|
|
69
|
+
table = load_table(table_config)
|
|
70
|
+
schema = table.schema()
|
|
223
71
|
|
|
224
72
|
tree = rich.tree.Tree(table_name)
|
|
225
73
|
for field in schema:
|
|
@@ -232,7 +80,8 @@ def table_schema(config_path: Path, table_name: str) -> None:
|
|
|
232
80
|
def table_history(config_path: Path, table_name: str) -> None:
|
|
233
81
|
config = load_yaml_config(config_path)
|
|
234
82
|
table_config = next(filter(lambda x: x.name == table_name, config.tables))
|
|
235
|
-
|
|
83
|
+
table = load_table(table_config)
|
|
84
|
+
history = table.history()
|
|
236
85
|
|
|
237
86
|
tree = rich.tree.Tree(table_name)
|
|
238
87
|
for rev in history.revisions:
|
|
@@ -257,22 +106,21 @@ def view_table(
|
|
|
257
106
|
cols: list[str] | None = None,
|
|
258
107
|
sort_asc: str | None = None,
|
|
259
108
|
sort_desc: str | None = None,
|
|
109
|
+
version: int | None = None,
|
|
260
110
|
) -> None:
|
|
261
111
|
config = load_yaml_config(config_path)
|
|
262
112
|
table_config = next(filter(lambda x: x.name == table_name, config.tables))
|
|
113
|
+
table = load_table(table_config)
|
|
114
|
+
table_dataset = table.dataset(version=version)
|
|
115
|
+
sql_query = generate_table_query(
|
|
116
|
+
table_name, limit=limit, cols=cols, sort_asc=sort_asc, sort_desc=sort_desc
|
|
117
|
+
)
|
|
118
|
+
results = execute_query({table_name: table_dataset}, sql_query)
|
|
263
119
|
|
|
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
120
|
out = rich.table.Table()
|
|
273
121
|
for column in results.columns:
|
|
274
122
|
out.add_column(column)
|
|
275
|
-
for value_list in results.
|
|
123
|
+
for value_list in results.to_numpy().tolist():
|
|
276
124
|
row = [str(x) for x in value_list]
|
|
277
125
|
out.add_row(*row)
|
|
278
126
|
|
|
@@ -282,10 +130,14 @@ def view_table(
|
|
|
282
130
|
|
|
283
131
|
def query_table(config_path: Path, sql_query: str) -> None:
|
|
284
132
|
config = load_yaml_config(config_path)
|
|
133
|
+
tables_dataset = {
|
|
134
|
+
table_config.name: load_table(table_config).dataset()
|
|
135
|
+
for table_config in config.tables
|
|
136
|
+
}
|
|
285
137
|
|
|
286
138
|
out: rich.jupyter.JupyterMixin
|
|
287
139
|
try:
|
|
288
|
-
results = execute_query(
|
|
140
|
+
results = execute_query(tables_dataset, sql_query)
|
|
289
141
|
out = rich.table.Table()
|
|
290
142
|
for column in results.columns:
|
|
291
143
|
out.add_column(column)
|
|
@@ -300,7 +152,9 @@ def query_table(config_path: Path, sql_query: str) -> None:
|
|
|
300
152
|
|
|
301
153
|
|
|
302
154
|
def cli() -> None:
|
|
303
|
-
parser = argparse.ArgumentParser(
|
|
155
|
+
parser = argparse.ArgumentParser(
|
|
156
|
+
"laketower", formatter_class=argparse.ArgumentDefaultsHelpFormatter
|
|
157
|
+
)
|
|
304
158
|
parser.add_argument("--version", action="version", version=__version__)
|
|
305
159
|
parser.add_argument(
|
|
306
160
|
"--config",
|
|
@@ -311,6 +165,17 @@ def cli() -> None:
|
|
|
311
165
|
)
|
|
312
166
|
subparsers = parser.add_subparsers(title="commands", required=True)
|
|
313
167
|
|
|
168
|
+
parser_web = subparsers.add_parser(
|
|
169
|
+
"web", help="Launch the web application", add_help=True
|
|
170
|
+
)
|
|
171
|
+
parser_web.add_argument(
|
|
172
|
+
"--reload",
|
|
173
|
+
help="Reload the web server on changes",
|
|
174
|
+
action="store_true",
|
|
175
|
+
required=False,
|
|
176
|
+
)
|
|
177
|
+
parser_web.set_defaults(func=lambda x: run_web(x.config, x.reload))
|
|
178
|
+
|
|
314
179
|
parser_config = subparsers.add_parser(
|
|
315
180
|
"config", help="Work with configuration", add_help=True
|
|
316
181
|
)
|
|
@@ -364,9 +229,12 @@ def cli() -> None:
|
|
|
364
229
|
parser_tables_view_sort_group.add_argument(
|
|
365
230
|
"--sort-desc", help="Sort by given column in descending order"
|
|
366
231
|
)
|
|
232
|
+
parser_tables_view.add_argument(
|
|
233
|
+
"--version", type=int, help="Time-travel to table revision number"
|
|
234
|
+
)
|
|
367
235
|
parser_tables_view.set_defaults(
|
|
368
236
|
func=lambda x: view_table(
|
|
369
|
-
x.config, x.table, x.limit, x.cols, x.sort_asc, x.sort_desc
|
|
237
|
+
x.config, x.table, x.limit, x.cols, x.sort_asc, x.sort_desc, x.version
|
|
370
238
|
)
|
|
371
239
|
)
|
|
372
240
|
|
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,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any, Optional, Protocol
|
|
5
|
+
|
|
6
|
+
import deltalake
|
|
7
|
+
import duckdb
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import pyarrow as pa
|
|
10
|
+
import pyarrow.dataset as padataset
|
|
11
|
+
import pydantic
|
|
12
|
+
import sqlglot
|
|
13
|
+
import sqlglot.dialects.duckdb
|
|
14
|
+
|
|
15
|
+
from laketower.config import ConfigTable, TableFormats
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DEFAULT_LIMIT = 10
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TableMetadata(pydantic.BaseModel):
|
|
22
|
+
table_format: TableFormats
|
|
23
|
+
name: Optional[str] = None
|
|
24
|
+
description: Optional[str] = None
|
|
25
|
+
uri: str
|
|
26
|
+
id: str
|
|
27
|
+
version: int
|
|
28
|
+
created_at: datetime
|
|
29
|
+
partitions: list[str]
|
|
30
|
+
configuration: dict[str, str]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TableRevision(pydantic.BaseModel):
|
|
34
|
+
version: int
|
|
35
|
+
timestamp: datetime
|
|
36
|
+
client_version: Optional[str] = None
|
|
37
|
+
operation: str
|
|
38
|
+
operation_parameters: dict[str, Any]
|
|
39
|
+
operation_metrics: dict[str, Any]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TableHistory(pydantic.BaseModel):
|
|
43
|
+
revisions: list[TableRevision]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TableProtocol(Protocol): # pragma: no cover
|
|
47
|
+
def metadata(self) -> TableMetadata: ...
|
|
48
|
+
def schema(self) -> pa.Schema: ...
|
|
49
|
+
def history(self) -> TableHistory: ...
|
|
50
|
+
def dataset(self, version: int | str | None = None) -> padataset.Dataset: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DeltaTable:
|
|
54
|
+
def __init__(self, table_config: ConfigTable):
|
|
55
|
+
super().__init__()
|
|
56
|
+
self.table_config = table_config
|
|
57
|
+
self._impl = deltalake.DeltaTable(table_config.uri)
|
|
58
|
+
|
|
59
|
+
def metadata(self) -> TableMetadata:
|
|
60
|
+
metadata = self._impl.metadata()
|
|
61
|
+
return TableMetadata(
|
|
62
|
+
table_format=self.table_config.table_format,
|
|
63
|
+
name=metadata.name,
|
|
64
|
+
description=metadata.description,
|
|
65
|
+
uri=self._impl.table_uri,
|
|
66
|
+
id=str(metadata.id),
|
|
67
|
+
version=self._impl.version(),
|
|
68
|
+
created_at=datetime.fromtimestamp(
|
|
69
|
+
metadata.created_time / 1000, tz=timezone.utc
|
|
70
|
+
),
|
|
71
|
+
partitions=metadata.partition_columns,
|
|
72
|
+
configuration=metadata.configuration,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def schema(self) -> pa.Schema:
|
|
76
|
+
return self._impl.schema().to_pyarrow()
|
|
77
|
+
|
|
78
|
+
def history(self) -> TableHistory:
|
|
79
|
+
delta_history = self._impl.history()
|
|
80
|
+
revisions = [
|
|
81
|
+
TableRevision(
|
|
82
|
+
version=event["version"],
|
|
83
|
+
timestamp=datetime.fromtimestamp(
|
|
84
|
+
event["timestamp"] / 1000, tz=timezone.utc
|
|
85
|
+
),
|
|
86
|
+
client_version=event.get("clientVersion") or event.get("engineInfo"),
|
|
87
|
+
operation=event["operation"],
|
|
88
|
+
operation_parameters=event["operationParameters"],
|
|
89
|
+
operation_metrics=event.get("operationMetrics") or {},
|
|
90
|
+
)
|
|
91
|
+
for event in delta_history
|
|
92
|
+
]
|
|
93
|
+
return TableHistory(revisions=revisions)
|
|
94
|
+
|
|
95
|
+
def dataset(self, version: int | str | None = None) -> padataset.Dataset:
|
|
96
|
+
if version is not None:
|
|
97
|
+
self._impl.load_as_version(version)
|
|
98
|
+
return self._impl.to_pyarrow_dataset()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_table(table_config: ConfigTable) -> TableProtocol:
|
|
102
|
+
format_handler = {TableFormats.delta: DeltaTable}
|
|
103
|
+
return format_handler[table_config.table_format](table_config)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def generate_table_query(
|
|
107
|
+
table_name: str,
|
|
108
|
+
limit: int | None = None,
|
|
109
|
+
cols: list[str] | None = None,
|
|
110
|
+
sort_asc: str | None = None,
|
|
111
|
+
sort_desc: str | None = None,
|
|
112
|
+
) -> str:
|
|
113
|
+
query_expr = (
|
|
114
|
+
sqlglot.select(*(cols or ["*"])).from_(table_name).limit(limit or DEFAULT_LIMIT)
|
|
115
|
+
)
|
|
116
|
+
if sort_asc:
|
|
117
|
+
query_expr = query_expr.order_by(f"{sort_asc} asc")
|
|
118
|
+
elif sort_desc:
|
|
119
|
+
query_expr = query_expr.order_by(f"{sort_desc} desc")
|
|
120
|
+
return sqlglot.Generator(dialect=sqlglot.dialects.duckdb.DuckDB).generate(
|
|
121
|
+
query_expr
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def execute_query(
|
|
126
|
+
tables_datasets: dict[str, padataset.Dataset], sql_query: str
|
|
127
|
+
) -> pd.DataFrame:
|
|
128
|
+
try:
|
|
129
|
+
conn = duckdb.connect()
|
|
130
|
+
for table_name, table_dataset in tables_datasets.items():
|
|
131
|
+
view_name = f"{table_name}_view"
|
|
132
|
+
conn.register(view_name, table_dataset)
|
|
133
|
+
conn.execute(f"create table {table_name} as select * from {view_name}") # nosec B608
|
|
134
|
+
return conn.execute(sql_query).df()
|
|
135
|
+
except duckdb.Error as e:
|
|
136
|
+
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="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,41 @@
|
|
|
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
|
+
<div class="table-responsive">
|
|
20
|
+
<table class="table table-sm table-bordered table-striped table-hover">
|
|
21
|
+
<thead>
|
|
22
|
+
<tr>
|
|
23
|
+
{% for column in table_results.columns %}
|
|
24
|
+
<th>{{ column }}</th>
|
|
25
|
+
{% endfor %}
|
|
26
|
+
</tr>
|
|
27
|
+
</thead>
|
|
28
|
+
<tbody class="table-group-divider">
|
|
29
|
+
{% for row in table_results.to_numpy().tolist() %}
|
|
30
|
+
<tr>
|
|
31
|
+
{% for col in row %}
|
|
32
|
+
<td>{{ col }}</td>
|
|
33
|
+
{% endfor %}
|
|
34
|
+
</tr>
|
|
35
|
+
{% endfor %}
|
|
36
|
+
</tbody>
|
|
37
|
+
</table>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
{% 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,160 @@
|
|
|
1
|
+
import urllib.parse
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Annotated, Optional
|
|
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
|
+
results = execute_query(tables_dataset, sql)
|
|
59
|
+
|
|
60
|
+
return templates.TemplateResponse(
|
|
61
|
+
request=request,
|
|
62
|
+
name="tables/query.html",
|
|
63
|
+
context={
|
|
64
|
+
"tables": config.tables,
|
|
65
|
+
"table_results": results,
|
|
66
|
+
"sql_query": sql,
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.get("/tables/{table_id}", response_class=HTMLResponse)
|
|
72
|
+
def get_table_index(request: Request, table_id: str) -> HTMLResponse:
|
|
73
|
+
config: Config = request.app.state.config
|
|
74
|
+
table_config = next(
|
|
75
|
+
filter(lambda table_config: table_config.name == table_id, config.tables)
|
|
76
|
+
)
|
|
77
|
+
table = load_table(table_config)
|
|
78
|
+
|
|
79
|
+
return templates.TemplateResponse(
|
|
80
|
+
request=request,
|
|
81
|
+
name="tables/index.html",
|
|
82
|
+
context={
|
|
83
|
+
"tables": config.tables,
|
|
84
|
+
"table_id": table_id,
|
|
85
|
+
"table_metadata": table.metadata(),
|
|
86
|
+
"table_schema": table.schema(),
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@router.get("/tables/{table_id}/history", response_class=HTMLResponse)
|
|
92
|
+
def get_table_history(request: Request, table_id: str) -> HTMLResponse:
|
|
93
|
+
config: Config = request.app.state.config
|
|
94
|
+
table_config = next(
|
|
95
|
+
filter(lambda table_config: table_config.name == table_id, config.tables)
|
|
96
|
+
)
|
|
97
|
+
table = load_table(table_config)
|
|
98
|
+
|
|
99
|
+
return templates.TemplateResponse(
|
|
100
|
+
request=request,
|
|
101
|
+
name="tables/history.html",
|
|
102
|
+
context={
|
|
103
|
+
"tables": config.tables,
|
|
104
|
+
"table_id": table_id,
|
|
105
|
+
"table_history": table.history(),
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.get("/tables/{table_id}/view", response_class=HTMLResponse)
|
|
111
|
+
def get_table_view(
|
|
112
|
+
request: Request,
|
|
113
|
+
table_id: str,
|
|
114
|
+
limit: Optional[int] = None,
|
|
115
|
+
cols: Annotated[Optional[list[str]], Query()] = None,
|
|
116
|
+
sort_asc: Optional[str] = None,
|
|
117
|
+
sort_desc: Optional[str] = None,
|
|
118
|
+
version: Optional[int] = None,
|
|
119
|
+
) -> HTMLResponse:
|
|
120
|
+
config: Config = request.app.state.config
|
|
121
|
+
table_config = next(
|
|
122
|
+
filter(lambda table_config: table_config.name == table_id, config.tables)
|
|
123
|
+
)
|
|
124
|
+
table = load_table(table_config)
|
|
125
|
+
table_name = table_config.name
|
|
126
|
+
table_metadata = table.metadata()
|
|
127
|
+
table_dataset = table.dataset(version=version)
|
|
128
|
+
sql_query = generate_table_query(
|
|
129
|
+
table_name, limit=limit, cols=cols, sort_asc=sort_asc, sort_desc=sort_desc
|
|
130
|
+
)
|
|
131
|
+
results = execute_query({table_name: table_dataset}, sql_query)
|
|
132
|
+
|
|
133
|
+
return templates.TemplateResponse(
|
|
134
|
+
request=request,
|
|
135
|
+
name="tables/view.html",
|
|
136
|
+
context={
|
|
137
|
+
"tables": config.tables,
|
|
138
|
+
"table_id": table_id,
|
|
139
|
+
"table_metadata": table_metadata,
|
|
140
|
+
"table_results": results,
|
|
141
|
+
"sql_query": sql_query,
|
|
142
|
+
"default_limit": DEFAULT_LIMIT,
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def create_app() -> FastAPI:
|
|
148
|
+
settings = Settings() # type: ignore[call-arg]
|
|
149
|
+
config = load_yaml_config(settings.laketower_config_path)
|
|
150
|
+
|
|
151
|
+
app = FastAPI(title="laketower")
|
|
152
|
+
app.mount(
|
|
153
|
+
"/static",
|
|
154
|
+
StaticFiles(directory=Path(__file__).parent / "static"),
|
|
155
|
+
name="static",
|
|
156
|
+
)
|
|
157
|
+
app.include_router(router)
|
|
158
|
+
app.state.config = config
|
|
159
|
+
|
|
160
|
+
return app
|
|
@@ -1,24 +1,40 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: laketower
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Database
|
|
11
23
|
Classifier: Topic :: Software Development
|
|
12
24
|
Classifier: Topic :: Utilities
|
|
13
25
|
Requires-Python: <3.14,>=3.9
|
|
14
26
|
Requires-Dist: deltalake
|
|
15
27
|
Requires-Dist: duckdb
|
|
28
|
+
Requires-Dist: fastapi
|
|
29
|
+
Requires-Dist: jinja2>=3
|
|
16
30
|
Requires-Dist: pandas
|
|
17
|
-
Requires-Dist: pyarrow
|
|
18
|
-
Requires-Dist: pydantic
|
|
31
|
+
Requires-Dist: pyarrow!=19.0.0
|
|
32
|
+
Requires-Dist: pydantic-settings>=2
|
|
33
|
+
Requires-Dist: pydantic>=2
|
|
19
34
|
Requires-Dist: pyyaml
|
|
20
35
|
Requires-Dist: rich
|
|
21
36
|
Requires-Dist: sqlglot
|
|
37
|
+
Requires-Dist: uvicorn
|
|
22
38
|
Description-Content-Type: text/markdown
|
|
23
39
|
|
|
24
40
|
# Laketower
|
|
@@ -26,8 +42,9 @@ Description-Content-Type: text/markdown
|
|
|
26
42
|
> Oversee your lakehouse
|
|
27
43
|
|
|
28
44
|
[](https://pypi.org/project/laketower/)
|
|
45
|
+
[](https://pypi.org/project/laketower/)
|
|
29
46
|
[](https://github.com/datalpia/laketower/actions/workflows/ci-cd.yml)
|
|
30
|
-
[](https://github.com/datalpia/laketower/blob/main/LICENSE)
|
|
47
|
+
[](https://github.com/datalpia/laketower/blob/main/LICENSE.md)
|
|
31
48
|
|
|
32
49
|
Utility application to explore and manage tables in your data lakehouse, especially tailored for data pipelines local development.
|
|
33
50
|
|
|
@@ -40,6 +57,7 @@ Utility application to explore and manage tables in your data lakehouse, especia
|
|
|
40
57
|
- View table content with a simple query builder
|
|
41
58
|
- Query all registered tables with DuckDB SQL dialect
|
|
42
59
|
- Static and versionable YAML configuration
|
|
60
|
+
- Web application
|
|
43
61
|
- CLI application
|
|
44
62
|
|
|
45
63
|
## Installation
|
|
@@ -90,21 +108,30 @@ tables:
|
|
|
90
108
|
format: delta
|
|
91
109
|
```
|
|
92
110
|
|
|
111
|
+
### Web Application
|
|
112
|
+
|
|
113
|
+
The easiest way to get started is to launch the Laketower web application:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
$ laketower -c demo/laketower.yml web
|
|
117
|
+
```
|
|
118
|
+
|
|
93
119
|
### CLI
|
|
94
120
|
|
|
95
121
|
Laketower provides a CLI interface:
|
|
96
122
|
|
|
97
123
|
```bash
|
|
98
124
|
$ laketower --help
|
|
99
|
-
usage: laketower [-h] [--version] [--config CONFIG] {config,tables} ...
|
|
125
|
+
usage: laketower [-h] [--version] [--config CONFIG] {web,config,tables} ...
|
|
100
126
|
|
|
101
127
|
options:
|
|
102
128
|
-h, --help show this help message and exit
|
|
103
129
|
--version show program's version number and exit
|
|
104
|
-
--config, -c CONFIG Path to the Laketower YAML configuration file
|
|
130
|
+
--config, -c CONFIG Path to the Laketower YAML configuration file (default: laketower.yml)
|
|
105
131
|
|
|
106
132
|
commands:
|
|
107
|
-
{config,tables}
|
|
133
|
+
{web,config,tables}
|
|
134
|
+
web Launch the web application
|
|
108
135
|
config Work with configuration
|
|
109
136
|
tables Work with tables
|
|
110
137
|
```
|
|
@@ -228,6 +255,7 @@ Optional arguments:
|
|
|
228
255
|
- `--sort-asc <col>`: sort by a column name in ascending order
|
|
229
256
|
- `--sort-desc <col>`: sort by a column name in descending order
|
|
230
257
|
- `--limit <num>` (default 10): limit the number of rows
|
|
258
|
+
- `--version`: time-travel to table revision number
|
|
231
259
|
|
|
232
260
|
```bash
|
|
233
261
|
$ laketower -c demo/laketower.yml tables view weather
|
|
@@ -262,6 +290,26 @@ $ laketower -c demo/laketower.yml tables view weather --cols time city temperatu
|
|
|
262
290
|
└───────────────────────────┴──────────┴───────────────────┘
|
|
263
291
|
```
|
|
264
292
|
|
|
293
|
+
```bash
|
|
294
|
+
$ laketower -c demo/laketower.yml tables view weather --version 1
|
|
295
|
+
|
|
296
|
+
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┓
|
|
297
|
+
┃ time ┃ city ┃ temperature_2m ┃ relative_humidity_2m ┃ wind_speed_10m ┃
|
|
298
|
+
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━┩
|
|
299
|
+
│ 2025-01-26 01:00:00+01:00 │ Grenoble │ 7.0 │ 87.0 │ 8.899999618530273 │
|
|
300
|
+
│ 2025-01-26 02:00:00+01:00 │ Grenoble │ 6.099999904632568 │ 87.0 │ 6.199999809265137 │
|
|
301
|
+
│ 2025-01-26 03:00:00+01:00 │ Grenoble │ 6.0 │ 86.0 │ 2.700000047683716 │
|
|
302
|
+
│ 2025-01-26 04:00:00+01:00 │ Grenoble │ 6.099999904632568 │ 82.0 │ 3.0999999046325684 │
|
|
303
|
+
│ 2025-01-26 05:00:00+01:00 │ Grenoble │ 5.5 │ 87.0 │ 3.299999952316284 │
|
|
304
|
+
│ 2025-01-26 06:00:00+01:00 │ Grenoble │ 5.199999809265137 │ 91.0 │ 2.200000047683716 │
|
|
305
|
+
│ 2025-01-26 07:00:00+01:00 │ Grenoble │ 4.800000190734863 │ 86.0 │ 3.0 │
|
|
306
|
+
│ 2025-01-26 08:00:00+01:00 │ Grenoble │ 4.900000095367432 │ 83.0 │ 1.100000023841858 │
|
|
307
|
+
│ 2025-01-26 09:00:00+01:00 │ Grenoble │ 4.0 │ 92.0 │ 3.0999999046325684 │
|
|
308
|
+
│ 2025-01-26 10:00:00+01:00 │ Grenoble │ 5.0 │ 86.0 │ 6.400000095367432 │
|
|
309
|
+
└───────────────────────────┴──────────┴───────────────────┴──────────────────────┴────────────────────┘
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
|
|
265
313
|
#### Query all registered tables
|
|
266
314
|
|
|
267
315
|
Query any registered tables using DuckDB SQL dialect!
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
laketower/__about__.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
|
|
2
|
+
laketower/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
laketower/__main__.py,sha256=czKxJKG8OfncnxWmpaOWx7b1JBwFnZNQi7wKSTncB4M,108
|
|
4
|
+
laketower/cli.py,sha256=CNbLntrc3IfhRhkzfDLAYLmYAsKCgAGXZGO79NbJyic,9027
|
|
5
|
+
laketower/config.py,sha256=cJ7KKWd2Pv9T_MbpK7QxqD8PdPZ9I38i3-tofcU0KeI,1049
|
|
6
|
+
laketower/tables.py,sha256=xR9w8vry1riS6nimi2D5ufY_QdAzZ4a_KT3Pa2C0pIY,4247
|
|
7
|
+
laketower/web.py,sha256=csONLkorgXBep8fVBKHh84lBWuPh3TlRuRliT3-dqD4,4910
|
|
8
|
+
laketower/static/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
laketower/templates/_base.html,sha256=QhS6I41Kt69OUZu8L7jUIaZH84hfC3h98JhhMBKZ1RE,2913
|
|
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=RA40_POqhzAFGZBewO3Ihx4n3kLuwSliZyuf_Ff2Q4M,1069
|
|
15
|
+
laketower/templates/tables/view.html,sha256=sFCQlEzIODc-M6VuHIqGI5PnPkGPaYPg21WAzQg_af0,3860
|
|
16
|
+
laketower-0.2.0.dist-info/METADATA,sha256=TJsj-QcO0e2NuwB_cHfyPWd_MWmIFqGxTI9GIDzUrds,15012
|
|
17
|
+
laketower-0.2.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
18
|
+
laketower-0.2.0.dist-info/entry_points.txt,sha256=OL_4klopvyEzasJOFJ-sKu54lv24Jvomni32h1WVUjk,48
|
|
19
|
+
laketower-0.2.0.dist-info/licenses/LICENSE.md,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
|
|
20
|
+
laketower-0.2.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
|