hotdata-runtime 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,47 @@
1
+ """Hotdata runtime primitives for notebook and app integrations."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from hotdata_runtime.client import (
6
+ HotdataClient,
7
+ ResultSummary,
8
+ RunHistoryItem,
9
+ from_env,
10
+ )
11
+ from hotdata_runtime.env import (
12
+ default_api_key,
13
+ default_host,
14
+ default_session_id,
15
+ explicit_workspace_id,
16
+ list_workspaces,
17
+ normalize_host,
18
+ pick_workspace,
19
+ resolve_workspace_selection,
20
+ WorkspaceSelection,
21
+ )
22
+ from hotdata_runtime.health import workspace_health_lines
23
+ from hotdata_runtime.result import QueryResult
24
+
25
+ try:
26
+ __version__ = version("hotdata-runtime")
27
+ except PackageNotFoundError:
28
+ __version__ = "0.0.0+unknown"
29
+
30
+ __all__ = [
31
+ "__version__",
32
+ "HotdataClient",
33
+ "QueryResult",
34
+ "workspace_health_lines",
35
+ "default_api_key",
36
+ "default_host",
37
+ "default_session_id",
38
+ "explicit_workspace_id",
39
+ "from_env",
40
+ "list_workspaces",
41
+ "normalize_host",
42
+ "pick_workspace",
43
+ "resolve_workspace_selection",
44
+ "ResultSummary",
45
+ "RunHistoryItem",
46
+ "WorkspaceSelection",
47
+ ]
@@ -0,0 +1,341 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ import time
5
+ from typing import Any, Iterator
6
+
7
+ from urllib3.exceptions import HTTPError as Urllib3HTTPError
8
+ from urllib3.exceptions import ProtocolError
9
+
10
+ from hotdata import ApiClient, Configuration
11
+ from hotdata.api.connections_api import ConnectionsApi
12
+ from hotdata.api.information_schema_api import InformationSchemaApi
13
+ from hotdata.api.query_api import QueryApi
14
+ from hotdata.api.query_runs_api import QueryRunsApi
15
+ from hotdata.api.results_api import ResultsApi
16
+ from hotdata.exceptions import ApiException
17
+ from hotdata.models.async_query_response import AsyncQueryResponse
18
+ from hotdata.models.query_request import QueryRequest
19
+ from hotdata.models.query_response import QueryResponse
20
+ from hotdata.models.table_info import TableInfo
21
+
22
+ from hotdata_runtime.env import (
23
+ default_api_key,
24
+ default_host,
25
+ default_session_id,
26
+ normalize_host,
27
+ pick_workspace,
28
+ )
29
+ from hotdata_runtime.http import default_http_retries
30
+ from hotdata_runtime.result import QueryResult
31
+
32
+ _TERMINAL = frozenset({"succeeded", "failed", "cancelled"})
33
+ _RESULT_FAILURE = frozenset({"failed", "cancelled"})
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ResultSummary:
38
+ result_id: str
39
+ status: str
40
+ created_at: str | None
41
+
42
+ def to_dict(self) -> dict[str, Any]:
43
+ return asdict(self)
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class RunHistoryItem:
48
+ query_run_id: str
49
+ status: str
50
+ created_at: str | None
51
+ execution_time_ms: int | None
52
+ result_id: str | None
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ return asdict(self)
56
+
57
+
58
+ class HotdataClient:
59
+ """Thin wrapper around the Hotdata Python SDK with query polling helpers."""
60
+
61
+ def __init__(
62
+ self,
63
+ api_key: str,
64
+ workspace_id: str,
65
+ *,
66
+ host: str | None = None,
67
+ session_id: str | None = None,
68
+ ) -> None:
69
+ self._host = normalize_host(host) if host else default_host()
70
+ self._api_key = api_key
71
+ self._workspace_id = workspace_id
72
+ self._session_id = session_id
73
+ self._config = Configuration(
74
+ host=self._host,
75
+ api_key=api_key,
76
+ workspace_id=workspace_id,
77
+ session_id=session_id,
78
+ retries=default_http_retries(),
79
+ )
80
+ self._api = ApiClient(self._config)
81
+
82
+ @classmethod
83
+ def from_env(cls) -> HotdataClient:
84
+ api_key = default_api_key()
85
+ if not api_key:
86
+ raise RuntimeError("HOTDATA_API_KEY must be set.")
87
+ host = default_host()
88
+ session = default_session_id()
89
+ workspace_id = pick_workspace(api_key, host, session)
90
+ return cls(api_key, workspace_id, host=host, session_id=session)
91
+
92
+ @property
93
+ def workspace_id(self) -> str:
94
+ return self._workspace_id
95
+
96
+ @property
97
+ def host(self) -> str:
98
+ return self._host
99
+
100
+ @property
101
+ def session_id(self) -> str | None:
102
+ return self._session_id
103
+
104
+ @property
105
+ def api(self) -> ApiClient:
106
+ return self._api
107
+
108
+ def close(self) -> None:
109
+ self._api.close()
110
+
111
+ def __enter__(self) -> HotdataClient:
112
+ return self
113
+
114
+ def __exit__(self, *args: object) -> None:
115
+ self.close()
116
+
117
+ def connections(self) -> ConnectionsApi:
118
+ return ConnectionsApi(self._api)
119
+
120
+ def _information_schema(self) -> InformationSchemaApi:
121
+ return InformationSchemaApi(self._api)
122
+
123
+ def _query_api(self) -> QueryApi:
124
+ return QueryApi(self._api)
125
+
126
+ def _query_runs_api(self) -> QueryRunsApi:
127
+ return QueryRunsApi(self._api)
128
+
129
+ def _results_api(self) -> ResultsApi:
130
+ return ResultsApi(self._api)
131
+
132
+ def query_runs(self) -> QueryRunsApi:
133
+ return self._query_runs_api()
134
+
135
+ def results(self) -> ResultsApi:
136
+ return self._results_api()
137
+
138
+ def list_recent_results(
139
+ self,
140
+ *,
141
+ limit: int = 50,
142
+ offset: int = 0,
143
+ ) -> list[ResultSummary]:
144
+ listing = self.results().list_results(limit=limit, offset=offset)
145
+ return [
146
+ ResultSummary(
147
+ result_id=r.id,
148
+ status=r.status,
149
+ created_at=r.created_at,
150
+ )
151
+ for r in listing.results
152
+ ]
153
+
154
+ def list_run_history(
155
+ self,
156
+ *,
157
+ limit: int = 20,
158
+ ) -> list[RunHistoryItem]:
159
+ listing = self.query_runs().list_query_runs(limit=limit)
160
+ return [
161
+ RunHistoryItem(
162
+ query_run_id=r.id,
163
+ status=r.status,
164
+ created_at=r.created_at,
165
+ execution_time_ms=r.execution_time_ms,
166
+ result_id=r.result_id,
167
+ )
168
+ for r in listing.query_runs
169
+ ]
170
+
171
+ def iter_tables(
172
+ self,
173
+ *,
174
+ connection_id: str | None = None,
175
+ include_columns: bool = False,
176
+ page_size: int = 200,
177
+ ) -> Iterator[TableInfo]:
178
+ cursor: str | None = None
179
+ while True:
180
+ resp = self._information_schema().information_schema(
181
+ connection_id=connection_id,
182
+ include_columns=include_columns,
183
+ limit=page_size,
184
+ cursor=cursor,
185
+ )
186
+ yield from resp.tables
187
+ if not resp.has_more or not resp.next_cursor:
188
+ break
189
+ cursor = resp.next_cursor
190
+
191
+ def qualified_table_name(self, t: TableInfo) -> str:
192
+ return f"{t.connection}.{t.var_schema}.{t.table}"
193
+
194
+ def list_qualified_table_names(
195
+ self, *, limit: int = 5000, connection_id: str | None = None
196
+ ) -> list[str]:
197
+ out: list[str] = []
198
+ for t in self.iter_tables(connection_id=connection_id):
199
+ out.append(self.qualified_table_name(t))
200
+ if len(out) >= limit:
201
+ break
202
+ return sorted(out)
203
+
204
+ def connection_id_by_name(self) -> dict[str, str]:
205
+ listing = self.connections().list_connections()
206
+ id_map: dict[str, str] = {}
207
+ duplicate_names: set[str] = set()
208
+ for c in listing.connections:
209
+ if c.name in id_map and id_map[c.name] != c.id:
210
+ duplicate_names.add(c.name)
211
+ id_map[c.name] = c.id
212
+ if duplicate_names:
213
+ names = ", ".join(sorted(duplicate_names))
214
+ raise RuntimeError(
215
+ f"Duplicate connection names found: {names}. "
216
+ "Use an explicit connection_id."
217
+ )
218
+ return id_map
219
+
220
+ def columns_for_qualified(
221
+ self,
222
+ qualified: str,
223
+ *,
224
+ connection_id: str | None = None,
225
+ ) -> list[TableInfo]:
226
+ parts = qualified.split(".")
227
+ if len(parts) < 3:
228
+ raise ValueError(
229
+ f"Expected connection.schema.table, got {qualified!r}"
230
+ )
231
+ conn_name, schema_name, table_name = (
232
+ parts[0],
233
+ parts[1],
234
+ ".".join(parts[2:]),
235
+ )
236
+ conn_id = connection_id
237
+ if conn_id is None:
238
+ id_map = self.connection_id_by_name()
239
+ conn_id = id_map.get(conn_name)
240
+ if not conn_id:
241
+ raise KeyError(f"Unknown connection {conn_name!r}")
242
+ resp = self._information_schema().information_schema(
243
+ connection_id=conn_id,
244
+ var_schema=schema_name,
245
+ table=table_name,
246
+ include_columns=True,
247
+ limit=10,
248
+ )
249
+ if not resp.tables:
250
+ return []
251
+ first = resp.tables[0]
252
+ return first.columns or []
253
+
254
+ def _poll_query_run(
255
+ self,
256
+ query_run_id: str,
257
+ *,
258
+ timeout_s: float = 300.0,
259
+ interval_s: float = 0.5,
260
+ ):
261
+ runs = self._query_runs_api()
262
+ deadline = time.monotonic() + timeout_s
263
+ last = None
264
+ while time.monotonic() < deadline:
265
+ last = runs.get_query_run(query_run_id)
266
+ if last.status in _TERMINAL:
267
+ return last
268
+ time.sleep(interval_s)
269
+ raise TimeoutError(
270
+ f"Query run {query_run_id} did not finish within {timeout_s}s "
271
+ f"(last status: {getattr(last, 'status', None)})"
272
+ )
273
+
274
+ def _wait_result_ready(
275
+ self,
276
+ result_id: str,
277
+ *,
278
+ timeout_s: float = 300.0,
279
+ interval_s: float = 0.5,
280
+ ):
281
+ results = self._results_api()
282
+ deadline = time.monotonic() + timeout_s
283
+ last = None
284
+ while time.monotonic() < deadline:
285
+ last = results.get_result(result_id)
286
+ if last.status == "ready":
287
+ return last
288
+ if last.status in _RESULT_FAILURE:
289
+ raise RuntimeError(
290
+ last.error_message or f"Result {last.status}"
291
+ )
292
+ time.sleep(interval_s)
293
+ raise TimeoutError(
294
+ f"Result {result_id} not ready within {timeout_s}s "
295
+ f"(last status: {getattr(last, 'status', None)})"
296
+ )
297
+
298
+ def execute_sql(self, sql: str) -> QueryResult:
299
+ last_err: BaseException | None = None
300
+ for attempt in range(3):
301
+ try:
302
+ return self._execute_sql_once(sql)
303
+ except (ProtocolError, ConnectionResetError, Urllib3HTTPError) as e:
304
+ last_err = e
305
+ if attempt == 2:
306
+ raise
307
+ time.sleep(0.2 * (2**attempt))
308
+ raise last_err # pragma: no cover
309
+
310
+ def _execute_sql_once(self, sql: str) -> QueryResult:
311
+ q = self._query_api()
312
+ try:
313
+ raw = q.query(QueryRequest(sql=sql))
314
+ except ApiException as e:
315
+ raise RuntimeError(e.reason or str(e)) from e
316
+
317
+ if isinstance(raw, AsyncQueryResponse):
318
+ run = self._poll_query_run(raw.query_run_id)
319
+ if run.status != "succeeded":
320
+ raise RuntimeError(
321
+ run.error_message or f"Query failed ({run.status})"
322
+ )
323
+ if run.result_id:
324
+ persisted = self._wait_result_ready(run.result_id)
325
+ return QueryResult.from_get_result(persisted)
326
+ raise RuntimeError("Query succeeded but no result_id was returned.")
327
+
328
+ if isinstance(raw, QueryResponse):
329
+ return QueryResult.from_query_response(raw)
330
+
331
+ raise RuntimeError(f"Unexpected query response type: {type(raw)!r}")
332
+
333
+ def get_result(self, result_id: str) -> QueryResult:
334
+ r = self._results_api().get_result(result_id)
335
+ if r.status != "ready":
336
+ r = self._wait_result_ready(result_id)
337
+ return QueryResult.from_get_result(r)
338
+
339
+
340
+ def from_env() -> HotdataClient:
341
+ return HotdataClient.from_env()
hotdata_runtime/env.py ADDED
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from urllib.parse import urlparse
6
+
7
+ from hotdata import ApiClient, Configuration
8
+ from hotdata.api.workspaces_api import WorkspacesApi
9
+
10
+
11
+ def normalize_host(url: str) -> str:
12
+ u = url.rstrip("/")
13
+ if u.endswith("/v1"):
14
+ u = u[:-3]
15
+ parsed = urlparse(u)
16
+ if not parsed.scheme or not parsed.netloc:
17
+ return u
18
+ return f"{parsed.scheme}://{parsed.netloc}"
19
+
20
+
21
+ def default_api_key() -> str:
22
+ return os.environ.get("HOTDATA_API_KEY", "")
23
+
24
+
25
+ def explicit_workspace_id() -> str | None:
26
+ return os.environ.get("HOTDATA_WORKSPACE")
27
+
28
+
29
+ def default_host() -> str:
30
+ raw = os.environ.get("HOTDATA_API_URL", "https://api.hotdata.dev")
31
+ return normalize_host(raw)
32
+
33
+
34
+ def default_session_id() -> str | None:
35
+ return os.environ.get("HOTDATA_SANDBOX")
36
+
37
+
38
+ def list_workspaces(api_key: str, host: str, session_id: str | None):
39
+ cfg = Configuration(
40
+ host=host,
41
+ api_key=api_key,
42
+ workspace_id=None,
43
+ session_id=session_id,
44
+ )
45
+ with ApiClient(cfg) as api:
46
+ listing = WorkspacesApi(api).list_workspaces()
47
+ return listing.workspaces
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class WorkspaceSelection:
52
+ workspace_id: str
53
+ source: str
54
+ workspaces: list
55
+
56
+
57
+ def resolve_workspace_selection(
58
+ api_key: str, host: str, session_id: str | None
59
+ ) -> WorkspaceSelection:
60
+ explicit = explicit_workspace_id()
61
+ if explicit:
62
+ return WorkspaceSelection(
63
+ workspace_id=explicit,
64
+ source="explicit_env",
65
+ workspaces=[],
66
+ )
67
+ workspaces = list_workspaces(api_key, host, session_id)
68
+ if not workspaces:
69
+ raise RuntimeError("No Hotdata workspaces found for this API key.")
70
+ active = [w for w in workspaces if w.active]
71
+ chosen = active[0] if active else workspaces[0]
72
+ return WorkspaceSelection(
73
+ workspace_id=chosen.public_id,
74
+ source="active" if active else "first",
75
+ workspaces=workspaces,
76
+ )
77
+
78
+
79
+ def pick_workspace(api_key: str, host: str, session_id: str | None) -> str:
80
+ selection = resolve_workspace_selection(api_key, host, session_id)
81
+ return selection.workspace_id
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from hotdata.exceptions import ApiException
4
+
5
+ from hotdata_runtime.client import HotdataClient
6
+
7
+
8
+ def workspace_health_lines(client: HotdataClient) -> tuple[bool, list[str]]:
9
+ """Return ``(ok, parts)`` where ``parts`` are short markdown fragments.
10
+
11
+ On failure, ``ok`` is False and ``parts`` is a single-element list with the error text.
12
+ """
13
+ try:
14
+ listing = client.connections().list_connections()
15
+ n = len(listing.connections)
16
+ lines = [
17
+ "**API** reachable",
18
+ f"**workspace** `{client.workspace_id}`",
19
+ f"**connections** {n}",
20
+ ]
21
+ if client.session_id:
22
+ lines.append(f"**sandbox** `{client.session_id}`")
23
+ return True, lines
24
+ except ApiException as e:
25
+ return False, [e.reason or str(e)]
26
+ except Exception as e:
27
+ return False, [str(e)]
@@ -0,0 +1,19 @@
1
+ """HTTP client defaults for Hotdata SDK :class:`~hotdata.Configuration`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib3.util.retry import Retry
6
+
7
+
8
+ def default_http_retries() -> Retry:
9
+ """Retry transient connection failures (e.g. stale pooled sockets)."""
10
+ return Retry(
11
+ total=3,
12
+ connect=3,
13
+ read=3,
14
+ backoff_factor=0.2,
15
+ status_forcelist=(502, 503, 504),
16
+ allowed_methods=frozenset(
17
+ ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"]
18
+ ),
19
+ )
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ from hotdata.models.get_result_response import GetResultResponse
7
+ from hotdata.models.query_response import QueryResponse
8
+
9
+
10
+ @dataclass
11
+ class QueryResult:
12
+ """Tabular result from a Hotdata query or stored result id."""
13
+
14
+ columns: list[str]
15
+ rows: list[list[Any]]
16
+ row_count: int
17
+ result_id: str | None
18
+ query_run_id: str | None
19
+ execution_time_ms: int | None
20
+ warning: str | None = None
21
+ error_message: str | None = None
22
+
23
+ def to_records(
24
+ self,
25
+ *,
26
+ max_rows: int | None = None,
27
+ ) -> list[dict[str, Any]]:
28
+ rows = self.rows if max_rows is None else self.rows[:max_rows]
29
+ return [dict(zip(self.columns, row)) for row in rows]
30
+
31
+ def metadata_dict(self) -> dict[str, Any]:
32
+ return {
33
+ "row_count": self.row_count,
34
+ "column_count": len(self.columns),
35
+ "result_id": self.result_id,
36
+ "query_run_id": self.query_run_id,
37
+ "execution_time_ms": self.execution_time_ms,
38
+ "warning": self.warning,
39
+ "error_message": self.error_message,
40
+ }
41
+
42
+ def to_pandas(self): # type: ignore[no-untyped-def]
43
+ import pandas as pd
44
+
45
+ if not self.columns:
46
+ return pd.DataFrame()
47
+ return pd.DataFrame(self.rows, columns=self.columns)
48
+
49
+ @classmethod
50
+ def from_query_response(cls, r: QueryResponse) -> QueryResult:
51
+ return cls(
52
+ columns=list(r.columns),
53
+ rows=[list(row) for row in r.rows],
54
+ row_count=r.row_count,
55
+ result_id=r.result_id,
56
+ query_run_id=r.query_run_id,
57
+ execution_time_ms=r.execution_time_ms,
58
+ warning=r.warning,
59
+ error_message=None,
60
+ )
61
+
62
+ @classmethod
63
+ def from_get_result(cls, r: GetResultResponse) -> QueryResult:
64
+ cols = list(r.columns or [])
65
+ row_data = [list(row) for row in (r.rows or [])]
66
+ return cls(
67
+ columns=cols,
68
+ rows=row_data,
69
+ row_count=r.row_count or 0,
70
+ result_id=r.result_id,
71
+ query_run_id=None,
72
+ execution_time_ms=None,
73
+ warning=None,
74
+ error_message=r.error_message,
75
+ )
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: hotdata-runtime
3
+ Version: 0.1.0
4
+ Summary: Workspace/session runtime primitives for Hotdata integrations
5
+ License: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: hotdata>=0.1.0
8
+ Requires-Dist: pandas>=2.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # hotdata-runtime
12
+
13
+ Shared runtime primitives for Hotdata integrations: workspace/session semantics, execution context, query state, run history, and replayable result handles. Framework packages (Marimo, Jupyter, Streamlit, LangGraph) depend on this package.
14
+
15
+ Runtime boundary and guarantees are defined in `CONTRACT.md`.
16
+
17
+ ## Features
18
+
19
+ - **Environment-driven client setup** — create clients from `HOTDATA_API_KEY`, optional `HOTDATA_API_URL`, `HOTDATA_WORKSPACE`, and `HOTDATA_SANDBOX`.
20
+ - **Workspace resolution** — choose an explicit workspace from env, otherwise discover workspaces and select the active workspace or first available workspace.
21
+ - **Sandbox/session propagation** — pass sandbox session context through the SDK via `X-Session-Id`.
22
+ - **HTTP resilience** — configure SDK retries for transient connection failures and retry SQL execution on stale pooled sockets.
23
+ - **SQL execution helper** — run SQL through `POST /v1/query`, poll async query runs when needed, and return a `QueryResult`.
24
+ - **Result utilities** — convert query results to records, pandas DataFrames, or metadata dictionaries for adapter display layers.
25
+ - **History helpers** — list recent results and query run history with normalized dataclasses.
26
+ - **Health helpers** — build compact API/workspace health summaries for UI integrations.
27
+
28
+ Install:
29
+
30
+ ```bash
31
+ uv pip install hotdata-runtime
32
+ # or: pip install hotdata-runtime
33
+ ```
34
+
35
+ Example:
36
+
37
+ ```bash
38
+ python examples/basic_usage.py
39
+ ```
40
+
41
+ Development (uses **uv**; creates `.venv/` in this repo):
42
+
43
+ ```bash
44
+ uv sync --locked
45
+ uv run pytest
46
+ ```
47
+
48
+ `uv.lock` is checked in so CI can run `uv sync --locked`. The default **dev** group (pytest) is enabled via `[tool.uv] default-groups`.
@@ -0,0 +1,9 @@
1
+ hotdata_runtime/__init__.py,sha256=fq8XUN9mbHEC78y2a361m0-8EqTdxF9nhpm0d_ylZEA,1094
2
+ hotdata_runtime/client.py,sha256=j3TXMJScBEkuIjWTqe-q_v7w5o5l0Lyvw71-sfHclhs,10611
3
+ hotdata_runtime/env.py,sha256=1gm56sQhJ2rdEtfvAzfXc0P44IodLLmSP15Uax_WnoM,2190
4
+ hotdata_runtime/health.py,sha256=37Gg_R8dHxSssiiLvJCWnW81ND6-IDb3xGsbsXbYdAc,892
5
+ hotdata_runtime/http.py,sha256=9UC4Rbw8-IEQ2sQ_MUTnOSM4KLPj3QKoFEXWUmtjMnE,529
6
+ hotdata_runtime/result.py,sha256=qs2EkdoxYG1H6sLn5gqDFLstBznwBEPkI0FwCeu4f-E,2290
7
+ hotdata_runtime-0.1.0.dist-info/METADATA,sha256=LocMOVRUWbyYwLxyU75RxnnUowU4MlWiAGYv8hj3xLc,1981
8
+ hotdata_runtime-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
9
+ hotdata_runtime-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any