hotdata-jupyter 0.2.1__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_jupyter/__init__.py +62 -0
- hotdata_jupyter/databases.py +237 -0
- hotdata_jupyter/display.py +35 -0
- hotdata_jupyter/env.py +40 -0
- hotdata_jupyter/magics.py +38 -0
- hotdata_jupyter/metadata.py +13 -0
- hotdata_jupyter/workspace.py +99 -0
- hotdata_jupyter-0.2.1.dist-info/METADATA +113 -0
- hotdata_jupyter-0.2.1.dist-info/RECORD +10 -0
- hotdata_jupyter-0.2.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Jupyter integration package for Hotdata runtime."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("hotdata-jupyter")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
__version__ = "0.0.0+unknown"
|
|
9
|
+
|
|
10
|
+
from hotdata_runtime import (
|
|
11
|
+
DEFAULT_SCHEMA,
|
|
12
|
+
HotdataClient,
|
|
13
|
+
LoadManagedTableResult,
|
|
14
|
+
ManagedDatabase,
|
|
15
|
+
QueryResult,
|
|
16
|
+
WorkspaceSelection,
|
|
17
|
+
from_env,
|
|
18
|
+
resolve_workspace_selection,
|
|
19
|
+
workspace_health_lines,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
from hotdata_jupyter.databases import (
|
|
23
|
+
ManagedDatabaseWriter,
|
|
24
|
+
create_managed_database,
|
|
25
|
+
display_managed_databases_panel,
|
|
26
|
+
load_managed_table_from_bytes,
|
|
27
|
+
managed_database_writer,
|
|
28
|
+
managed_databases_markdown,
|
|
29
|
+
)
|
|
30
|
+
from hotdata_jupyter.display import display_query_result, result_markdown
|
|
31
|
+
from hotdata_jupyter.env import load_default_env_files, load_env_file
|
|
32
|
+
from hotdata_jupyter.magics import HotdataMagics, load_ipython_extension
|
|
33
|
+
from hotdata_jupyter.metadata import notebook_metadata
|
|
34
|
+
from hotdata_jupyter.workspace import WorkspaceSelector, workspace_selector_from_env
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"DEFAULT_SCHEMA",
|
|
38
|
+
"HotdataClient",
|
|
39
|
+
"HotdataMagics",
|
|
40
|
+
"LoadManagedTableResult",
|
|
41
|
+
"ManagedDatabase",
|
|
42
|
+
"ManagedDatabaseWriter",
|
|
43
|
+
"QueryResult",
|
|
44
|
+
"WorkspaceSelection",
|
|
45
|
+
"WorkspaceSelector",
|
|
46
|
+
"__version__",
|
|
47
|
+
"create_managed_database",
|
|
48
|
+
"display_managed_databases_panel",
|
|
49
|
+
"display_query_result",
|
|
50
|
+
"from_env",
|
|
51
|
+
"load_default_env_files",
|
|
52
|
+
"load_env_file",
|
|
53
|
+
"load_ipython_extension",
|
|
54
|
+
"load_managed_table_from_bytes",
|
|
55
|
+
"managed_database_writer",
|
|
56
|
+
"managed_databases_markdown",
|
|
57
|
+
"notebook_metadata",
|
|
58
|
+
"resolve_workspace_selection",
|
|
59
|
+
"result_markdown",
|
|
60
|
+
"workspace_health_lines",
|
|
61
|
+
"workspace_selector_from_env",
|
|
62
|
+
]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Jupyter UI for managed Hotdata databases (create + parquet table loads)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
|
|
8
|
+
import ipywidgets as widgets
|
|
9
|
+
from hotdata_runtime import (
|
|
10
|
+
DEFAULT_SCHEMA,
|
|
11
|
+
HotdataClient,
|
|
12
|
+
HotdataError,
|
|
13
|
+
HotdataTransientError,
|
|
14
|
+
LoadManagedTableResult,
|
|
15
|
+
ManagedDatabase,
|
|
16
|
+
classify_sdk_error,
|
|
17
|
+
)
|
|
18
|
+
from IPython.display import Markdown, display
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _parse_table_names(text: str) -> list[str]:
|
|
22
|
+
return [line.strip() for line in text.splitlines() if line.strip()]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _format_runtime_error(error: HotdataError) -> str:
|
|
26
|
+
"""Render a typed runtime/SDK error as a readable widget status message.
|
|
27
|
+
|
|
28
|
+
Transient (retryable) failures get a hint to try again so notebook users
|
|
29
|
+
are not left guessing whether the operation can simply be re-run.
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(error, HotdataTransientError):
|
|
32
|
+
return f"{error} (temporary - try again)"
|
|
33
|
+
return str(error)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _upload_parquet_bytes(client: HotdataClient, contents: bytes) -> str:
|
|
37
|
+
with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as tmp:
|
|
38
|
+
tmp.write(contents)
|
|
39
|
+
path = tmp.name
|
|
40
|
+
try:
|
|
41
|
+
return client.upload_parquet(path)
|
|
42
|
+
finally:
|
|
43
|
+
os.unlink(path)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def managed_databases_markdown(client: HotdataClient) -> str:
|
|
47
|
+
dbs = client.list_managed_databases()
|
|
48
|
+
if not dbs:
|
|
49
|
+
return (
|
|
50
|
+
"### Managed databases\n\n"
|
|
51
|
+
"_No managed databases yet._ Create one below, or with the CLI: "
|
|
52
|
+
"`hotdata databases create --name <name> --table <table>`."
|
|
53
|
+
)
|
|
54
|
+
lines = [
|
|
55
|
+
"### Managed databases",
|
|
56
|
+
"",
|
|
57
|
+
"| description | id | sql_prefix |",
|
|
58
|
+
"| --- | --- | --- |",
|
|
59
|
+
]
|
|
60
|
+
for db in dbs:
|
|
61
|
+
prefix = f"{db.id}.{{schema}}.{{table}}"
|
|
62
|
+
lines.append(f"| {db.description or db.id} | `{db.id}` | `{prefix}` |")
|
|
63
|
+
lines.append("")
|
|
64
|
+
lines.append("_Query as `database.schema.table` in SQL._")
|
|
65
|
+
return "\n".join(lines)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def display_managed_databases_panel(client: HotdataClient) -> None:
|
|
69
|
+
display(Markdown(managed_databases_markdown(client)))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create_managed_database(
|
|
73
|
+
client: HotdataClient,
|
|
74
|
+
*,
|
|
75
|
+
name: str,
|
|
76
|
+
schema: str = DEFAULT_SCHEMA,
|
|
77
|
+
tables: list[str] | None = None,
|
|
78
|
+
) -> ManagedDatabase:
|
|
79
|
+
return client.create_managed_database(description=name, schema=schema, tables=tables)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def load_managed_table_from_bytes(
|
|
83
|
+
client: HotdataClient,
|
|
84
|
+
database: str,
|
|
85
|
+
table: str,
|
|
86
|
+
contents: bytes,
|
|
87
|
+
*,
|
|
88
|
+
schema: str = DEFAULT_SCHEMA,
|
|
89
|
+
) -> LoadManagedTableResult:
|
|
90
|
+
upload_id = _upload_parquet_bytes(client, contents)
|
|
91
|
+
return client.load_managed_table(
|
|
92
|
+
database,
|
|
93
|
+
table,
|
|
94
|
+
schema=schema,
|
|
95
|
+
upload_id=upload_id,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ManagedDatabaseWriter:
|
|
100
|
+
"""Create managed databases and load parquet files from a Jupyter notebook."""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
client: HotdataClient,
|
|
105
|
+
*,
|
|
106
|
+
default_schema: str = DEFAULT_SCHEMA,
|
|
107
|
+
) -> None:
|
|
108
|
+
self._client = client
|
|
109
|
+
self._default_schema = default_schema
|
|
110
|
+
self._status = widgets.Output()
|
|
111
|
+
|
|
112
|
+
self.name = widgets.Text(description="Database name", layout=widgets.Layout(width="100%"))
|
|
113
|
+
self.schema = widgets.Text(
|
|
114
|
+
value=default_schema,
|
|
115
|
+
description="Schema",
|
|
116
|
+
layout=widgets.Layout(width="100%"),
|
|
117
|
+
)
|
|
118
|
+
self.tables = widgets.Textarea(
|
|
119
|
+
description="Tables",
|
|
120
|
+
placeholder="One table name per line",
|
|
121
|
+
layout=widgets.Layout(width="100%", height="80px"),
|
|
122
|
+
)
|
|
123
|
+
self.create = widgets.Button(description="Create database", button_style="success")
|
|
124
|
+
|
|
125
|
+
self._rebuild_database_pick()
|
|
126
|
+
self.table = widgets.Text(description="Table name", layout=widgets.Layout(width="100%"))
|
|
127
|
+
self.file = widgets.FileUpload(accept=".parquet", description="Parquet file")
|
|
128
|
+
self.load = widgets.Button(description="Load table", button_style="success")
|
|
129
|
+
|
|
130
|
+
self.create.on_click(self._on_create)
|
|
131
|
+
self.load.on_click(self._on_load)
|
|
132
|
+
|
|
133
|
+
def _rebuild_database_pick(self) -> None:
|
|
134
|
+
current = getattr(getattr(self, "database", None), "value", None)
|
|
135
|
+
dbs = self._client.list_managed_databases()
|
|
136
|
+
if not dbs:
|
|
137
|
+
self.database = widgets.Dropdown(
|
|
138
|
+
options=[("(create one first)", "")],
|
|
139
|
+
value="",
|
|
140
|
+
description="Database",
|
|
141
|
+
layout=widgets.Layout(width="100%"),
|
|
142
|
+
)
|
|
143
|
+
return
|
|
144
|
+
options = [(db.description or db.id, db.id) for db in dbs]
|
|
145
|
+
value = current if current in {val for _, val in options} else options[0][1]
|
|
146
|
+
self.database = widgets.Dropdown(
|
|
147
|
+
options=options,
|
|
148
|
+
value=value,
|
|
149
|
+
description="Database",
|
|
150
|
+
layout=widgets.Layout(width="100%"),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _on_create(self, _button: widgets.Button) -> None:
|
|
154
|
+
db_name = self.name.value.strip()
|
|
155
|
+
if not db_name:
|
|
156
|
+
self._write_status("Enter a database name.", error=True)
|
|
157
|
+
return
|
|
158
|
+
schema = self.schema.value.strip() or self._default_schema
|
|
159
|
+
tables = _parse_table_names(self.tables.value)
|
|
160
|
+
try:
|
|
161
|
+
db = self._client.create_managed_database(
|
|
162
|
+
description=db_name,
|
|
163
|
+
schema=schema,
|
|
164
|
+
tables=tables or None,
|
|
165
|
+
)
|
|
166
|
+
self._rebuild_database_pick()
|
|
167
|
+
self._write_status(
|
|
168
|
+
f"Created {db.description or db.id} ({db.id}). "
|
|
169
|
+
"Load parquet into a declared table below."
|
|
170
|
+
)
|
|
171
|
+
except HotdataError as e:
|
|
172
|
+
self._write_status(_format_runtime_error(e), error=True)
|
|
173
|
+
except (ValueError, KeyError) as e:
|
|
174
|
+
self._write_status(_format_runtime_error(classify_sdk_error(e)), error=True)
|
|
175
|
+
|
|
176
|
+
def _on_load(self, _button: widgets.Button) -> None:
|
|
177
|
+
database = self.database.value
|
|
178
|
+
table = self.table.value.strip()
|
|
179
|
+
if not database:
|
|
180
|
+
self._write_status("Choose or create a database first.", error=True)
|
|
181
|
+
return
|
|
182
|
+
if not table:
|
|
183
|
+
self._write_status("Enter a table name.", error=True)
|
|
184
|
+
return
|
|
185
|
+
if not self.file.value:
|
|
186
|
+
self._write_status("Choose a parquet file to upload.", error=True)
|
|
187
|
+
return
|
|
188
|
+
schema = self.schema.value.strip() or self._default_schema
|
|
189
|
+
try:
|
|
190
|
+
contents = self.file.value[0]["content"]
|
|
191
|
+
upload_id = _upload_parquet_bytes(self._client, contents)
|
|
192
|
+
loaded = self._client.load_managed_table(
|
|
193
|
+
database,
|
|
194
|
+
table,
|
|
195
|
+
schema=schema,
|
|
196
|
+
upload_id=upload_id,
|
|
197
|
+
)
|
|
198
|
+
self._write_status(f"Loaded {loaded.full_name} · {loaded.row_count} rows.")
|
|
199
|
+
except HotdataError as e:
|
|
200
|
+
self._write_status(_format_runtime_error(e), error=True)
|
|
201
|
+
except (ValueError, KeyError, OSError) as e:
|
|
202
|
+
self._write_status(_format_runtime_error(classify_sdk_error(e)), error=True)
|
|
203
|
+
|
|
204
|
+
def _write_status(self, message: str, *, error: bool = False) -> None:
|
|
205
|
+
with self._status:
|
|
206
|
+
self._status.clear_output(wait=True)
|
|
207
|
+
display(Markdown(f"**Error:** {message}" if error else message))
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def ui(self):
|
|
211
|
+
return widgets.VBox(
|
|
212
|
+
[
|
|
213
|
+
widgets.HTML("<h4>Create database</h4>"),
|
|
214
|
+
self.name,
|
|
215
|
+
self.schema,
|
|
216
|
+
self.tables,
|
|
217
|
+
self.create,
|
|
218
|
+
widgets.HTML("<h4>Load parquet table</h4>"),
|
|
219
|
+
self.database,
|
|
220
|
+
self.table,
|
|
221
|
+
self.file,
|
|
222
|
+
self.load,
|
|
223
|
+
self._status,
|
|
224
|
+
]
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def display(self) -> None:
|
|
228
|
+
display_managed_databases_panel(self._client)
|
|
229
|
+
display(self.ui)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def managed_database_writer(
|
|
233
|
+
client: HotdataClient,
|
|
234
|
+
*,
|
|
235
|
+
default_schema: str = DEFAULT_SCHEMA,
|
|
236
|
+
) -> ManagedDatabaseWriter:
|
|
237
|
+
return ManagedDatabaseWriter(client, default_schema=default_schema)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Display adapters for Hotdata query results in notebooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from hotdata_runtime import QueryResult
|
|
6
|
+
from IPython.display import Markdown, display
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def result_markdown(result: QueryResult) -> str:
|
|
10
|
+
meta = result.metadata_dict()
|
|
11
|
+
parts: list[str] = [
|
|
12
|
+
f"**rows** `{meta['row_count']}`",
|
|
13
|
+
f"**columns** `{meta['column_count']}`",
|
|
14
|
+
]
|
|
15
|
+
if meta["result_id"]:
|
|
16
|
+
parts.append(f"**result_id** `{meta['result_id']}`")
|
|
17
|
+
if meta["query_run_id"]:
|
|
18
|
+
parts.append(f"**query_run_id** `{meta['query_run_id']}`")
|
|
19
|
+
if meta["execution_time_ms"] is not None:
|
|
20
|
+
parts.append(f"**execution_time_ms** `{meta['execution_time_ms']}`")
|
|
21
|
+
if meta["warning"]:
|
|
22
|
+
parts.append(f"**warning** `{meta['warning']}`")
|
|
23
|
+
if meta["error_message"]:
|
|
24
|
+
parts.append(f"**error** `{meta['error_message']}`")
|
|
25
|
+
return " · ".join(parts)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def display_query_result(
|
|
29
|
+
result: QueryResult,
|
|
30
|
+
*,
|
|
31
|
+
title: str = "Hotdata result",
|
|
32
|
+
):
|
|
33
|
+
display(Markdown(f"### {title}"))
|
|
34
|
+
display(Markdown(result_markdown(result)))
|
|
35
|
+
return result.to_pandas()
|
hotdata_jupyter/env.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Load Hotdata credentials into the current process (e.g. Jupyter kernel)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_env_file(path: str | Path, *, override: bool = False) -> bool:
|
|
10
|
+
"""Parse a shell-style ``KEY=VALUE`` file into ``os.environ``.
|
|
11
|
+
|
|
12
|
+
Returns True if the file existed and was read.
|
|
13
|
+
"""
|
|
14
|
+
p = Path(path).expanduser()
|
|
15
|
+
if not p.is_file():
|
|
16
|
+
return False
|
|
17
|
+
for raw in p.read_text(encoding="utf-8").splitlines():
|
|
18
|
+
line = raw.strip()
|
|
19
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
20
|
+
continue
|
|
21
|
+
key, _, value = line.partition("=")
|
|
22
|
+
key = key.strip()
|
|
23
|
+
value = value.strip().strip("'\"")
|
|
24
|
+
if not key:
|
|
25
|
+
continue
|
|
26
|
+
if override or key not in os.environ:
|
|
27
|
+
os.environ[key] = value
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_default_env_files() -> list[str]:
|
|
32
|
+
"""Load ``~/.env`` then ``.env`` in the current working directory."""
|
|
33
|
+
loaded: list[str] = []
|
|
34
|
+
home = Path.home() / ".env"
|
|
35
|
+
if load_env_file(home):
|
|
36
|
+
loaded.append(str(home))
|
|
37
|
+
cwd = Path.cwd() / ".env"
|
|
38
|
+
if cwd != home and load_env_file(cwd):
|
|
39
|
+
loaded.append(str(cwd))
|
|
40
|
+
return loaded
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""IPython magics for executing Hotdata SQL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from hotdata_runtime import HotdataClient
|
|
6
|
+
from IPython.core.magic import Magics, line_cell_magic, magics_class
|
|
7
|
+
|
|
8
|
+
from hotdata_jupyter.display import display_query_result
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@magics_class
|
|
12
|
+
class HotdataMagics(Magics):
|
|
13
|
+
def __init__(self, shell, client: HotdataClient | None = None):
|
|
14
|
+
super().__init__(shell)
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
def _runtime_client(self) -> HotdataClient:
|
|
18
|
+
if self._client is None:
|
|
19
|
+
self._client = HotdataClient.from_env()
|
|
20
|
+
return self._client
|
|
21
|
+
|
|
22
|
+
@line_cell_magic
|
|
23
|
+
def hotdata(self, line: str, cell: str | None = None):
|
|
24
|
+
if cell is not None:
|
|
25
|
+
# Cell magic: %%hotdata [database]
|
|
26
|
+
# Optional database name on the first line scopes the query.
|
|
27
|
+
sql = cell
|
|
28
|
+
database = line.strip() or None
|
|
29
|
+
else:
|
|
30
|
+
# Line magic: %hotdata SELECT ...
|
|
31
|
+
sql = line
|
|
32
|
+
database = None
|
|
33
|
+
result = self._runtime_client().execute_sql(sql, database=database)
|
|
34
|
+
return display_query_result(result)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_ipython_extension(ipython) -> None:
|
|
38
|
+
ipython.register_magics(HotdataMagics)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Notebook metadata helpers for Hotdata sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from hotdata_runtime import HotdataClient
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def notebook_metadata(client: HotdataClient) -> dict[str, str | None]:
|
|
9
|
+
return {
|
|
10
|
+
"workspace_id": client.workspace_id,
|
|
11
|
+
"host": client.host,
|
|
12
|
+
"session_id": client.session_id,
|
|
13
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Jupyter workspace selection (aligned with hotdata-marimo)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ipywidgets as widgets
|
|
6
|
+
from hotdata_runtime import (
|
|
7
|
+
HotdataClient,
|
|
8
|
+
default_api_key,
|
|
9
|
+
default_host,
|
|
10
|
+
default_session_id,
|
|
11
|
+
resolve_workspace_selection,
|
|
12
|
+
)
|
|
13
|
+
from IPython.display import Markdown
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WorkspaceSelector:
|
|
17
|
+
"""Workspace picker that rebuilds ``HotdataClient`` when the selection changes."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
api_key: str,
|
|
23
|
+
host: str | None = None,
|
|
24
|
+
session_id: str | None = None,
|
|
25
|
+
label: str = "Workspace",
|
|
26
|
+
) -> None:
|
|
27
|
+
self._api_key = api_key
|
|
28
|
+
self._host = host or default_host()
|
|
29
|
+
self._session_id = session_id
|
|
30
|
+
self._client_cache: HotdataClient | None = None
|
|
31
|
+
self._client_cache_wid: str | None = None
|
|
32
|
+
selection = resolve_workspace_selection(api_key, self._host, session_id)
|
|
33
|
+
self._explicit = selection.source == "explicit_env"
|
|
34
|
+
if self._explicit:
|
|
35
|
+
self._pick = None
|
|
36
|
+
self._workspace_id = selection.workspace_id
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
workspaces = selection.workspaces
|
|
40
|
+
if len(workspaces) == 1:
|
|
41
|
+
self._pick = None
|
|
42
|
+
self._workspace_id = workspaces[0].public_id
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
labels: list[tuple[str, str]] = []
|
|
46
|
+
seen: set[str] = set()
|
|
47
|
+
for w in workspaces:
|
|
48
|
+
base = w.name
|
|
49
|
+
label_text = base if base not in seen else f"{base} ({w.public_id})"
|
|
50
|
+
seen.add(base)
|
|
51
|
+
labels.append((label_text, w.public_id))
|
|
52
|
+
|
|
53
|
+
labels.sort(key=lambda t: 0 if t[1] == selection.workspace_id else 1)
|
|
54
|
+
self._pick = widgets.Dropdown(
|
|
55
|
+
options=labels,
|
|
56
|
+
value=selection.workspace_id,
|
|
57
|
+
description=label,
|
|
58
|
+
layout=widgets.Layout(width="100%"),
|
|
59
|
+
)
|
|
60
|
+
self._workspace_id = selection.workspace_id
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def workspace_id(self) -> str:
|
|
64
|
+
if self._pick is None:
|
|
65
|
+
return self._workspace_id
|
|
66
|
+
value = self._pick.value
|
|
67
|
+
return value if value else self._workspace_id
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def client(self) -> HotdataClient:
|
|
71
|
+
wid = self.workspace_id
|
|
72
|
+
if self._client_cache is None or self._client_cache_wid != wid:
|
|
73
|
+
self._client_cache = HotdataClient(
|
|
74
|
+
self._api_key,
|
|
75
|
+
wid,
|
|
76
|
+
host=self._host,
|
|
77
|
+
session_id=self._session_id,
|
|
78
|
+
)
|
|
79
|
+
self._client_cache_wid = wid
|
|
80
|
+
return self._client_cache
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def ui(self):
|
|
84
|
+
if self._pick is None:
|
|
85
|
+
return Markdown(f"**Workspace** `{self.workspace_id}`")
|
|
86
|
+
_ = self._pick.value
|
|
87
|
+
return self._pick
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def workspace_selector_from_env(*, label: str = "Workspace") -> WorkspaceSelector:
|
|
91
|
+
api_key = default_api_key()
|
|
92
|
+
if not api_key:
|
|
93
|
+
raise RuntimeError("HOTDATA_API_KEY must be set.")
|
|
94
|
+
return WorkspaceSelector(
|
|
95
|
+
api_key=api_key,
|
|
96
|
+
host=default_host(),
|
|
97
|
+
session_id=default_session_id(),
|
|
98
|
+
label=label,
|
|
99
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hotdata-jupyter
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Jupyter integration for Hotdata runtime
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: hotdata-runtime>=0.3.0
|
|
8
|
+
Requires-Dist: hotdata>=0.4.1
|
|
9
|
+
Requires-Dist: ipython>=8.18
|
|
10
|
+
Requires-Dist: ipywidgets>=8.1
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# hotdata-jupyter
|
|
14
|
+
|
|
15
|
+
[Jupyter](https://jupyter.org/) helpers for [Hotdata](https://hotdata.dev) — rich query display, workspace selection, managed databases, and a `%%hotdata` cell magic for running SQL directly in cells.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install hotdata-jupyter
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Authentication
|
|
24
|
+
|
|
25
|
+
Set `HOTDATA_API_KEY` in your environment. Optionally set `HOTDATA_WORKSPACE` to pin a specific workspace (the first available workspace is used if unset).
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import hotdata_jupyter as hj
|
|
31
|
+
|
|
32
|
+
client = hj.from_env()
|
|
33
|
+
result = client.execute_sql("SELECT 1 AS ok")
|
|
34
|
+
hj.display_query_result(result)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Workspace selection
|
|
38
|
+
|
|
39
|
+
When `HOTDATA_WORKSPACE` is set, the client connects to that workspace directly. If you have multiple workspaces, use the interactive picker — it renders a dropdown and updates `ws.client` when the selection changes:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
ws = hj.workspace_selector_from_env()
|
|
43
|
+
display(ws.ui)
|
|
44
|
+
client = ws.client
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Running SQL
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
result = client.execute_sql("SELECT * FROM orders LIMIT 10")
|
|
51
|
+
hj.display_query_result(result)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`display_query_result` renders the row count, column names, and a pandas DataFrame inline in the notebook.
|
|
55
|
+
|
|
56
|
+
## Cell magic
|
|
57
|
+
|
|
58
|
+
Load the extension once per session, then use `%%hotdata` cells to write SQL without wrapping it in Python strings. The last active client is picked up automatically:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
%load_ext hotdata_jupyter
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
%%hotdata
|
|
66
|
+
SELECT
|
|
67
|
+
product,
|
|
68
|
+
SUM(amount) AS total
|
|
69
|
+
FROM orders
|
|
70
|
+
GROUP BY product
|
|
71
|
+
ORDER BY total DESC
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Managed databases
|
|
75
|
+
|
|
76
|
+
List the managed databases in your workspace:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
hj.display_managed_databases_panel(client)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Create a database and load parquet files programmatically:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
db = hj.create_managed_database(client, name="sales", tables=["orders"])
|
|
86
|
+
|
|
87
|
+
with open("orders.parquet", "rb") as f:
|
|
88
|
+
loaded = hj.load_managed_table_from_bytes(client, "sales", "orders", f.read())
|
|
89
|
+
|
|
90
|
+
print(f"Loaded {loaded.row_count} rows into {loaded.full_name}")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Or use the interactive ipywidgets form:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
writer = hj.managed_database_writer(client)
|
|
97
|
+
writer.display()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Open the demo notebook
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
jupyter lab examples/demo.ipynb
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The demo covers workspace selection, connection listing, schema browsing, query history, and cell magics.
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
uv sync --locked
|
|
112
|
+
uv run pytest
|
|
113
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
hotdata_jupyter/__init__.py,sha256=a0Xgbq3D3Go-Qs7VjIbRlK5qWE2eMR5gYLVLoUo9QgY,1752
|
|
2
|
+
hotdata_jupyter/databases.py,sha256=QVS0LLh7kdsJDpBje3cEEh3ti0w8IcixoYVE6DCO57w,8064
|
|
3
|
+
hotdata_jupyter/display.py,sha256=6X5k6dIWHwTuLkXTgFbC50g-PUBbaxPLSZyjFnB2zVs,1115
|
|
4
|
+
hotdata_jupyter/env.py,sha256=mb7QxNwQnd_QbTonZb2qSCNVncXKK2rI8rurnDEJAOU,1214
|
|
5
|
+
hotdata_jupyter/magics.py,sha256=cZCo5TsxrmHgNtUFlTjirKH7A-IupQpjggXuue2A7no,1209
|
|
6
|
+
hotdata_jupyter/metadata.py,sha256=BJdYle83OkRb8-q1tYlw27OEYHh9JapuBJ_4sP-1cK0,340
|
|
7
|
+
hotdata_jupyter/workspace.py,sha256=hLVcITsL8ySX6etcVzUpyQnC9kgcU3HiTXTlNqFldrU,3078
|
|
8
|
+
hotdata_jupyter-0.2.1.dist-info/METADATA,sha256=5rduKZgN3d1EF5g0maBIdKxqAFve39g_b5pEZem0ekc,2643
|
|
9
|
+
hotdata_jupyter-0.2.1.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
hotdata_jupyter-0.2.1.dist-info/RECORD,,
|