hotdata-marimo 0.1.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.
- hotdata_marimo/__init__.py +80 -0
- hotdata_marimo/_options.py +107 -0
- hotdata_marimo/display.py +209 -0
- hotdata_marimo/sql_editor.py +201 -0
- hotdata_marimo/sql_engine.py +371 -0
- hotdata_marimo/table_browser.py +221 -0
- hotdata_marimo/workspace_selector.py +93 -0
- hotdata_marimo-0.1.0.dist-info/METADATA +124 -0
- hotdata_marimo-0.1.0.dist-info/RECORD +10 -0
- hotdata_marimo-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Marimo ``mo.sql`` engine integration for :class:`~hotdata_runtime.HotdataClient`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from hotdata_runtime import HotdataClient
|
|
9
|
+
|
|
10
|
+
from marimo import _loggers
|
|
11
|
+
from marimo._data.models import (
|
|
12
|
+
Database,
|
|
13
|
+
DataSourceConnection,
|
|
14
|
+
DataTable,
|
|
15
|
+
DataTableColumn,
|
|
16
|
+
Schema,
|
|
17
|
+
)
|
|
18
|
+
from marimo._sql.engines.types import InferenceConfig, SQLConnection
|
|
19
|
+
from marimo._sql.utils import convert_to_output, sql_type_to_data_type
|
|
20
|
+
from marimo._types.ids import VariableName
|
|
21
|
+
|
|
22
|
+
LOGGER = _loggers.marimo_logger()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _table_schema_name(t: Any) -> str:
|
|
26
|
+
return str(t.var_schema)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HotdataMarimoEngine(SQLConnection[HotdataClient]):
|
|
30
|
+
"""Marimo :class:`~marimo._sql.engines.types.SQLConnection` backed by Hotdata.
|
|
31
|
+
|
|
32
|
+
Catalog methods support Marimo's Data Sources panel. ``execute()`` only runs SQL
|
|
33
|
+
via :meth:`~hotdata_runtime.HotdataClient.execute_sql` (no catalog calls in that path).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
connection: HotdataClient,
|
|
39
|
+
engine_name: VariableName | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
super().__init__(connection, engine_name)
|
|
42
|
+
self._connections_cache: list[Any] | None = None
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def source(self) -> str:
|
|
46
|
+
return "hotdata"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def dialect(self) -> str:
|
|
50
|
+
# Marimo labels engines as ``{dialect} ({variable_name})``; display_name is patched to "Hotdata".
|
|
51
|
+
return "hotdata"
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def is_compatible(var: Any) -> bool:
|
|
55
|
+
return isinstance(var, HotdataClient)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def inference_config(self) -> InferenceConfig:
|
|
59
|
+
return InferenceConfig(
|
|
60
|
+
auto_discover_schemas=True,
|
|
61
|
+
auto_discover_tables="auto",
|
|
62
|
+
auto_discover_columns="auto",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def _resolve_should_auto_discover(
|
|
66
|
+
self,
|
|
67
|
+
value: bool | Literal["auto"],
|
|
68
|
+
) -> bool:
|
|
69
|
+
if value == "auto":
|
|
70
|
+
return True
|
|
71
|
+
return value
|
|
72
|
+
|
|
73
|
+
def _connection_id(self, connection_name: str) -> str | None:
|
|
74
|
+
try:
|
|
75
|
+
return self._connection.connection_id_by_name().get(connection_name)
|
|
76
|
+
except RuntimeError as e:
|
|
77
|
+
LOGGER.warning("%s", e)
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def _connections(self) -> list[Any]:
|
|
81
|
+
if self._connections_cache is None:
|
|
82
|
+
self._connections_cache = list(
|
|
83
|
+
self._connection.connections().list_connections().connections
|
|
84
|
+
)
|
|
85
|
+
return self._connections_cache
|
|
86
|
+
|
|
87
|
+
def _iter_grouped(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
connection_id: str | None,
|
|
91
|
+
include_columns: bool,
|
|
92
|
+
) -> dict[str, dict[str, list[Any]]]:
|
|
93
|
+
grouped: dict[str, dict[str, list[Any]]] = defaultdict(
|
|
94
|
+
lambda: defaultdict(list)
|
|
95
|
+
)
|
|
96
|
+
for t in self._connection.iter_tables(
|
|
97
|
+
connection_id=connection_id,
|
|
98
|
+
include_columns=include_columns,
|
|
99
|
+
):
|
|
100
|
+
grouped[str(t.connection)][_table_schema_name(t)].append(t)
|
|
101
|
+
return grouped
|
|
102
|
+
|
|
103
|
+
def get_default_database(self) -> str | None:
|
|
104
|
+
listing = self._connections()
|
|
105
|
+
if not listing:
|
|
106
|
+
return None
|
|
107
|
+
return str(listing[0].name)
|
|
108
|
+
|
|
109
|
+
def get_default_schema(self) -> str | None:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def get_databases(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
include_schemas: bool | Literal["auto"],
|
|
116
|
+
include_tables: bool | Literal["auto"],
|
|
117
|
+
include_table_details: bool | Literal["auto"],
|
|
118
|
+
) -> list[Database]:
|
|
119
|
+
databases: list[Database] = []
|
|
120
|
+
for c in self._connections():
|
|
121
|
+
name = str(c.name)
|
|
122
|
+
if self._resolve_should_auto_discover(include_schemas):
|
|
123
|
+
schemas = self.get_schemas(
|
|
124
|
+
database=name,
|
|
125
|
+
include_tables=self._resolve_should_auto_discover(
|
|
126
|
+
include_tables
|
|
127
|
+
),
|
|
128
|
+
include_table_details=self._resolve_should_auto_discover(
|
|
129
|
+
include_table_details
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
else:
|
|
133
|
+
schemas = []
|
|
134
|
+
databases.append(
|
|
135
|
+
Database(
|
|
136
|
+
name=name,
|
|
137
|
+
dialect=self.dialect,
|
|
138
|
+
schemas=schemas,
|
|
139
|
+
engine=self._engine_name,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
return databases
|
|
143
|
+
|
|
144
|
+
def get_schemas(
|
|
145
|
+
self,
|
|
146
|
+
*,
|
|
147
|
+
database: str | None,
|
|
148
|
+
include_tables: bool,
|
|
149
|
+
include_table_details: bool,
|
|
150
|
+
) -> list[Schema]:
|
|
151
|
+
if not database:
|
|
152
|
+
return []
|
|
153
|
+
conn_id = self._connection_id(database)
|
|
154
|
+
if conn_id is None:
|
|
155
|
+
LOGGER.warning("Unknown Hotdata connection name %r", database)
|
|
156
|
+
return []
|
|
157
|
+
grouped = self._iter_grouped(
|
|
158
|
+
connection_id=conn_id,
|
|
159
|
+
include_columns=include_table_details,
|
|
160
|
+
)
|
|
161
|
+
inner = grouped.get(database, {})
|
|
162
|
+
schemas: list[Schema] = []
|
|
163
|
+
for schema_name in sorted(inner.keys()):
|
|
164
|
+
tables: list[DataTable] = []
|
|
165
|
+
if include_tables:
|
|
166
|
+
tables = self.get_tables_in_schema(
|
|
167
|
+
schema=schema_name,
|
|
168
|
+
database=database,
|
|
169
|
+
include_table_details=include_table_details,
|
|
170
|
+
)
|
|
171
|
+
if not tables:
|
|
172
|
+
continue
|
|
173
|
+
schemas.append(Schema(name=schema_name, tables=tables))
|
|
174
|
+
return schemas
|
|
175
|
+
|
|
176
|
+
def _data_table_from_table_info(self, t: Any) -> DataTable:
|
|
177
|
+
cols: list[DataTableColumn] = []
|
|
178
|
+
for col in t.columns or []:
|
|
179
|
+
cols.append(
|
|
180
|
+
DataTableColumn(
|
|
181
|
+
name=str(col.name),
|
|
182
|
+
type=sql_type_to_data_type(str(col.data_type)),
|
|
183
|
+
external_type=str(col.data_type),
|
|
184
|
+
sample_values=[],
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
return DataTable(
|
|
188
|
+
source_type="connection",
|
|
189
|
+
source=self.source,
|
|
190
|
+
name=str(t.table),
|
|
191
|
+
num_rows=None,
|
|
192
|
+
num_columns=len(cols) if cols else None,
|
|
193
|
+
variable_name=None,
|
|
194
|
+
engine=self._engine_name,
|
|
195
|
+
type="table",
|
|
196
|
+
columns=cols,
|
|
197
|
+
primary_keys=None,
|
|
198
|
+
indexes=None,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def get_tables_in_schema(
|
|
202
|
+
self,
|
|
203
|
+
*,
|
|
204
|
+
schema: str,
|
|
205
|
+
database: str,
|
|
206
|
+
include_table_details: bool,
|
|
207
|
+
) -> list[DataTable]:
|
|
208
|
+
conn_id = self._connection_id(database)
|
|
209
|
+
if conn_id is None:
|
|
210
|
+
return []
|
|
211
|
+
grouped = self._iter_grouped(
|
|
212
|
+
connection_id=conn_id,
|
|
213
|
+
include_columns=include_table_details,
|
|
214
|
+
)
|
|
215
|
+
tables_info = grouped.get(database, {}).get(schema, [])
|
|
216
|
+
out: list[DataTable] = []
|
|
217
|
+
for t in sorted(tables_info, key=lambda x: str(x.table)):
|
|
218
|
+
if include_table_details:
|
|
219
|
+
if t.columns:
|
|
220
|
+
out.append(self._data_table_from_table_info(t))
|
|
221
|
+
continue
|
|
222
|
+
dt = self.get_table_details(
|
|
223
|
+
table_name=str(t.table),
|
|
224
|
+
schema_name=schema,
|
|
225
|
+
database_name=database,
|
|
226
|
+
)
|
|
227
|
+
if dt is not None:
|
|
228
|
+
out.append(dt)
|
|
229
|
+
else:
|
|
230
|
+
out.append(
|
|
231
|
+
DataTable(
|
|
232
|
+
source_type="connection",
|
|
233
|
+
source=self.source,
|
|
234
|
+
name=str(t.table),
|
|
235
|
+
num_rows=None,
|
|
236
|
+
num_columns=len(t.columns or []) if t.columns else None,
|
|
237
|
+
variable_name=None,
|
|
238
|
+
engine=self._engine_name,
|
|
239
|
+
type="table",
|
|
240
|
+
columns=[],
|
|
241
|
+
primary_keys=None,
|
|
242
|
+
indexes=None,
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
return out
|
|
246
|
+
|
|
247
|
+
def get_table_details(
|
|
248
|
+
self,
|
|
249
|
+
*,
|
|
250
|
+
table_name: str,
|
|
251
|
+
schema_name: str,
|
|
252
|
+
database_name: str,
|
|
253
|
+
) -> DataTable | None:
|
|
254
|
+
conn_id = self._connection_id(database_name)
|
|
255
|
+
if conn_id is None:
|
|
256
|
+
return None
|
|
257
|
+
qualified = f"{database_name}.{schema_name}.{table_name}"
|
|
258
|
+
try:
|
|
259
|
+
cols_raw = self._connection.columns_for_qualified(
|
|
260
|
+
qualified, connection_id=conn_id
|
|
261
|
+
)
|
|
262
|
+
except Exception:
|
|
263
|
+
LOGGER.warning(
|
|
264
|
+
"Failed to load columns for %s",
|
|
265
|
+
qualified,
|
|
266
|
+
exc_info=True,
|
|
267
|
+
)
|
|
268
|
+
return None
|
|
269
|
+
cols: list[DataTableColumn] = []
|
|
270
|
+
for col in cols_raw:
|
|
271
|
+
cols.append(
|
|
272
|
+
DataTableColumn(
|
|
273
|
+
name=str(col.name),
|
|
274
|
+
type=sql_type_to_data_type(str(col.data_type)),
|
|
275
|
+
external_type=str(col.data_type),
|
|
276
|
+
sample_values=[],
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
return DataTable(
|
|
280
|
+
source_type="connection",
|
|
281
|
+
source=self.source,
|
|
282
|
+
name=table_name,
|
|
283
|
+
num_rows=None,
|
|
284
|
+
num_columns=len(cols),
|
|
285
|
+
variable_name=None,
|
|
286
|
+
engine=self._engine_name,
|
|
287
|
+
type="table",
|
|
288
|
+
columns=cols,
|
|
289
|
+
primary_keys=None,
|
|
290
|
+
indexes=None,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def execute(self, query: str) -> Any:
|
|
294
|
+
qr = self._connection.execute_sql(query)
|
|
295
|
+
fmt = self.sql_output_format()
|
|
296
|
+
|
|
297
|
+
def to_polars() -> Any:
|
|
298
|
+
import polars as pl
|
|
299
|
+
|
|
300
|
+
if not qr.columns:
|
|
301
|
+
return pl.DataFrame()
|
|
302
|
+
return pl.DataFrame(qr.rows, schema=qr.columns, orient="row")
|
|
303
|
+
|
|
304
|
+
return convert_to_output(
|
|
305
|
+
sql_output_format=fmt,
|
|
306
|
+
to_polars=to_polars,
|
|
307
|
+
to_pandas=qr.to_pandas,
|
|
308
|
+
to_native=to_polars,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
_HOTDATA_ENGINE_DISPLAY_NAME = "Hotdata"
|
|
313
|
+
_ORIGINAL_ENGINE_TO_CONNECTION = None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _install_hotdata_engine_display_name() -> None:
|
|
317
|
+
"""Show ``Hotdata`` in Marimo's SQL engine / Data Sources UI (not ``sql (client)``)."""
|
|
318
|
+
global _ORIGINAL_ENGINE_TO_CONNECTION
|
|
319
|
+
if _ORIGINAL_ENGINE_TO_CONNECTION is not None:
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
import marimo._sql.get_engines as ge
|
|
323
|
+
|
|
324
|
+
_ORIGINAL_ENGINE_TO_CONNECTION = ge.engine_to_data_source_connection
|
|
325
|
+
|
|
326
|
+
def engine_to_data_source_connection(
|
|
327
|
+
variable_name: VariableName, engine: object
|
|
328
|
+
) -> DataSourceConnection:
|
|
329
|
+
conn = _ORIGINAL_ENGINE_TO_CONNECTION(variable_name, engine) # type: ignore[arg-type]
|
|
330
|
+
if not isinstance(engine, HotdataMarimoEngine):
|
|
331
|
+
return conn
|
|
332
|
+
return DataSourceConnection(
|
|
333
|
+
source=conn.source,
|
|
334
|
+
dialect=conn.dialect,
|
|
335
|
+
name=conn.name,
|
|
336
|
+
display_name=_HOTDATA_ENGINE_DISPLAY_NAME,
|
|
337
|
+
databases=conn.databases,
|
|
338
|
+
default_database=conn.default_database,
|
|
339
|
+
default_schema=conn.default_schema,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
_set_engine_to_data_source_connection(engine_to_data_source_connection)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _set_engine_to_data_source_connection(fn: object) -> None:
|
|
346
|
+
"""Marimo imports this helper in multiple modules; patch all bindings."""
|
|
347
|
+
import marimo._runtime.runner.hooks_post_execution as hpe
|
|
348
|
+
import marimo._runtime.runtime as rt
|
|
349
|
+
import marimo._sql.get_engines as ge
|
|
350
|
+
|
|
351
|
+
ge.engine_to_data_source_connection = fn # type: ignore[assignment]
|
|
352
|
+
hpe.engine_to_data_source_connection = fn # type: ignore[assignment]
|
|
353
|
+
rt.engine_to_data_source_connection = fn # type: ignore[assignment]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def register_hotdata_sql_engine() -> None:
|
|
357
|
+
"""Register :class:`HotdataMarimoEngine` with Marimo's SQL engine registry (idempotent)."""
|
|
358
|
+
_install_hotdata_engine_display_name()
|
|
359
|
+
from marimo._sql.get_engines import SUPPORTED_ENGINES
|
|
360
|
+
|
|
361
|
+
if HotdataMarimoEngine in SUPPORTED_ENGINES:
|
|
362
|
+
return
|
|
363
|
+
SUPPORTED_ENGINES.insert(0, HotdataMarimoEngine)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def unregister_hotdata_sql_engine() -> None:
|
|
367
|
+
"""Remove :class:`HotdataMarimoEngine` from Marimo's registry (mostly for tests)."""
|
|
368
|
+
from marimo._sql.get_engines import SUPPORTED_ENGINES
|
|
369
|
+
|
|
370
|
+
while HotdataMarimoEngine in SUPPORTED_ENGINES:
|
|
371
|
+
SUPPORTED_ENGINES.remove(HotdataMarimoEngine)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import marimo as mo
|
|
6
|
+
|
|
7
|
+
from hotdata_runtime import HotdataClient
|
|
8
|
+
|
|
9
|
+
from hotdata_marimo._options import (
|
|
10
|
+
connection_picker,
|
|
11
|
+
empty_dropdown,
|
|
12
|
+
resolve_connection_picker,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TableBrowser:
|
|
17
|
+
"""Pick a fully qualified `connection.schema.table` and inspect columns.
|
|
18
|
+
|
|
19
|
+
Marimo does not allow reading ``.value`` on UI elements in the same cell that
|
|
20
|
+
constructs them. Build ``TableBrowser`` in one cell and use ``.ui`` in another.
|
|
21
|
+
|
|
22
|
+
The table dropdown is not recreated on every render (that would make it
|
|
23
|
+
"born" in the layout cell). It is only rebuilt when the active connection
|
|
24
|
+
changes: after a rebuild, ``.value`` is not read until a later run.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
client: HotdataClient,
|
|
30
|
+
*,
|
|
31
|
+
table_limit: int = 5000,
|
|
32
|
+
connection_id: str | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self._client = client
|
|
35
|
+
self._table_limit = table_limit
|
|
36
|
+
self._override_connection_id = connection_id
|
|
37
|
+
self._conn_pick: Any | None = None
|
|
38
|
+
self._implicit_connection_id: str | None = None
|
|
39
|
+
|
|
40
|
+
if self._override_connection_id is None:
|
|
41
|
+
self._conn_pick, self._implicit_connection_id = resolve_connection_picker(
|
|
42
|
+
client
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
self._table_pick_ctx: str | None = None
|
|
46
|
+
self._rebuilt_table_pick_this_run = False
|
|
47
|
+
self._init_table_pick()
|
|
48
|
+
|
|
49
|
+
def _init_table_pick(self) -> None:
|
|
50
|
+
if self._conn_pick is not None:
|
|
51
|
+
self.table_pick = empty_dropdown(
|
|
52
|
+
label="Table",
|
|
53
|
+
message="(select connection above)",
|
|
54
|
+
)
|
|
55
|
+
self._empty_catalog = True
|
|
56
|
+
self._all_names = []
|
|
57
|
+
self._table_pick_ctx = ""
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
names = self._names_for_active_connection()
|
|
61
|
+
self._all_names = names
|
|
62
|
+
if not names:
|
|
63
|
+
self.table_pick = empty_dropdown(
|
|
64
|
+
label="Table",
|
|
65
|
+
message="(no tables in catalog)",
|
|
66
|
+
)
|
|
67
|
+
self._empty_catalog = True
|
|
68
|
+
else:
|
|
69
|
+
self._empty_catalog = False
|
|
70
|
+
self.table_pick = mo.ui.dropdown(
|
|
71
|
+
options={n: n for n in names},
|
|
72
|
+
label="Table",
|
|
73
|
+
full_width=True,
|
|
74
|
+
searchable=True,
|
|
75
|
+
)
|
|
76
|
+
self._table_pick_ctx = self._active_connection_id()
|
|
77
|
+
|
|
78
|
+
def _active_connection_id(self) -> str | None:
|
|
79
|
+
if self._override_connection_id is not None:
|
|
80
|
+
return self._override_connection_id or None
|
|
81
|
+
if self._conn_pick is not None:
|
|
82
|
+
v = self._conn_pick.value # type: ignore[attr-defined]
|
|
83
|
+
return v if v else None
|
|
84
|
+
if self._implicit_connection_id is None:
|
|
85
|
+
return None
|
|
86
|
+
return self._implicit_connection_id or None
|
|
87
|
+
|
|
88
|
+
def _names_for_active_connection(self) -> list[str]:
|
|
89
|
+
cid = self._active_connection_id()
|
|
90
|
+
if cid is None or cid == "":
|
|
91
|
+
return []
|
|
92
|
+
return self._client.list_qualified_table_names(
|
|
93
|
+
limit=self._table_limit,
|
|
94
|
+
connection_id=cid,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def _rebuild_table_pick(self, names: list[str]) -> None:
|
|
98
|
+
self._all_names = names
|
|
99
|
+
if not names:
|
|
100
|
+
self._empty_catalog = True
|
|
101
|
+
self.table_pick = empty_dropdown(
|
|
102
|
+
label="Table",
|
|
103
|
+
message="(no tables in catalog)",
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
self._empty_catalog = False
|
|
107
|
+
self.table_pick = mo.ui.dropdown(
|
|
108
|
+
options={n: n for n in names},
|
|
109
|
+
label="Table",
|
|
110
|
+
full_width=True,
|
|
111
|
+
searchable=True,
|
|
112
|
+
)
|
|
113
|
+
self._table_pick_ctx = self._active_connection_id()
|
|
114
|
+
self._rebuilt_table_pick_this_run = True
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def selected_connection_id(self) -> str | None:
|
|
118
|
+
return self._active_connection_id()
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def selected_table(self) -> str | None:
|
|
122
|
+
v = self.table_pick.value
|
|
123
|
+
return v if v else None
|
|
124
|
+
|
|
125
|
+
def _sync_table_catalog(self) -> None:
|
|
126
|
+
"""Refresh the table dropdown when the active connection changes."""
|
|
127
|
+
if self._conn_pick is not None:
|
|
128
|
+
_ = self._conn_pick.value # type: ignore[attr-defined]
|
|
129
|
+
cid = self._active_connection_id()
|
|
130
|
+
if not cid:
|
|
131
|
+
return
|
|
132
|
+
if cid == self._table_pick_ctx:
|
|
133
|
+
return
|
|
134
|
+
self._rebuild_table_pick(self._names_for_active_connection())
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def ui(self):
|
|
138
|
+
self._rebuilt_table_pick_this_run = False
|
|
139
|
+
self._sync_table_catalog()
|
|
140
|
+
|
|
141
|
+
if not self._rebuilt_table_pick_this_run:
|
|
142
|
+
_ = self.table_pick.value
|
|
143
|
+
|
|
144
|
+
sel = None if self._rebuilt_table_pick_this_run else self.selected_table
|
|
145
|
+
cid = self._active_connection_id()
|
|
146
|
+
conn_header = (
|
|
147
|
+
mo.md(f"**Connection** `{self._active_connection_id()}`")
|
|
148
|
+
if self._active_connection_id()
|
|
149
|
+
else None
|
|
150
|
+
)
|
|
151
|
+
if not sel:
|
|
152
|
+
if self._conn_pick is not None and not cid:
|
|
153
|
+
hint = "Choose a connection above to load tables."
|
|
154
|
+
elif self._empty_catalog:
|
|
155
|
+
hint = (
|
|
156
|
+
"_No tables returned from the information schema. "
|
|
157
|
+
"Try refreshing a connection in Hotdata._"
|
|
158
|
+
)
|
|
159
|
+
else:
|
|
160
|
+
hint = "Choose a table below (search inside the dropdown when needed)."
|
|
161
|
+
stack = [
|
|
162
|
+
mo.md(
|
|
163
|
+
f"**Workspace** `{self._client.workspace_id}` — {hint}"
|
|
164
|
+
),
|
|
165
|
+
]
|
|
166
|
+
if conn_header is not None:
|
|
167
|
+
stack.append(conn_header)
|
|
168
|
+
if self._conn_pick is not None:
|
|
169
|
+
stack.append(self._conn_pick)
|
|
170
|
+
stack.append(self.table_pick)
|
|
171
|
+
return mo.vstack(stack, gap=1)
|
|
172
|
+
|
|
173
|
+
cols = self._client.columns_for_qualified(
|
|
174
|
+
sel,
|
|
175
|
+
connection_id=self.selected_connection_id,
|
|
176
|
+
)
|
|
177
|
+
if not cols:
|
|
178
|
+
body = mo.md("_No column metadata returned (check catalog sync)._")
|
|
179
|
+
else:
|
|
180
|
+
lines = [
|
|
181
|
+
f"| `{c.name}` | {c.data_type} | "
|
|
182
|
+
f"{'NULL' if c.nullable else 'NOT NULL'} |"
|
|
183
|
+
for c in cols
|
|
184
|
+
]
|
|
185
|
+
table = (
|
|
186
|
+
"| column | type | null |\n| --- | --- | --- |\n"
|
|
187
|
+
+ "\n".join(lines)
|
|
188
|
+
)
|
|
189
|
+
body = mo.md(table)
|
|
190
|
+
starter = f"```sql\nSELECT *\nFROM {sel}\nLIMIT 100\n```"
|
|
191
|
+
stack2 = [
|
|
192
|
+
mo.md(
|
|
193
|
+
f"**Workspace** `{self._client.workspace_id}` — "
|
|
194
|
+
f"**selected** `{sel}`"
|
|
195
|
+
),
|
|
196
|
+
]
|
|
197
|
+
if conn_header is not None:
|
|
198
|
+
stack2.append(conn_header)
|
|
199
|
+
if self._conn_pick is not None:
|
|
200
|
+
stack2.append(self._conn_pick)
|
|
201
|
+
stack2.extend(
|
|
202
|
+
[
|
|
203
|
+
self.table_pick,
|
|
204
|
+
mo.md("### Columns"),
|
|
205
|
+
body,
|
|
206
|
+
mo.md("### Starter query"),
|
|
207
|
+
mo.md(starter),
|
|
208
|
+
]
|
|
209
|
+
)
|
|
210
|
+
return mo.vstack(stack2, gap=1)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def table_browser(
|
|
214
|
+
client: HotdataClient,
|
|
215
|
+
*,
|
|
216
|
+
table_limit: int = 5000,
|
|
217
|
+
connection_id: str | None = None,
|
|
218
|
+
) -> TableBrowser:
|
|
219
|
+
return TableBrowser(
|
|
220
|
+
client, table_limit=table_limit, connection_id=connection_id
|
|
221
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import marimo as mo
|
|
4
|
+
from hotdata_runtime import (
|
|
5
|
+
HotdataClient,
|
|
6
|
+
default_api_key,
|
|
7
|
+
default_host,
|
|
8
|
+
default_session_id,
|
|
9
|
+
resolve_workspace_selection,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from hotdata_marimo._options import unique_label_options
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WorkspaceSelector:
|
|
16
|
+
"""Workspace picker that rebuilds `HotdataClient` as selection changes."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
*,
|
|
21
|
+
api_key: str,
|
|
22
|
+
host: str | None = None,
|
|
23
|
+
session_id: str | None = None,
|
|
24
|
+
label: str = "Workspace",
|
|
25
|
+
) -> None:
|
|
26
|
+
self._api_key = api_key
|
|
27
|
+
self._host = host or default_host()
|
|
28
|
+
self._session_id = session_id
|
|
29
|
+
selection = resolve_workspace_selection(api_key, self._host, session_id)
|
|
30
|
+
self._explicit = selection.source == "explicit_env"
|
|
31
|
+
if self._explicit:
|
|
32
|
+
self._pick = None
|
|
33
|
+
self._workspace_id = selection.workspace_id
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
workspaces = selection.workspaces
|
|
37
|
+
if len(workspaces) == 1:
|
|
38
|
+
self._pick = None
|
|
39
|
+
self._workspace_id = workspaces[0].public_id
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
pairs = [(w.name, w.public_id) for w in workspaces]
|
|
43
|
+
options = unique_label_options(
|
|
44
|
+
pairs,
|
|
45
|
+
disambiguate=lambda name, public_id, count: f"{name} ({public_id})",
|
|
46
|
+
)
|
|
47
|
+
items = sorted(
|
|
48
|
+
options.items(),
|
|
49
|
+
key=lambda item: 0 if item[1] == selection.workspace_id else 1,
|
|
50
|
+
)
|
|
51
|
+
self._pick = mo.ui.dropdown(
|
|
52
|
+
options=dict(items),
|
|
53
|
+
label=label,
|
|
54
|
+
full_width=True,
|
|
55
|
+
)
|
|
56
|
+
self._workspace_id = selection.workspace_id
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def workspace_id(self) -> str:
|
|
60
|
+
if self._pick is None:
|
|
61
|
+
return self._workspace_id
|
|
62
|
+
v = self._pick.value
|
|
63
|
+
return v if v else self._workspace_id
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def client(self) -> HotdataClient:
|
|
67
|
+
return HotdataClient(
|
|
68
|
+
self._api_key,
|
|
69
|
+
self.workspace_id,
|
|
70
|
+
host=self._host,
|
|
71
|
+
session_id=self._session_id,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def ui(self):
|
|
76
|
+
if self._pick is None:
|
|
77
|
+
return mo.md(f"**Workspace** `{self.workspace_id}`")
|
|
78
|
+
_ = self._pick.value
|
|
79
|
+
return mo.vstack([self._pick], gap=1)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def workspace_selector_from_env(*, label: str = "Workspace") -> WorkspaceSelector:
|
|
83
|
+
api_key = default_api_key()
|
|
84
|
+
if not api_key:
|
|
85
|
+
raise RuntimeError("HOTDATA_API_KEY must be set.")
|
|
86
|
+
host = default_host()
|
|
87
|
+
session = default_session_id()
|
|
88
|
+
return WorkspaceSelector(
|
|
89
|
+
api_key=api_key,
|
|
90
|
+
host=host,
|
|
91
|
+
session_id=session,
|
|
92
|
+
label=label,
|
|
93
|
+
)
|