hotdata-runtime 0.1.0__tar.gz → 0.1.1__tar.gz
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_runtime-0.1.0 → hotdata_runtime-0.1.1}/CONTRACT.md +17 -0
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/PKG-INFO +3 -2
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/README.md +1 -0
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/hotdata_runtime/__init__.py +18 -0
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/hotdata_runtime/client.py +151 -0
- hotdata_runtime-0.1.1/hotdata_runtime/databases.py +91 -0
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/pyproject.toml +2 -2
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/tests/test_contract.py +8 -0
- hotdata_runtime-0.1.1/tests/test_databases.py +209 -0
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/uv.lock +5 -5
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/.github/workflows/publish.yml +0 -0
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/.gitignore +0 -0
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/examples/basic_usage.py +0 -0
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/hotdata_runtime/env.py +0 -0
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/hotdata_runtime/health.py +0 -0
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/hotdata_runtime/http.py +0 -0
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/hotdata_runtime/result.py +0 -0
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/tests/test_client.py +0 -0
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/tests/test_health.py +0 -0
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/tests/test_result.py +0 -0
- {hotdata_runtime-0.1.0 → hotdata_runtime-0.1.1}/tests/test_version.py +0 -0
|
@@ -30,6 +30,14 @@ The supported import surface is:
|
|
|
30
30
|
- `ResultSummary`
|
|
31
31
|
- `RunHistoryItem`
|
|
32
32
|
- `WorkspaceSelection`
|
|
33
|
+
- `ManagedDatabase`
|
|
34
|
+
- `ManagedTable`
|
|
35
|
+
- `LoadManagedTableResult`
|
|
36
|
+
- `MANAGED_SOURCE_TYPE`
|
|
37
|
+
- `DEFAULT_SCHEMA`
|
|
38
|
+
- `build_managed_config`
|
|
39
|
+
- `create_connection_request`
|
|
40
|
+
- `is_parquet_path`
|
|
33
41
|
|
|
34
42
|
Adapters should import from `hotdata_runtime` and treat this surface as the stable API.
|
|
35
43
|
|
|
@@ -49,6 +57,15 @@ Adapters should import from `hotdata_runtime` and treat this surface as the stab
|
|
|
49
57
|
- `list_qualified_table_names(...)` returns sorted fully qualified table names.
|
|
50
58
|
- `columns_for_qualified(qualified, connection_id=...)` resolves table columns, and
|
|
51
59
|
adapters should pass `connection_id` when known.
|
|
60
|
+
- `uploads()` returns the uploads API wrapper for parquet staging.
|
|
61
|
+
- `list_managed_databases()` returns managed-catalog connections (`source_type: managed`).
|
|
62
|
+
- `resolve_managed_database(name_or_id)` resolves a managed database by name or id.
|
|
63
|
+
- `create_managed_database(name, schema=..., tables=...)` creates a managed database and optionally declares tables up front.
|
|
64
|
+
- `delete_managed_database(name_or_id)` deletes a managed database connection.
|
|
65
|
+
- `list_managed_tables(database, schema=...)` lists tables in a managed database.
|
|
66
|
+
- `upload_parquet(path)` uploads a local parquet file and returns an upload id.
|
|
67
|
+
- `load_managed_table(database, table, schema=..., upload_id=..., file=...)` publishes parquet data into a declared managed table.
|
|
68
|
+
- `delete_managed_table(database, table, schema=...)` deletes a managed table.
|
|
52
69
|
|
|
53
70
|
### `QueryResult`
|
|
54
71
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hotdata-runtime
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Workspace/session runtime primitives for Hotdata integrations
|
|
5
5
|
License: MIT
|
|
6
6
|
Requires-Python: >=3.10
|
|
7
|
-
Requires-Dist: hotdata>=0.
|
|
7
|
+
Requires-Dist: hotdata>=0.2.0
|
|
8
8
|
Requires-Dist: pandas>=2.0
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
|
|
@@ -23,6 +23,7 @@ Runtime boundary and guarantees are defined in `CONTRACT.md`.
|
|
|
23
23
|
- **SQL execution helper** — run SQL through `POST /v1/query`, poll async query runs when needed, and return a `QueryResult`.
|
|
24
24
|
- **Result utilities** — convert query results to records, pandas DataFrames, or metadata dictionaries for adapter display layers.
|
|
25
25
|
- **History helpers** — list recent results and query run history with normalized dataclasses.
|
|
26
|
+
- **Managed databases** — create Hotdata-owned catalogs, declare tables, upload parquet, and load managed tables (mirrors `hotdata databases` in the CLI).
|
|
26
27
|
- **Health helpers** — build compact API/workspace health summaries for UI integrations.
|
|
27
28
|
|
|
28
29
|
Install:
|
|
@@ -13,6 +13,7 @@ Runtime boundary and guarantees are defined in `CONTRACT.md`.
|
|
|
13
13
|
- **SQL execution helper** — run SQL through `POST /v1/query`, poll async query runs when needed, and return a `QueryResult`.
|
|
14
14
|
- **Result utilities** — convert query results to records, pandas DataFrames, or metadata dictionaries for adapter display layers.
|
|
15
15
|
- **History helpers** — list recent results and query run history with normalized dataclasses.
|
|
16
|
+
- **Managed databases** — create Hotdata-owned catalogs, declare tables, upload parquet, and load managed tables (mirrors `hotdata databases` in the CLI).
|
|
16
17
|
- **Health helpers** — build compact API/workspace health summaries for UI integrations.
|
|
17
18
|
|
|
18
19
|
Install:
|
|
@@ -8,6 +8,16 @@ from hotdata_runtime.client import (
|
|
|
8
8
|
RunHistoryItem,
|
|
9
9
|
from_env,
|
|
10
10
|
)
|
|
11
|
+
from hotdata_runtime.databases import (
|
|
12
|
+
DEFAULT_SCHEMA,
|
|
13
|
+
LoadManagedTableResult,
|
|
14
|
+
ManagedDatabase,
|
|
15
|
+
ManagedTable,
|
|
16
|
+
MANAGED_SOURCE_TYPE,
|
|
17
|
+
build_managed_config,
|
|
18
|
+
create_connection_request,
|
|
19
|
+
is_parquet_path,
|
|
20
|
+
)
|
|
11
21
|
from hotdata_runtime.env import (
|
|
12
22
|
default_api_key,
|
|
13
23
|
default_host,
|
|
@@ -29,8 +39,16 @@ except PackageNotFoundError:
|
|
|
29
39
|
|
|
30
40
|
__all__ = [
|
|
31
41
|
"__version__",
|
|
42
|
+
"DEFAULT_SCHEMA",
|
|
32
43
|
"HotdataClient",
|
|
44
|
+
"LoadManagedTableResult",
|
|
45
|
+
"MANAGED_SOURCE_TYPE",
|
|
46
|
+
"ManagedDatabase",
|
|
47
|
+
"ManagedTable",
|
|
33
48
|
"QueryResult",
|
|
49
|
+
"build_managed_config",
|
|
50
|
+
"create_connection_request",
|
|
51
|
+
"is_parquet_path",
|
|
34
52
|
"workspace_health_lines",
|
|
35
53
|
"default_api_key",
|
|
36
54
|
"default_host",
|
|
@@ -13,10 +13,12 @@ from hotdata.api.information_schema_api import InformationSchemaApi
|
|
|
13
13
|
from hotdata.api.query_api import QueryApi
|
|
14
14
|
from hotdata.api.query_runs_api import QueryRunsApi
|
|
15
15
|
from hotdata.api.results_api import ResultsApi
|
|
16
|
+
from hotdata.api.uploads_api import UploadsApi
|
|
16
17
|
from hotdata.exceptions import ApiException
|
|
17
18
|
from hotdata.models.async_query_response import AsyncQueryResponse
|
|
18
19
|
from hotdata.models.query_request import QueryRequest
|
|
19
20
|
from hotdata.models.query_response import QueryResponse
|
|
21
|
+
from hotdata.models.load_managed_table_request import LoadManagedTableRequest
|
|
20
22
|
from hotdata.models.table_info import TableInfo
|
|
21
23
|
|
|
22
24
|
from hotdata_runtime.env import (
|
|
@@ -26,6 +28,17 @@ from hotdata_runtime.env import (
|
|
|
26
28
|
normalize_host,
|
|
27
29
|
pick_workspace,
|
|
28
30
|
)
|
|
31
|
+
from hotdata_runtime.databases import (
|
|
32
|
+
DEFAULT_SCHEMA,
|
|
33
|
+
LoadManagedTableResult,
|
|
34
|
+
ManagedDatabase,
|
|
35
|
+
ManagedTable,
|
|
36
|
+
MANAGED_SOURCE_TYPE,
|
|
37
|
+
api_error_message,
|
|
38
|
+
create_connection_request,
|
|
39
|
+
is_parquet_path,
|
|
40
|
+
managed_database_from_connection,
|
|
41
|
+
)
|
|
29
42
|
from hotdata_runtime.http import default_http_retries
|
|
30
43
|
from hotdata_runtime.result import QueryResult
|
|
31
44
|
|
|
@@ -135,6 +148,144 @@ class HotdataClient:
|
|
|
135
148
|
def results(self) -> ResultsApi:
|
|
136
149
|
return self._results_api()
|
|
137
150
|
|
|
151
|
+
def uploads(self) -> UploadsApi:
|
|
152
|
+
return UploadsApi(self._api)
|
|
153
|
+
|
|
154
|
+
def list_managed_databases(self) -> list[ManagedDatabase]:
|
|
155
|
+
listing = self.connections().list_connections()
|
|
156
|
+
return [
|
|
157
|
+
managed_database_from_connection(c)
|
|
158
|
+
for c in listing.connections
|
|
159
|
+
if c.source_type == MANAGED_SOURCE_TYPE
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
def resolve_managed_database(self, name_or_id: str) -> ManagedDatabase:
|
|
163
|
+
listing = self.connections().list_connections()
|
|
164
|
+
match = None
|
|
165
|
+
for c in listing.connections:
|
|
166
|
+
if c.id == name_or_id or c.name == name_or_id:
|
|
167
|
+
match = c
|
|
168
|
+
break
|
|
169
|
+
if match is None:
|
|
170
|
+
raise KeyError(f"No database named or with id {name_or_id!r}")
|
|
171
|
+
if match.source_type != MANAGED_SOURCE_TYPE:
|
|
172
|
+
raise ValueError(
|
|
173
|
+
f"{match.name!r} is not a managed database "
|
|
174
|
+
f"(source_type: {match.source_type})"
|
|
175
|
+
)
|
|
176
|
+
return managed_database_from_connection(match)
|
|
177
|
+
|
|
178
|
+
def create_managed_database(
|
|
179
|
+
self,
|
|
180
|
+
name: str,
|
|
181
|
+
*,
|
|
182
|
+
schema: str = DEFAULT_SCHEMA,
|
|
183
|
+
tables: list[str] | None = None,
|
|
184
|
+
) -> ManagedDatabase:
|
|
185
|
+
request = create_connection_request(name, schema=schema, tables=tables)
|
|
186
|
+
try:
|
|
187
|
+
created = self.connections().create_connection(request)
|
|
188
|
+
except ApiException as e:
|
|
189
|
+
raise RuntimeError(api_error_message(e)) from e
|
|
190
|
+
return managed_database_from_connection(created)
|
|
191
|
+
|
|
192
|
+
def delete_managed_database(self, name_or_id: str) -> None:
|
|
193
|
+
db = self.resolve_managed_database(name_or_id)
|
|
194
|
+
try:
|
|
195
|
+
self.connections().delete_connection(db.id)
|
|
196
|
+
except ApiException as e:
|
|
197
|
+
raise RuntimeError(api_error_message(e)) from e
|
|
198
|
+
|
|
199
|
+
def list_managed_tables(
|
|
200
|
+
self,
|
|
201
|
+
database: str,
|
|
202
|
+
*,
|
|
203
|
+
schema: str | None = None,
|
|
204
|
+
) -> list[ManagedTable]:
|
|
205
|
+
db = self.resolve_managed_database(database)
|
|
206
|
+
rows: list[ManagedTable] = []
|
|
207
|
+
for t in self.iter_tables(connection_id=db.id):
|
|
208
|
+
if schema is not None and t.var_schema != schema:
|
|
209
|
+
continue
|
|
210
|
+
rows.append(
|
|
211
|
+
ManagedTable(
|
|
212
|
+
full_name=f"{db.name}.{t.var_schema}.{t.table}",
|
|
213
|
+
schema=t.var_schema,
|
|
214
|
+
table=t.table,
|
|
215
|
+
synced=t.synced,
|
|
216
|
+
last_sync=t.last_sync,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
rows.sort(key=lambda row: (row.schema, row.table))
|
|
220
|
+
return rows
|
|
221
|
+
|
|
222
|
+
def upload_parquet(self, path: str) -> str:
|
|
223
|
+
if not is_parquet_path(path):
|
|
224
|
+
raise ValueError(
|
|
225
|
+
f"Managed table loads require a parquet file (got {path!r})"
|
|
226
|
+
)
|
|
227
|
+
with open(path, "rb") as f:
|
|
228
|
+
data = f.read()
|
|
229
|
+
try:
|
|
230
|
+
uploaded = self.uploads().upload_file(
|
|
231
|
+
data,
|
|
232
|
+
_content_type="application/octet-stream",
|
|
233
|
+
)
|
|
234
|
+
except ApiException as e:
|
|
235
|
+
raise RuntimeError(api_error_message(e)) from e
|
|
236
|
+
return uploaded.id
|
|
237
|
+
|
|
238
|
+
def load_managed_table(
|
|
239
|
+
self,
|
|
240
|
+
database: str,
|
|
241
|
+
table: str,
|
|
242
|
+
*,
|
|
243
|
+
schema: str = DEFAULT_SCHEMA,
|
|
244
|
+
upload_id: str | None = None,
|
|
245
|
+
file: str | None = None,
|
|
246
|
+
) -> LoadManagedTableResult:
|
|
247
|
+
if (upload_id is None) == (file is None):
|
|
248
|
+
raise ValueError("Exactly one of upload_id or file is required")
|
|
249
|
+
db = self.resolve_managed_database(database)
|
|
250
|
+
if upload_id is not None:
|
|
251
|
+
resolved_upload_id = upload_id
|
|
252
|
+
else:
|
|
253
|
+
assert file is not None
|
|
254
|
+
resolved_upload_id = self.upload_parquet(file)
|
|
255
|
+
request = LoadManagedTableRequest(
|
|
256
|
+
mode="replace",
|
|
257
|
+
upload_id=resolved_upload_id,
|
|
258
|
+
)
|
|
259
|
+
try:
|
|
260
|
+
loaded = self.connections().load_managed_table(
|
|
261
|
+
db.id,
|
|
262
|
+
schema,
|
|
263
|
+
table,
|
|
264
|
+
request,
|
|
265
|
+
)
|
|
266
|
+
except ApiException as e:
|
|
267
|
+
raise RuntimeError(api_error_message(e)) from e
|
|
268
|
+
return LoadManagedTableResult(
|
|
269
|
+
connection_id=loaded.connection_id,
|
|
270
|
+
schema_name=loaded.schema_name,
|
|
271
|
+
table_name=loaded.table_name,
|
|
272
|
+
row_count=loaded.row_count,
|
|
273
|
+
full_name=f"{db.name}.{loaded.schema_name}.{loaded.table_name}",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def delete_managed_table(
|
|
277
|
+
self,
|
|
278
|
+
database: str,
|
|
279
|
+
table: str,
|
|
280
|
+
*,
|
|
281
|
+
schema: str = DEFAULT_SCHEMA,
|
|
282
|
+
) -> None:
|
|
283
|
+
db = self.resolve_managed_database(database)
|
|
284
|
+
try:
|
|
285
|
+
self.connections().delete_managed_table(db.id, schema, table)
|
|
286
|
+
except ApiException as e:
|
|
287
|
+
raise RuntimeError(api_error_message(e)) from e
|
|
288
|
+
|
|
138
289
|
def list_recent_results(
|
|
139
290
|
self,
|
|
140
291
|
*,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Managed database helpers (Hotdata-owned catalogs with parquet table loads)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import asdict, dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from hotdata.exceptions import ApiException
|
|
10
|
+
from hotdata.models.create_connection_request import CreateConnectionRequest
|
|
11
|
+
|
|
12
|
+
MANAGED_SOURCE_TYPE = "managed"
|
|
13
|
+
DEFAULT_SCHEMA = "public"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ManagedDatabase:
|
|
18
|
+
id: str
|
|
19
|
+
name: str
|
|
20
|
+
source_type: str
|
|
21
|
+
|
|
22
|
+
def to_dict(self) -> dict[str, Any]:
|
|
23
|
+
return asdict(self)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ManagedTable:
|
|
28
|
+
full_name: str
|
|
29
|
+
schema: str
|
|
30
|
+
table: str
|
|
31
|
+
synced: bool
|
|
32
|
+
last_sync: str | None
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict[str, Any]:
|
|
35
|
+
return asdict(self)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class LoadManagedTableResult:
|
|
40
|
+
connection_id: str
|
|
41
|
+
schema_name: str
|
|
42
|
+
table_name: str
|
|
43
|
+
row_count: int
|
|
44
|
+
full_name: str
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, Any]:
|
|
47
|
+
return asdict(self)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_parquet_path(path: str) -> bool:
|
|
51
|
+
return Path(path).suffix.lower() == ".parquet"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_managed_config(schema: str, tables: list[str]) -> dict[str, Any]:
|
|
55
|
+
if not tables:
|
|
56
|
+
return {}
|
|
57
|
+
return {
|
|
58
|
+
"schemas": [
|
|
59
|
+
{
|
|
60
|
+
"name": schema,
|
|
61
|
+
"tables": [{"name": table} for table in tables],
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def create_connection_request(
|
|
68
|
+
name: str,
|
|
69
|
+
*,
|
|
70
|
+
schema: str = DEFAULT_SCHEMA,
|
|
71
|
+
tables: list[str] | None = None,
|
|
72
|
+
) -> CreateConnectionRequest:
|
|
73
|
+
table_list = tables or []
|
|
74
|
+
return CreateConnectionRequest(
|
|
75
|
+
name=name,
|
|
76
|
+
source_type=MANAGED_SOURCE_TYPE,
|
|
77
|
+
config=build_managed_config(schema, table_list),
|
|
78
|
+
skip_discovery=True,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def managed_database_from_connection(conn: Any) -> ManagedDatabase:
|
|
83
|
+
return ManagedDatabase(
|
|
84
|
+
id=str(conn.id),
|
|
85
|
+
name=str(conn.name),
|
|
86
|
+
source_type=str(conn.source_type),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def api_error_message(exc: ApiException) -> str:
|
|
91
|
+
return exc.reason or str(exc)
|
|
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hotdata-runtime"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.1"
|
|
8
8
|
description = "Workspace/session runtime primitives for Hotdata integrations"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = { text = "MIT" }
|
|
12
12
|
dependencies = [
|
|
13
|
-
"hotdata>=0.
|
|
13
|
+
"hotdata>=0.2.0",
|
|
14
14
|
"pandas>=2.0",
|
|
15
15
|
]
|
|
16
16
|
|
|
@@ -11,8 +11,16 @@ from hotdata_runtime.result import QueryResult
|
|
|
11
11
|
def test_public_exports_contract():
|
|
12
12
|
assert hr.__all__ == [
|
|
13
13
|
"__version__",
|
|
14
|
+
"DEFAULT_SCHEMA",
|
|
14
15
|
"HotdataClient",
|
|
16
|
+
"LoadManagedTableResult",
|
|
17
|
+
"MANAGED_SOURCE_TYPE",
|
|
18
|
+
"ManagedDatabase",
|
|
19
|
+
"ManagedTable",
|
|
15
20
|
"QueryResult",
|
|
21
|
+
"build_managed_config",
|
|
22
|
+
"create_connection_request",
|
|
23
|
+
"is_parquet_path",
|
|
16
24
|
"workspace_health_lines",
|
|
17
25
|
"default_api_key",
|
|
18
26
|
"default_host",
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
from unittest.mock import mock_open, patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from hotdata.exceptions import ApiException
|
|
9
|
+
from hotdata_runtime.client import HotdataClient
|
|
10
|
+
from hotdata_runtime.databases import (
|
|
11
|
+
build_managed_config,
|
|
12
|
+
create_connection_request,
|
|
13
|
+
is_parquet_path,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _client() -> HotdataClient:
|
|
18
|
+
return HotdataClient("k", "ws", host="https://api.hotdata.dev")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_build_managed_config_empty_without_tables():
|
|
22
|
+
assert build_managed_config("public", []) == {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_build_managed_config_declares_tables():
|
|
26
|
+
cfg = build_managed_config("public", ["orders", "customers"])
|
|
27
|
+
assert cfg == {
|
|
28
|
+
"schemas": [
|
|
29
|
+
{
|
|
30
|
+
"name": "public",
|
|
31
|
+
"tables": [{"name": "orders"}, {"name": "customers"}],
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_create_connection_request_uses_managed_source_type():
|
|
38
|
+
req = create_connection_request("sales", schema="public", tables=["orders"])
|
|
39
|
+
assert req.name == "sales"
|
|
40
|
+
assert req.source_type == "managed"
|
|
41
|
+
assert req.skip_discovery is True
|
|
42
|
+
assert req.config["schemas"][0]["tables"][0]["name"] == "orders"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.parametrize(
|
|
46
|
+
("path", "expected"),
|
|
47
|
+
[
|
|
48
|
+
("/data/orders.parquet", True),
|
|
49
|
+
("/data/ORDERS.PARQUET", True),
|
|
50
|
+
("/data/orders.csv", False),
|
|
51
|
+
],
|
|
52
|
+
)
|
|
53
|
+
def test_is_parquet_path(path: str, expected: bool):
|
|
54
|
+
assert is_parquet_path(path) is expected
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_list_managed_databases_filters_managed_only():
|
|
58
|
+
client = _client()
|
|
59
|
+
listing = SimpleNamespace(
|
|
60
|
+
connections=[
|
|
61
|
+
SimpleNamespace(id="c1", name="sales", source_type="managed"),
|
|
62
|
+
SimpleNamespace(id="c2", name="warehouse", source_type="postgres"),
|
|
63
|
+
]
|
|
64
|
+
)
|
|
65
|
+
with patch.object(client, "connections") as connections:
|
|
66
|
+
connections.return_value.list_connections.return_value = listing
|
|
67
|
+
dbs = client.list_managed_databases()
|
|
68
|
+
assert [db.name for db in dbs] == ["sales"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_resolve_managed_database_by_name_and_id():
|
|
72
|
+
client = _client()
|
|
73
|
+
listing = SimpleNamespace(
|
|
74
|
+
connections=[
|
|
75
|
+
SimpleNamespace(id="conn_abc", name="sales", source_type="managed"),
|
|
76
|
+
]
|
|
77
|
+
)
|
|
78
|
+
with patch.object(client, "connections") as connections:
|
|
79
|
+
connections.return_value.list_connections.return_value = listing
|
|
80
|
+
by_name = client.resolve_managed_database("sales")
|
|
81
|
+
by_id = client.resolve_managed_database("conn_abc")
|
|
82
|
+
assert by_name.id == "conn_abc"
|
|
83
|
+
assert by_id.name == "sales"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_resolve_managed_database_rejects_non_managed():
|
|
87
|
+
client = _client()
|
|
88
|
+
listing = SimpleNamespace(
|
|
89
|
+
connections=[
|
|
90
|
+
SimpleNamespace(id="c1", name="warehouse", source_type="postgres"),
|
|
91
|
+
]
|
|
92
|
+
)
|
|
93
|
+
with patch.object(client, "connections") as connections:
|
|
94
|
+
connections.return_value.list_connections.return_value = listing
|
|
95
|
+
with pytest.raises(ValueError, match="not a managed database"):
|
|
96
|
+
client.resolve_managed_database("warehouse")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_create_managed_database_returns_summary():
|
|
100
|
+
client = _client()
|
|
101
|
+
created = SimpleNamespace(id="conn_new", name="mydb", source_type="managed")
|
|
102
|
+
with patch.object(client, "connections") as connections:
|
|
103
|
+
connections.return_value.create_connection.return_value = created
|
|
104
|
+
db = client.create_managed_database("mydb", tables=["orders"])
|
|
105
|
+
assert db.id == "conn_new"
|
|
106
|
+
assert db.name == "mydb"
|
|
107
|
+
req = connections.return_value.create_connection.call_args.args[0]
|
|
108
|
+
assert req.config["schemas"][0]["tables"][0]["name"] == "orders"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_create_managed_database_wraps_api_errors():
|
|
112
|
+
client = _client()
|
|
113
|
+
with patch.object(client, "connections") as connections:
|
|
114
|
+
connections.return_value.create_connection.side_effect = ApiException(
|
|
115
|
+
status=400,
|
|
116
|
+
reason="bad request",
|
|
117
|
+
)
|
|
118
|
+
with pytest.raises(RuntimeError, match="bad request"):
|
|
119
|
+
client.create_managed_database("mydb")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_list_managed_tables_builds_full_names():
|
|
123
|
+
client = _client()
|
|
124
|
+
listing = SimpleNamespace(
|
|
125
|
+
connections=[
|
|
126
|
+
SimpleNamespace(id="conn1", name="sales", source_type="managed"),
|
|
127
|
+
]
|
|
128
|
+
)
|
|
129
|
+
table = SimpleNamespace(
|
|
130
|
+
connection="sales",
|
|
131
|
+
var_schema="public",
|
|
132
|
+
table="orders",
|
|
133
|
+
synced=True,
|
|
134
|
+
last_sync="2026-05-19T00:00:00Z",
|
|
135
|
+
)
|
|
136
|
+
with patch.object(client, "connections") as connections, patch.object(
|
|
137
|
+
client, "iter_tables", return_value=[table]
|
|
138
|
+
):
|
|
139
|
+
connections.return_value.list_connections.return_value = listing
|
|
140
|
+
rows = client.list_managed_tables("sales")
|
|
141
|
+
assert len(rows) == 1
|
|
142
|
+
assert rows[0].full_name == "sales.public.orders"
|
|
143
|
+
assert rows[0].synced is True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_upload_parquet_rejects_non_parquet():
|
|
147
|
+
client = _client()
|
|
148
|
+
with pytest.raises(ValueError, match="parquet"):
|
|
149
|
+
client.upload_parquet("/tmp/data.csv")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_upload_parquet_returns_upload_id():
|
|
153
|
+
client = _client()
|
|
154
|
+
uploaded = SimpleNamespace(id="upl_123")
|
|
155
|
+
with patch("builtins.open", mock_open(read_data=b"PAR1")), patch.object(
|
|
156
|
+
client, "uploads"
|
|
157
|
+
) as uploads:
|
|
158
|
+
uploads.return_value.upload_file.return_value = uploaded
|
|
159
|
+
upload_id = client.upload_parquet("/tmp/data.parquet")
|
|
160
|
+
assert upload_id == "upl_123"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_load_managed_table_with_upload_id():
|
|
164
|
+
client = _client()
|
|
165
|
+
db = SimpleNamespace(id="conn1", name="sales", source_type="managed")
|
|
166
|
+
loaded = SimpleNamespace(
|
|
167
|
+
connection_id="conn1",
|
|
168
|
+
schema_name="public",
|
|
169
|
+
table_name="orders",
|
|
170
|
+
row_count=42,
|
|
171
|
+
)
|
|
172
|
+
with patch.object(client, "resolve_managed_database", return_value=db), patch.object(
|
|
173
|
+
client, "connections"
|
|
174
|
+
) as connections:
|
|
175
|
+
connections.return_value.load_managed_table.return_value = loaded
|
|
176
|
+
result = client.load_managed_table(
|
|
177
|
+
"sales",
|
|
178
|
+
"orders",
|
|
179
|
+
upload_id="upl_123",
|
|
180
|
+
)
|
|
181
|
+
assert result.row_count == 42
|
|
182
|
+
assert result.full_name == "sales.public.orders"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def test_load_managed_table_requires_exactly_one_source():
|
|
186
|
+
client = _client()
|
|
187
|
+
with pytest.raises(ValueError, match="Exactly one"):
|
|
188
|
+
client.load_managed_table("sales", "orders")
|
|
189
|
+
with pytest.raises(ValueError, match="Exactly one"):
|
|
190
|
+
client.load_managed_table(
|
|
191
|
+
"sales",
|
|
192
|
+
"orders",
|
|
193
|
+
upload_id="upl_1",
|
|
194
|
+
file="/tmp/x.parquet",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_delete_managed_table_calls_sdk():
|
|
199
|
+
client = _client()
|
|
200
|
+
db = SimpleNamespace(id="conn1", name="sales", source_type="managed")
|
|
201
|
+
with patch.object(client, "resolve_managed_database", return_value=db), patch.object(
|
|
202
|
+
client, "connections"
|
|
203
|
+
) as connections:
|
|
204
|
+
client.delete_managed_table("sales", "orders")
|
|
205
|
+
connections.return_value.delete_managed_table.assert_called_once_with(
|
|
206
|
+
"conn1",
|
|
207
|
+
"public",
|
|
208
|
+
"orders",
|
|
209
|
+
)
|
|
@@ -43,7 +43,7 @@ wheels = [
|
|
|
43
43
|
|
|
44
44
|
[[package]]
|
|
45
45
|
name = "hotdata"
|
|
46
|
-
version = "0.
|
|
46
|
+
version = "0.2.0"
|
|
47
47
|
source = { registry = "https://pypi.org/simple" }
|
|
48
48
|
dependencies = [
|
|
49
49
|
{ name = "pydantic" },
|
|
@@ -51,14 +51,14 @@ dependencies = [
|
|
|
51
51
|
{ name = "typing-extensions" },
|
|
52
52
|
{ name = "urllib3" },
|
|
53
53
|
]
|
|
54
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
54
|
+
sdist = { url = "https://files.pythonhosted.org/packages/ce/0f/1e9e024aa13f8d4bf8f9fb1bce777da6ca19da05b8435f2ba5cd5f87ec80/hotdata-0.2.0.tar.gz", hash = "sha256:e1131c05ed34d2f39ddee84930eb6694ed46971d7a442df5932689b28a6c9b4f", size = 108780, upload-time = "2026-05-19T04:01:38.345Z" }
|
|
55
55
|
wheels = [
|
|
56
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
56
|
+
{ url = "https://files.pythonhosted.org/packages/9a/e7/63b4820963ec475fe16403d363e5ddec237cfe01a39c2d7aff6a6d48d720/hotdata-0.2.0-py3-none-any.whl", hash = "sha256:d3d644a3b607f4891a784b8d5afa30a00bd9e437db013fd0581bf8bca501ac0d", size = 256603, upload-time = "2026-05-19T04:01:36.253Z" },
|
|
57
57
|
]
|
|
58
58
|
|
|
59
59
|
[[package]]
|
|
60
60
|
name = "hotdata-runtime"
|
|
61
|
-
version = "0.1.
|
|
61
|
+
version = "0.1.1"
|
|
62
62
|
source = { editable = "." }
|
|
63
63
|
dependencies = [
|
|
64
64
|
{ name = "hotdata" },
|
|
@@ -74,7 +74,7 @@ dev = [
|
|
|
74
74
|
|
|
75
75
|
[package.metadata]
|
|
76
76
|
requires-dist = [
|
|
77
|
-
{ name = "hotdata", specifier = ">=0.
|
|
77
|
+
{ name = "hotdata", specifier = ">=0.2.0" },
|
|
78
78
|
{ name = "pandas", specifier = ">=2.0" },
|
|
79
79
|
]
|
|
80
80
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|