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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.1.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 enum
5
- from datetime import datetime, timezone
4
+ import os
6
5
  from pathlib import Path
7
- from typing import Any
8
6
 
9
- import deltalake
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 sqlglot
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
- 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
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
- metadata = load_table_metadata(table_config)
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
- schema = load_table_schema(table_config)
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
- history = load_table_history(table_config)
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.values.tolist():
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(config.tables, sql_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("laketower")
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,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,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.1.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<19
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
  [![PyPI](https://img.shields.io/pypi/v/laketower.svg)](https://pypi.org/project/laketower/)
45
+ [![Python Versions](https://img.shields.io/pypi/pyversions/laketower?logo=python&logoColor=white)](https://pypi.org/project/laketower/)
29
46
  [![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)
47
+ [![License](https://img.shields.io/github/license/datalpia/laketower)](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,,
@@ -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,,