renovara-sql 0.2.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,119 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: renovara-sql
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python client for the Renovara SQL API: DuckDB SQL over Australian NEM and east coast gas market data from AEMO
|
|
5
|
+
Project-URL: Homepage, https://renovara.co/sql-api
|
|
6
|
+
Project-URL: Documentation, https://renovara.co/how-renovara-sql-works
|
|
7
|
+
Project-URL: API description, https://sql.api.renovara.co/v1/openapi.json
|
|
8
|
+
Author-email: "Scoptico Pty Ltd (Renovara)" <info@renovara.co>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: aemo,australia,duckdb,electricity,energy,gas,nem,sql
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Provides-Extra: pandas
|
|
18
|
+
Requires-Dist: pandas; extra == 'pandas'
|
|
19
|
+
Requires-Dist: pyarrow; extra == 'pandas'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# Renovara SQL API — Python client
|
|
23
|
+
|
|
24
|
+
Read-only SQL over Australian National Electricity Market (NEM) and east
|
|
25
|
+
coast gas market data from AEMO, from your own code. The SQL is DuckDB's.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install renovara-sql
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## See what the data is — no credentials needed
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from renovara_sql import Client
|
|
35
|
+
|
|
36
|
+
public = Client("https://sql.api.renovara.co/v1")
|
|
37
|
+
print(public.guide()[:2000]) # which table answers what, and the traps
|
|
38
|
+
for t in public.tables(): # every table, one line each
|
|
39
|
+
print(t["table"], "-", t["description"][:80])
|
|
40
|
+
print(public.table("silver_dispatchis_reports_dispatch_price")["columns"][:5])
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Credentials
|
|
44
|
+
|
|
45
|
+
Your Renovara credentials page gives you a file:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"api_url": "https://sql.api.renovara.co/v1/",
|
|
50
|
+
"token_url": "https://auth.api.renovara.co/oauth2/token",
|
|
51
|
+
"client_id": "…",
|
|
52
|
+
"client_secret": "…",
|
|
53
|
+
"scope": "renovara-sql/query"
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Keep it private: anyone holding it can query as your organisation. It is an
|
|
58
|
+
OAuth client-credentials pair, exchanged for an access token that lasts an
|
|
59
|
+
hour; the client keeps the token for the hour and renews it for you.
|
|
60
|
+
|
|
61
|
+
## Run queries
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from renovara_sql import Client
|
|
65
|
+
|
|
66
|
+
client = Client.from_file("~/.config/renovara-sql/acme.json")
|
|
67
|
+
result = client.query("""
|
|
68
|
+
SELECT REGIONID, round(avg(RRP), 2) AS avg_price
|
|
69
|
+
FROM silver_dispatchis_reports_dispatch_price
|
|
70
|
+
WHERE SETTLEMENTDATE >= current_timestamp + INTERVAL 10 HOUR - INTERVAL 7 DAY
|
|
71
|
+
AND INTERVENTION = 0
|
|
72
|
+
GROUP BY 1 ORDER BY 1
|
|
73
|
+
""")
|
|
74
|
+
print(result.column_names, result.rows)
|
|
75
|
+
print(result.usage) # your plan, query time used, statements today
|
|
76
|
+
frame = result.to_pandas() # needs pandas; pyarrow for exact types
|
|
77
|
+
result.download("prices.parquet") # the full result, any size
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`python quickstart.py <credentials file>` runs a short tour.
|
|
81
|
+
|
|
82
|
+
## What to know
|
|
83
|
+
|
|
84
|
+
- **DuckDB SQL**, one `SELECT` per request (or `EXPLAIN SELECT …` for the
|
|
85
|
+
plan). `client.guide()` lists the differences from Databricks SQL. Tables
|
|
86
|
+
answer to their bare names or to `external_data.nemweb.<table>`.
|
|
87
|
+
- **Times**: `SETTLEMENTDATE` and the other NEM timestamps are AEST market
|
|
88
|
+
time (UTC+10, no daylight saving) already. Don't shift them. "Now" in market
|
|
89
|
+
time is `current_timestamp + INTERVAL 10 HOUR`.
|
|
90
|
+
- **Long queries**: `query()` returns when the query finishes, polling if the
|
|
91
|
+
server needs more than ~20 s. A statement stops at 300 s. For control, use
|
|
92
|
+
`submit(sql)`, `status(statement_id)` and `cancel(statement_id)`.
|
|
93
|
+
- **Results**: up to 10,000 rows come back inline as `result.rows`; every
|
|
94
|
+
result is also a Parquet file at `result.parquet_url` for an hour. Over
|
|
95
|
+
5,000,000 rows a statement is refused: aggregate in SQL. Statements and
|
|
96
|
+
results last 24 hours.
|
|
97
|
+
- **Retries are safe**: `query()` sends an idempotency key, so a retry never
|
|
98
|
+
runs a statement twice.
|
|
99
|
+
- **Errors** raise `QueryFailed` (a statement that ran and failed: `code` is
|
|
100
|
+
`QUERY_REJECTED`, `SQL_ERROR`, `QUERY_TIMEOUT`, `RESULT_TOO_LARGE` or
|
|
101
|
+
`CANCELLED`) or `RenovaraError` (a refused request, e.g. `DAILY_LIMIT`,
|
|
102
|
+
`ALLOWANCE_USED`). Each has a `docs_url`.
|
|
103
|
+
|
|
104
|
+
## Without the client
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" -d grant_type=client_credentials \
|
|
108
|
+
-d scope=renovara-sql/query "$TOKEN_URL" | jq -r .access_token)
|
|
109
|
+
# Keep $TOKEN for its hour: each one issued is billed, so not one per query.
|
|
110
|
+
|
|
111
|
+
curl -s -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
112
|
+
-H "Idempotency-Key: $(uuidgen)" \
|
|
113
|
+
-d '{"sql": "SELECT count(*) FROM silver_dispatchis_reports_dispatch_price", "wait_seconds": 20}' \
|
|
114
|
+
"${API_URL}statements"
|
|
115
|
+
# -> {"statement_id": "…", "state": "SUCCEEDED", "result": {"columns": …, "rows": …}}
|
|
116
|
+
# or "state": "RUNNING": then GET ${API_URL}statements/<statement_id> until it isn't
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The API describes itself: `GET ${API_URL}` and `GET ${API_URL}openapi.json`.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
renovara_sql.py,sha256=sZ3BKDzGRwQGcH9I6V6AsSmG8lSUt5FeEwSm_77YVl8,13248
|
|
2
|
+
renovara_sql-0.2.0.dist-info/METADATA,sha256=LePR-Ta4edL0LfT-wRMb6USCeh3fjb7HGUQA3ijNOcM,4803
|
|
3
|
+
renovara_sql-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
4
|
+
renovara_sql-0.2.0.dist-info/licenses/LICENSE,sha256=yYZwktV_ENrfr7x1YCg_UG9Jot3rBXmctGS4j4v5e8w,1095
|
|
5
|
+
renovara_sql-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Scoptico Pty Ltd (trading as Renovara)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
renovara_sql.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Python client for the Renovara SQL API.
|
|
2
|
+
|
|
3
|
+
One file, standard library only. pandas (and pyarrow, for exact column types)
|
|
4
|
+
are needed only for Result.to_pandas().
|
|
5
|
+
|
|
6
|
+
from renovara_sql import Client
|
|
7
|
+
|
|
8
|
+
client = Client.from_file("~/.config/renovara-sql/acme.json")
|
|
9
|
+
result = client.query("SELECT REGIONID, avg(RRP) FROM silver_dispatchis_reports_dispatch_price "
|
|
10
|
+
"WHERE SETTLEMENTDATE >= current_timestamp + INTERVAL 10 HOUR - INTERVAL 7 DAY "
|
|
11
|
+
"GROUP BY 1")
|
|
12
|
+
print(result.rows)
|
|
13
|
+
|
|
14
|
+
Queries are DuckDB SQL, not Databricks SQL: read client.guide() first, which
|
|
15
|
+
lists the differences. NEM timestamps (SETTLEMENTDATE and friends) are AEST
|
|
16
|
+
market time already; the server's session zone is UTC so they are shown and
|
|
17
|
+
compared unshifted.
|
|
18
|
+
|
|
19
|
+
The guide and the tables' descriptions need no credentials:
|
|
20
|
+
|
|
21
|
+
Client("https://sql.api.renovara.co/v1").table("silver_dispatchis_reports_dispatch_price")
|
|
22
|
+
|
|
23
|
+
How a query runs: query() submits it and the server waits up to 20 s. Most
|
|
24
|
+
queries finish inside that and come back in one call. A longer one comes back
|
|
25
|
+
with a statement ID, and query() polls until it finishes. Use submit(),
|
|
26
|
+
status() and cancel() to do that yourself.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import base64
|
|
31
|
+
import io
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import time
|
|
35
|
+
import urllib.error
|
|
36
|
+
import urllib.parse
|
|
37
|
+
import urllib.request
|
|
38
|
+
import uuid
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
__version__ = "0.2.0"
|
|
43
|
+
|
|
44
|
+
TERMINAL = {"SUCCEEDED", "FAILED"}
|
|
45
|
+
# Retry-After longer than this is a limit to report, not a pause to sit out.
|
|
46
|
+
MAX_RETRY_WAIT_S = 60
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RenovaraError(Exception):
|
|
50
|
+
"""A request the API refused, with its error code, message and docs link."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, status: int, code: str, message: str, docs_url: str | None = None,
|
|
53
|
+
retry_after: float | None = None):
|
|
54
|
+
super().__init__(f"{code}: {message}" if code else message)
|
|
55
|
+
self.status, self.code, self.message = status, code, message
|
|
56
|
+
self.docs_url, self.retry_after = docs_url, retry_after
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class QueryFailed(RenovaraError):
|
|
60
|
+
"""The query ran and failed: rejected, a SQL error, timed out or cancelled."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, statement_id: str, code: str, message: str, docs_url: str | None = None):
|
|
63
|
+
super().__init__(200, code, message, docs_url)
|
|
64
|
+
self.statement_id = statement_id
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Result:
|
|
69
|
+
statement_id: str
|
|
70
|
+
columns: list[dict]
|
|
71
|
+
row_count: int
|
|
72
|
+
parquet_url: str
|
|
73
|
+
rows: list[list] | None = None
|
|
74
|
+
query_ms: int | None = None
|
|
75
|
+
usage: dict | None = None
|
|
76
|
+
raw: dict = field(default_factory=dict, repr=False)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def column_names(self) -> list[str]:
|
|
80
|
+
return [c["name"] for c in self.columns]
|
|
81
|
+
|
|
82
|
+
def download(self, path: str | os.PathLike) -> Path:
|
|
83
|
+
"""Save the full result as Parquet. The link works for an hour."""
|
|
84
|
+
path = Path(path)
|
|
85
|
+
with urllib.request.urlopen(self.parquet_url, timeout=300) as response:
|
|
86
|
+
path.write_bytes(response.read())
|
|
87
|
+
return path
|
|
88
|
+
|
|
89
|
+
def to_pandas(self):
|
|
90
|
+
"""The result as a DataFrame.
|
|
91
|
+
|
|
92
|
+
With pyarrow installed this reads the Parquet file, keeping exact
|
|
93
|
+
types. Without it, it builds the frame from the inline rows and
|
|
94
|
+
parses timestamp columns.
|
|
95
|
+
"""
|
|
96
|
+
import pandas as pd
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
import pyarrow # noqa: F401
|
|
100
|
+
except ImportError:
|
|
101
|
+
if self.rows is None:
|
|
102
|
+
raise RuntimeError(
|
|
103
|
+
f"This result has {self.row_count:,} rows, too many to return inline. "
|
|
104
|
+
"Install pyarrow to read its Parquet file.") from None
|
|
105
|
+
frame = pd.DataFrame(self.rows, columns=self.column_names)
|
|
106
|
+
for column in self.columns:
|
|
107
|
+
if column["type"].startswith(("TIMESTAMP", "DATE")):
|
|
108
|
+
frame[column["name"]] = pd.to_datetime(frame[column["name"]])
|
|
109
|
+
return frame
|
|
110
|
+
with urllib.request.urlopen(self.parquet_url, timeout=300) as response:
|
|
111
|
+
return pd.read_parquet(io.BytesIO(response.read()))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Client:
|
|
115
|
+
def __init__(self, api_url: str, token_url: str | None = None, client_id: str | None = None,
|
|
116
|
+
client_secret: str | None = None, scope: str = "renovara-sql/query",
|
|
117
|
+
timeout: float = 30):
|
|
118
|
+
self.api_url = api_url.rstrip("/") + "/"
|
|
119
|
+
self.token_url = token_url
|
|
120
|
+
self.client_id = client_id
|
|
121
|
+
self._client_secret = client_secret
|
|
122
|
+
self.scope = scope
|
|
123
|
+
self.timeout = timeout
|
|
124
|
+
self._token: str | None = None
|
|
125
|
+
self._token_expires = 0.0
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def from_file(cls, path: str | os.PathLike) -> "Client":
|
|
129
|
+
"""Read the credentials file from your Renovara credentials page."""
|
|
130
|
+
data = json.loads(Path(path).expanduser().read_text())
|
|
131
|
+
return cls(data["api_url"], data["token_url"], data["client_id"],
|
|
132
|
+
data["client_secret"], data.get("scope", "renovara-sql/query"))
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def from_env(cls) -> "Client":
|
|
136
|
+
"""RENOVARA_SQL_API_URL, _TOKEN_URL, _CLIENT_ID and _CLIENT_SECRET."""
|
|
137
|
+
e = os.environ
|
|
138
|
+
return cls(e["RENOVARA_SQL_API_URL"], e["RENOVARA_SQL_TOKEN_URL"],
|
|
139
|
+
e["RENOVARA_SQL_CLIENT_ID"], e["RENOVARA_SQL_CLIENT_SECRET"])
|
|
140
|
+
|
|
141
|
+
# -- auth ------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
def _access_token(self) -> str:
|
|
144
|
+
"""An OAuth client-credentials token, kept for its hour.
|
|
145
|
+
|
|
146
|
+
Renewed a minute before it expires, not per request: each token
|
|
147
|
+
Renovara's sign-in service issues is billed, so a fresh one per query
|
|
148
|
+
would cost more than the query.
|
|
149
|
+
"""
|
|
150
|
+
if self._token and time.time() < self._token_expires - 60:
|
|
151
|
+
return self._token
|
|
152
|
+
if not (self.token_url and self.client_id and self._client_secret):
|
|
153
|
+
raise RenovaraError(401, "UNAUTHORIZED",
|
|
154
|
+
"Running queries needs credentials: Client.from_file(...) with "
|
|
155
|
+
"the file from your Renovara credentials page.")
|
|
156
|
+
basic = base64.b64encode(f"{self.client_id}:{self._client_secret}".encode()).decode()
|
|
157
|
+
request = urllib.request.Request(
|
|
158
|
+
self.token_url, method="POST",
|
|
159
|
+
data=urllib.parse.urlencode({"grant_type": "client_credentials",
|
|
160
|
+
"scope": self.scope}).encode(),
|
|
161
|
+
headers={"Authorization": f"Basic {basic}",
|
|
162
|
+
"Content-Type": "application/x-www-form-urlencoded"})
|
|
163
|
+
try:
|
|
164
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
165
|
+
body = json.load(response)
|
|
166
|
+
except urllib.error.HTTPError as e:
|
|
167
|
+
raise RenovaraError(e.code, "AUTH_FAILED",
|
|
168
|
+
f"Could not get an access token: {e.read().decode()[:300]}") from None
|
|
169
|
+
self._token = body["access_token"]
|
|
170
|
+
self._token_expires = time.time() + int(body.get("expires_in", 3600))
|
|
171
|
+
return self._token
|
|
172
|
+
|
|
173
|
+
# -- HTTP ------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def _call(self, method: str, path: str, body: dict | None = None, timeout: float | None = None,
|
|
176
|
+
headers: dict | None = None, auth: bool = True, text: bool = False):
|
|
177
|
+
for attempt in range(5):
|
|
178
|
+
sent = {"User-Agent": f"renovara-sql-python/{__version__}", **(headers or {})}
|
|
179
|
+
if body is not None:
|
|
180
|
+
sent["Content-Type"] = "application/json"
|
|
181
|
+
if auth:
|
|
182
|
+
sent["Authorization"] = f"Bearer {self._access_token()}"
|
|
183
|
+
request = urllib.request.Request(
|
|
184
|
+
self.api_url + path, method=method,
|
|
185
|
+
data=json.dumps(body).encode() if body is not None else None, headers=sent)
|
|
186
|
+
try:
|
|
187
|
+
with urllib.request.urlopen(request, timeout=timeout or self.timeout) as response:
|
|
188
|
+
return response.read().decode() if text else json.load(response)
|
|
189
|
+
except urllib.error.HTTPError as e:
|
|
190
|
+
raw = e.read().decode(errors="replace")
|
|
191
|
+
try:
|
|
192
|
+
error = json.loads(raw).get("error") or {}
|
|
193
|
+
except ValueError:
|
|
194
|
+
error = {}
|
|
195
|
+
error = error if isinstance(error, dict) else {}
|
|
196
|
+
retry_after = float(e.headers.get("Retry-After") or 0) or None
|
|
197
|
+
if e.code == 401 and auth and attempt == 0:
|
|
198
|
+
self._token = None # expired or revoked mid-flight: renew once
|
|
199
|
+
continue
|
|
200
|
+
retryable = e.code in (502, 503, 504) or (
|
|
201
|
+
e.code == 429 and (retry_after or 1) <= MAX_RETRY_WAIT_S)
|
|
202
|
+
if retryable and attempt < 4:
|
|
203
|
+
time.sleep(retry_after or 2 ** attempt)
|
|
204
|
+
continue
|
|
205
|
+
raise RenovaraError(e.code, error.get("code", "HTTP_ERROR"),
|
|
206
|
+
error.get("message", raw[:300] or e.reason),
|
|
207
|
+
error.get("docs_url"), retry_after) from None
|
|
208
|
+
raise RenovaraError(429, "THROTTLED", "Too many requests; try again shortly.")
|
|
209
|
+
|
|
210
|
+
# -- what the data is (no credentials needed) ----------------------------
|
|
211
|
+
|
|
212
|
+
def guide(self, domain: str = "nem") -> str:
|
|
213
|
+
"""The analyst guide (markdown): which table answers what, the traps,
|
|
214
|
+
worked SQL, and how DuckDB differs from the Databricks SQL it quotes.
|
|
215
|
+
domain is "nem" (electricity) or "gas"."""
|
|
216
|
+
return self._call("GET", f"guide?domain={urllib.parse.quote(domain)}", auth=False, text=True)
|
|
217
|
+
|
|
218
|
+
def tables(self) -> list[dict]:
|
|
219
|
+
"""Every table you can query, each with a one-line description."""
|
|
220
|
+
return self._call("GET", "tables", auth=False)["tables"]
|
|
221
|
+
|
|
222
|
+
def table(self, name: str) -> dict:
|
|
223
|
+
"""A table's columns with their meaning, primary key and measured coverage."""
|
|
224
|
+
return self._call("GET", f"tables/{urllib.parse.quote(name)}", auth=False)
|
|
225
|
+
|
|
226
|
+
def reference(self, path: str, domain: str = "nem") -> dict:
|
|
227
|
+
"""A file the guide points to, by its references/ path."""
|
|
228
|
+
query = urllib.parse.urlencode({"path": path, "domain": domain})
|
|
229
|
+
return self._call("GET", f"reference?{query}", auth=False)
|
|
230
|
+
|
|
231
|
+
# -- running SQL -----------------------------------------------------
|
|
232
|
+
|
|
233
|
+
def submit(self, sql: str, wait_seconds: float = 0, idempotency_key: str | None = None) -> dict:
|
|
234
|
+
"""Start a query. With wait_seconds > 0 (max 25) the server waits for it first.
|
|
235
|
+
|
|
236
|
+
With an idempotency_key, sending the same key again returns the first
|
|
237
|
+
statement instead of running the query twice.
|
|
238
|
+
"""
|
|
239
|
+
headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
|
|
240
|
+
return self._call("POST", "statements", {"sql": sql, "wait_seconds": wait_seconds},
|
|
241
|
+
timeout=max(self.timeout, wait_seconds + 10), headers=headers)
|
|
242
|
+
|
|
243
|
+
def status(self, statement_id: str) -> dict:
|
|
244
|
+
"""A statement's state, and its result once it has SUCCEEDED."""
|
|
245
|
+
return self._call("GET", f"statements/{statement_id}")
|
|
246
|
+
|
|
247
|
+
def cancel(self, statement_id: str) -> dict:
|
|
248
|
+
"""Stop a pending or running statement. Its state becomes FAILED, CANCELLED."""
|
|
249
|
+
return self._call("POST", f"statements/{statement_id}/cancel", {})
|
|
250
|
+
|
|
251
|
+
def query(self, sql: str, timeout: float = 900, on_progress=None) -> Result:
|
|
252
|
+
"""Run a query and return its result, polling if it takes a while.
|
|
253
|
+
|
|
254
|
+
Safe to retry: the submit carries an idempotency key, so a retry after
|
|
255
|
+
a dropped connection returns the same statement. on_progress(statement)
|
|
256
|
+
is called after each poll, if given.
|
|
257
|
+
"""
|
|
258
|
+
statement = self.submit(sql, wait_seconds=20, idempotency_key=uuid.uuid4().hex)
|
|
259
|
+
usage = statement.get("usage")
|
|
260
|
+
deadline = time.monotonic() + timeout
|
|
261
|
+
pause = 1.0
|
|
262
|
+
while statement["state"] not in TERMINAL:
|
|
263
|
+
if time.monotonic() > deadline:
|
|
264
|
+
raise TimeoutError(f"Statement {statement['statement_id']} still "
|
|
265
|
+
f"{statement['state']} after {timeout:.0f} s; "
|
|
266
|
+
"poll it later with status(), or cancel() it.")
|
|
267
|
+
time.sleep(pause)
|
|
268
|
+
pause = min(pause * 1.5, 5.0)
|
|
269
|
+
statement = self.status(statement["statement_id"])
|
|
270
|
+
if on_progress:
|
|
271
|
+
on_progress(statement)
|
|
272
|
+
return self._result(statement, usage)
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def _result(statement: dict, usage: dict | None = None) -> Result:
|
|
276
|
+
if statement["state"] == "FAILED":
|
|
277
|
+
error = statement.get("error") or {}
|
|
278
|
+
raise QueryFailed(statement["statement_id"], error.get("code", "FAILED"),
|
|
279
|
+
error.get("message", "The query failed."), error.get("docs_url"))
|
|
280
|
+
result = statement["result"]
|
|
281
|
+
return Result(statement_id=statement["statement_id"], columns=result["columns"],
|
|
282
|
+
row_count=result["row_count"], parquet_url=result["parquet_url"],
|
|
283
|
+
rows=result.get("rows"), query_ms=statement.get("query_ms"),
|
|
284
|
+
usage=usage or statement.get("usage"), raw=statement)
|