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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.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 enum
5
- from datetime import datetime, timezone
2
+ import os
6
3
  from pathlib import Path
7
- from typing import Any
8
4
 
9
- import deltalake
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 sqlglot
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
- class TableFormats(str, enum.Enum):
28
- delta = "delta"
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
- metadata = load_table_metadata(table_config)
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
- schema = load_table_schema(table_config)
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
- history = load_table_history(table_config)
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.values.tolist():
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(config.tables, sql_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("laketower")
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,4 @@
1
+ {% extends "_base.html" %}
2
+
3
+ {% block body %}
4
+ {% endblock %}
@@ -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.1.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.9
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<19
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
  [![PyPI](https://img.shields.io/pypi/v/laketower.svg)](https://pypi.org/project/laketower/)
44
+ [![Python Versions](https://img.shields.io/pypi/pyversions/laketower?logo=python&logoColor=white)](https://pypi.org/project/laketower/)
29
45
  [![CI/CD](https://github.com/datalpia/laketower/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/datalpia/laketower/actions/workflows/ci-cd.yml)
30
- [![License](https://img.shields.io/github/license/datalpia/laketower)](https://github.com/datalpia/laketower/blob/main/LICENSE)
46
+ [![License](https://img.shields.io/github/license/datalpia/laketower)](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,,
@@ -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,,