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.
@@ -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
+ )