duckgresql 1.4.4.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.
- duckgresql/__init__.py +123 -0
- duckgresql/_config.py +13 -0
- duckgresql/_flight.py +152 -0
- duckgresql/_rest.py +125 -0
- duckgresql/_rest_async.py +119 -0
- duckgresql/_types.py +35 -0
- duckgresql/_version.py +1 -0
- duckgresql/async_connection.py +173 -0
- duckgresql/async_job.py +154 -0
- duckgresql/connection.py +172 -0
- duckgresql/exceptions.py +27 -0
- duckgresql/py.typed +0 -0
- duckgresql/result.py +128 -0
- duckgresql-1.4.4.0.dist-info/METADATA +160 -0
- duckgresql-1.4.4.0.dist-info/RECORD +17 -0
- duckgresql-1.4.4.0.dist-info/WHEEL +4 -0
- duckgresql-1.4.4.0.dist-info/licenses/LICENSE +21 -0
duckgresql/__init__.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""DuckGresQL Python SDK — DuckDB-compatible client for remote databases.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import duckgresql
|
|
6
|
+
|
|
7
|
+
conn = duckgresql.connect(token="dkgql_...", database="mydb")
|
|
8
|
+
result = conn.execute("SELECT * FROM users LIMIT 10")
|
|
9
|
+
print(result.fetchall())
|
|
10
|
+
conn.close()
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from duckgresql._config import (
|
|
16
|
+
DEFAULT_FLIGHT_PORT,
|
|
17
|
+
DEFAULT_HOST,
|
|
18
|
+
DEFAULT_REST_PORT,
|
|
19
|
+
DEFAULT_REST_SCHEME,
|
|
20
|
+
DEFAULT_USE_TLS,
|
|
21
|
+
)
|
|
22
|
+
from duckgresql._types import JobStatus
|
|
23
|
+
from duckgresql._version import __version__
|
|
24
|
+
from duckgresql.async_connection import DuckgresqlAsync
|
|
25
|
+
from duckgresql.async_job import AsyncJob, AsyncJobAsync
|
|
26
|
+
from duckgresql.connection import Duckgresql
|
|
27
|
+
from duckgresql.exceptions import (
|
|
28
|
+
AuthenticationError,
|
|
29
|
+
ConnectionError,
|
|
30
|
+
DuckgresqlError,
|
|
31
|
+
JobError,
|
|
32
|
+
QueryError,
|
|
33
|
+
TimeoutError,
|
|
34
|
+
)
|
|
35
|
+
from duckgresql.result import DuckgresqlResult
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def connect(
|
|
39
|
+
host: str = DEFAULT_HOST,
|
|
40
|
+
*,
|
|
41
|
+
token: str,
|
|
42
|
+
database: str,
|
|
43
|
+
port: int = DEFAULT_FLIGHT_PORT,
|
|
44
|
+
use_tls: bool = DEFAULT_USE_TLS,
|
|
45
|
+
rest_port: int = DEFAULT_REST_PORT,
|
|
46
|
+
rest_scheme: str = DEFAULT_REST_SCHEME,
|
|
47
|
+
) -> Duckgresql:
|
|
48
|
+
"""Create a synchronous connection to a DuckGresQL server.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
host : str
|
|
53
|
+
Server hostname or IP. Defaults to ``DUCKGRESQL_HOST`` env var, or
|
|
54
|
+
the value baked in at release time.
|
|
55
|
+
token : str
|
|
56
|
+
API token (``dkgql_…`` prefix).
|
|
57
|
+
database : str
|
|
58
|
+
Database name or UUID.
|
|
59
|
+
port : int
|
|
60
|
+
Arrow Flight SQL (gRPC) port. Defaults to ``DUCKGRESQL_FLIGHT_PORT``
|
|
61
|
+
env var, or the value baked in at release time.
|
|
62
|
+
use_tls : bool
|
|
63
|
+
Use TLS for Flight SQL. Defaults to ``DUCKGRESQL_USE_TLS`` env var.
|
|
64
|
+
rest_port : int
|
|
65
|
+
REST API port. Defaults to ``DUCKGRESQL_REST_PORT`` env var, or the
|
|
66
|
+
value baked in at release time.
|
|
67
|
+
rest_scheme : str
|
|
68
|
+
``"http"`` or ``"https"``. Defaults to ``DUCKGRESQL_REST_SCHEME``
|
|
69
|
+
env var.
|
|
70
|
+
"""
|
|
71
|
+
return Duckgresql(
|
|
72
|
+
host,
|
|
73
|
+
token=token,
|
|
74
|
+
database=database,
|
|
75
|
+
port=port,
|
|
76
|
+
use_tls=use_tls,
|
|
77
|
+
rest_port=rest_port,
|
|
78
|
+
rest_scheme=rest_scheme,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def connect_async(
|
|
83
|
+
host: str = DEFAULT_HOST,
|
|
84
|
+
*,
|
|
85
|
+
token: str,
|
|
86
|
+
database: str,
|
|
87
|
+
port: int = DEFAULT_FLIGHT_PORT,
|
|
88
|
+
use_tls: bool = DEFAULT_USE_TLS,
|
|
89
|
+
rest_port: int = DEFAULT_REST_PORT,
|
|
90
|
+
rest_scheme: str = DEFAULT_REST_SCHEME,
|
|
91
|
+
) -> DuckgresqlAsync:
|
|
92
|
+
"""Create an asynchronous connection to a DuckGresQL server.
|
|
93
|
+
|
|
94
|
+
Same parameters as :func:`connect`. Must be ``await``-ed.
|
|
95
|
+
"""
|
|
96
|
+
return await DuckgresqlAsync.create(
|
|
97
|
+
host,
|
|
98
|
+
token=token,
|
|
99
|
+
database=database,
|
|
100
|
+
port=port,
|
|
101
|
+
use_tls=use_tls,
|
|
102
|
+
rest_port=rest_port,
|
|
103
|
+
rest_scheme=rest_scheme,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = [
|
|
108
|
+
"__version__",
|
|
109
|
+
"connect",
|
|
110
|
+
"connect_async",
|
|
111
|
+
"Duckgresql",
|
|
112
|
+
"DuckgresqlAsync",
|
|
113
|
+
"DuckgresqlResult",
|
|
114
|
+
"AsyncJob",
|
|
115
|
+
"AsyncJobAsync",
|
|
116
|
+
"JobStatus",
|
|
117
|
+
"DuckgresqlError",
|
|
118
|
+
"ConnectionError",
|
|
119
|
+
"AuthenticationError",
|
|
120
|
+
"QueryError",
|
|
121
|
+
"JobError",
|
|
122
|
+
"TimeoutError",
|
|
123
|
+
]
|
duckgresql/_config.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Connection defaults — hardcoded at release time.
|
|
2
|
+
|
|
3
|
+
This file is auto-generated by ``scripts/inject_release_defaults.py``.
|
|
4
|
+
Do not edit manually.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
DEFAULT_HOST: str = 'api.duckgresql.io'
|
|
10
|
+
DEFAULT_FLIGHT_PORT: int = 47470
|
|
11
|
+
DEFAULT_REST_PORT: int = 443
|
|
12
|
+
DEFAULT_USE_TLS: bool = False
|
|
13
|
+
DEFAULT_REST_SCHEME: str = 'https'
|
duckgresql/_flight.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Low-level Arrow Flight SQL client for DuckGresQL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
import pyarrow as pa
|
|
8
|
+
import pyarrow.flight as flight
|
|
9
|
+
|
|
10
|
+
from duckgresql.exceptions import AuthenticationError, ConnectionError, QueryError
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Minimal protobuf encoder for Flight SQL command descriptors
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# The Arrow Flight SQL protocol requires GetFlightInfo/DoGet to receive a
|
|
16
|
+
# FlightDescriptor whose `cmd` bytes are a serialised google.protobuf.Any
|
|
17
|
+
# wrapping the appropriate command message (e.g. CommandStatementQuery).
|
|
18
|
+
# Passing raw SQL bytes causes "proto: cannot parse invalid wire-format data".
|
|
19
|
+
|
|
20
|
+
_COMMAND_TYPE_URL = (
|
|
21
|
+
"type.googleapis.com/arrow.flight.protocol.sql.CommandStatementQuery"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _varint(n: int) -> bytes:
|
|
26
|
+
result = bytearray()
|
|
27
|
+
while n > 0x7F:
|
|
28
|
+
result.append((n & 0x7F) | 0x80)
|
|
29
|
+
n >>= 7
|
|
30
|
+
result.append(n)
|
|
31
|
+
return bytes(result)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pb_string(field: int, value: str) -> bytes:
|
|
35
|
+
data = value.encode("utf-8")
|
|
36
|
+
return _varint((field << 3) | 2) + _varint(len(data)) + data
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _pb_bytes_field(field: int, value: bytes) -> bytes:
|
|
40
|
+
return _varint((field << 3) | 2) + _varint(len(value)) + value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _flight_sql_command(query: str) -> bytes:
|
|
44
|
+
"""Return bytes for google.protobuf.Any(CommandStatementQuery{query})."""
|
|
45
|
+
# CommandStatementQuery { string query = 1; }
|
|
46
|
+
cmd = _pb_string(1, query)
|
|
47
|
+
# google.protobuf.Any { string type_url = 1; bytes value = 2; }
|
|
48
|
+
return _pb_string(1, _COMMAND_TYPE_URL) + _pb_bytes_field(2, cmd)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FlightSQLClient:
|
|
52
|
+
"""Thin wrapper around :class:`pyarrow.flight.FlightClient` for Flight SQL.
|
|
53
|
+
|
|
54
|
+
Authentication uses BasicAuth where *username* is the API token and
|
|
55
|
+
*password* is the database name. The server returns a ``conn_`` bearer
|
|
56
|
+
token that is sent on every subsequent RPC.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
host: str,
|
|
62
|
+
port: int,
|
|
63
|
+
token: str,
|
|
64
|
+
database: str,
|
|
65
|
+
*,
|
|
66
|
+
use_tls: bool = False,
|
|
67
|
+
) -> None:
|
|
68
|
+
scheme = "grpc+tls" if use_tls else "grpc"
|
|
69
|
+
location = f"{scheme}://{host}:{port}"
|
|
70
|
+
try:
|
|
71
|
+
self._client = flight.FlightClient(location)
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
raise ConnectionError(f"Failed to connect to Flight SQL at {location}: {exc}") from exc
|
|
74
|
+
|
|
75
|
+
# BasicAuth handshake — username=token, password=database
|
|
76
|
+
try:
|
|
77
|
+
header_pair = self._client.authenticate_basic_token(token, database)
|
|
78
|
+
self._auth_header: tuple[bytes, bytes] = header_pair
|
|
79
|
+
except flight.FlightUnauthenticatedError as exc:
|
|
80
|
+
raise AuthenticationError(f"Flight SQL authentication failed: {exc}") from exc
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
raise ConnectionError(f"Flight SQL handshake failed: {exc}") from exc
|
|
83
|
+
|
|
84
|
+
self._closed = False
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
# Internal helpers
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def _call_options(self) -> flight.FlightCallOptions:
|
|
91
|
+
"""Build call options with the bearer token from the handshake."""
|
|
92
|
+
return flight.FlightCallOptions(headers=[self._auth_header])
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
# Public API
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
def execute_query(self, query: str) -> pa.Table:
|
|
99
|
+
"""Execute a read query and return the full result as a :class:`pyarrow.Table`."""
|
|
100
|
+
try:
|
|
101
|
+
descriptor = flight.FlightDescriptor.for_command(_flight_sql_command(query))
|
|
102
|
+
opts = self._call_options()
|
|
103
|
+
info = self._client.get_flight_info(descriptor, opts)
|
|
104
|
+
|
|
105
|
+
# Flight SQL returns one or more endpoints; read the first.
|
|
106
|
+
if not info.endpoints:
|
|
107
|
+
return pa.table({})
|
|
108
|
+
|
|
109
|
+
ticket = info.endpoints[0].ticket
|
|
110
|
+
reader = self._client.do_get(ticket, opts)
|
|
111
|
+
return reader.read_all()
|
|
112
|
+
except flight.FlightUnauthenticatedError as exc:
|
|
113
|
+
raise AuthenticationError(str(exc)) from exc
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
raise QueryError(f"Query execution failed: {exc}") from exc
|
|
116
|
+
|
|
117
|
+
def execute_update(self, query: str) -> int:
|
|
118
|
+
"""Execute a DML statement and return the number of affected rows."""
|
|
119
|
+
try:
|
|
120
|
+
opts = self._call_options()
|
|
121
|
+
# For DML we use the same descriptor path; the server decides
|
|
122
|
+
# based on the SQL whether to return rows or an update count.
|
|
123
|
+
descriptor = flight.FlightDescriptor.for_command(_flight_sql_command(query))
|
|
124
|
+
info = self._client.get_flight_info(descriptor, opts)
|
|
125
|
+
|
|
126
|
+
if not info.endpoints:
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
ticket = info.endpoints[0].ticket
|
|
130
|
+
reader = self._client.do_get(ticket, opts)
|
|
131
|
+
table = reader.read_all()
|
|
132
|
+
|
|
133
|
+
# The server returns a single-row table with the affected count
|
|
134
|
+
# when the statement is DML. If it returns regular rows, return
|
|
135
|
+
# the row count instead.
|
|
136
|
+
if table.num_columns == 1 and table.column_names[0] == "affected_rows":
|
|
137
|
+
return int(table.column(0)[0].as_py())
|
|
138
|
+
return cast(int, table.num_rows)
|
|
139
|
+
except flight.FlightUnauthenticatedError as exc:
|
|
140
|
+
raise AuthenticationError(str(exc)) from exc
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
raise QueryError(f"Update execution failed: {exc}") from exc
|
|
143
|
+
|
|
144
|
+
def close(self) -> None:
|
|
145
|
+
"""Close the underlying Flight client."""
|
|
146
|
+
if not self._closed:
|
|
147
|
+
self._client.close()
|
|
148
|
+
self._closed = True
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def closed(self) -> bool:
|
|
152
|
+
return self._closed
|
duckgresql/_rest.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Synchronous REST client for DuckGresQL async query endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from duckgresql.exceptions import AuthenticationError, ConnectionError, JobError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class RestClient:
|
|
13
|
+
"""Thin synchronous wrapper around :mod:`httpx` for DuckGresQL REST API.
|
|
14
|
+
|
|
15
|
+
Handles the ``/connect`` handshake and async-job lifecycle endpoints.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, base_url: str, *, timeout: float = 30.0) -> None:
|
|
19
|
+
self._base_url = base_url.rstrip("/")
|
|
20
|
+
self._http = httpx.Client(base_url=self._base_url, timeout=timeout)
|
|
21
|
+
self._closed = False
|
|
22
|
+
|
|
23
|
+
# ------------------------------------------------------------------
|
|
24
|
+
# Connection
|
|
25
|
+
# ------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
def connect(self, token: str, database: str) -> str:
|
|
28
|
+
"""Exchange an API token + database for a connection token.
|
|
29
|
+
|
|
30
|
+
Returns the ``conn_`` prefixed token string.
|
|
31
|
+
"""
|
|
32
|
+
try:
|
|
33
|
+
resp = self._http.post(
|
|
34
|
+
"/connect",
|
|
35
|
+
json={"database": database},
|
|
36
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
37
|
+
)
|
|
38
|
+
except httpx.HTTPError as exc:
|
|
39
|
+
raise ConnectionError(f"REST connect failed: {exc}") from exc
|
|
40
|
+
|
|
41
|
+
if resp.status_code == 401:
|
|
42
|
+
body = resp.json()
|
|
43
|
+
msg = body.get("error", {}).get("message", "Authentication failed")
|
|
44
|
+
raise AuthenticationError(msg)
|
|
45
|
+
if resp.status_code >= 400:
|
|
46
|
+
raise ConnectionError(f"REST connect returned {resp.status_code}: {resp.text}")
|
|
47
|
+
|
|
48
|
+
return cast(str, resp.json()["connection_token"])
|
|
49
|
+
|
|
50
|
+
# ------------------------------------------------------------------
|
|
51
|
+
# Async job endpoints
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def _headers(self, conn_token: str) -> dict[str, str]:
|
|
55
|
+
return {"Authorization": f"Bearer {conn_token}"}
|
|
56
|
+
|
|
57
|
+
def submit_async(
|
|
58
|
+
self,
|
|
59
|
+
conn_token: str,
|
|
60
|
+
query: str,
|
|
61
|
+
bindings: Any | None = None,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Submit an async query and return the ``job_id``."""
|
|
64
|
+
payload: dict[str, Any] = {"query": query}
|
|
65
|
+
if bindings is not None:
|
|
66
|
+
payload["bindings"] = bindings
|
|
67
|
+
|
|
68
|
+
resp = self._http.post(
|
|
69
|
+
"/query/async",
|
|
70
|
+
json=payload,
|
|
71
|
+
headers=self._headers(conn_token),
|
|
72
|
+
)
|
|
73
|
+
self._check_response(resp)
|
|
74
|
+
return cast(str, resp.json()["job_id"])
|
|
75
|
+
|
|
76
|
+
def get_job(self, conn_token: str, job_id: str) -> dict[str, Any]:
|
|
77
|
+
"""Get status/metadata for a specific job."""
|
|
78
|
+
resp = self._http.get(
|
|
79
|
+
f"/query/jobs/{job_id}",
|
|
80
|
+
headers=self._headers(conn_token),
|
|
81
|
+
)
|
|
82
|
+
self._check_response(resp)
|
|
83
|
+
return cast(dict[str, Any], resp.json())
|
|
84
|
+
|
|
85
|
+
def get_job_result(self, conn_token: str, job_id: str) -> dict[str, Any]:
|
|
86
|
+
"""Get the result rows for a completed job."""
|
|
87
|
+
resp = self._http.get(
|
|
88
|
+
f"/query/jobs/{job_id}/result",
|
|
89
|
+
headers=self._headers(conn_token),
|
|
90
|
+
)
|
|
91
|
+
self._check_response(resp)
|
|
92
|
+
return cast(dict[str, Any], resp.json())
|
|
93
|
+
|
|
94
|
+
def cancel_job(self, conn_token: str, job_id: str) -> None:
|
|
95
|
+
"""Request cancellation of a pending/running job."""
|
|
96
|
+
resp = self._http.post(
|
|
97
|
+
f"/query/jobs/{job_id}/cancel",
|
|
98
|
+
headers=self._headers(conn_token),
|
|
99
|
+
)
|
|
100
|
+
self._check_response(resp)
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# Helpers
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _check_response(resp: httpx.Response) -> None:
|
|
108
|
+
if resp.status_code == 401:
|
|
109
|
+
raise AuthenticationError("Connection token invalid or expired")
|
|
110
|
+
if resp.status_code >= 400:
|
|
111
|
+
try:
|
|
112
|
+
body = resp.json()
|
|
113
|
+
msg = body.get("error", {}).get("message", resp.text)
|
|
114
|
+
except Exception:
|
|
115
|
+
msg = resp.text
|
|
116
|
+
raise JobError(f"REST request failed ({resp.status_code}): {msg}")
|
|
117
|
+
|
|
118
|
+
def close(self) -> None:
|
|
119
|
+
if not self._closed:
|
|
120
|
+
self._http.close()
|
|
121
|
+
self._closed = True
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def closed(self) -> bool:
|
|
125
|
+
return self._closed
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Asynchronous REST client for DuckGresQL async query endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from duckgresql.exceptions import AuthenticationError, ConnectionError, JobError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AsyncRestClient:
|
|
13
|
+
"""Thin asynchronous wrapper around :mod:`httpx` for DuckGresQL REST API."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, base_url: str, *, timeout: float = 30.0) -> None:
|
|
16
|
+
self._base_url = base_url.rstrip("/")
|
|
17
|
+
self._http = httpx.AsyncClient(base_url=self._base_url, timeout=timeout)
|
|
18
|
+
self._closed = False
|
|
19
|
+
|
|
20
|
+
# ------------------------------------------------------------------
|
|
21
|
+
# Connection
|
|
22
|
+
# ------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
async def connect(self, token: str, database: str) -> str:
|
|
25
|
+
"""Exchange an API token + database for a connection token."""
|
|
26
|
+
try:
|
|
27
|
+
resp = await self._http.post(
|
|
28
|
+
"/connect",
|
|
29
|
+
json={"database": database},
|
|
30
|
+
headers={"Authorization": f"Bearer {token}"},
|
|
31
|
+
)
|
|
32
|
+
except httpx.HTTPError as exc:
|
|
33
|
+
raise ConnectionError(f"REST connect failed: {exc}") from exc
|
|
34
|
+
|
|
35
|
+
if resp.status_code == 401:
|
|
36
|
+
body = resp.json()
|
|
37
|
+
msg = body.get("error", {}).get("message", "Authentication failed")
|
|
38
|
+
raise AuthenticationError(msg)
|
|
39
|
+
if resp.status_code >= 400:
|
|
40
|
+
raise ConnectionError(f"REST connect returned {resp.status_code}: {resp.text}")
|
|
41
|
+
|
|
42
|
+
return cast(str, resp.json()["connection_token"])
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
# Async job endpoints
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def _headers(self, conn_token: str) -> dict[str, str]:
|
|
49
|
+
return {"Authorization": f"Bearer {conn_token}"}
|
|
50
|
+
|
|
51
|
+
async def submit_async(
|
|
52
|
+
self,
|
|
53
|
+
conn_token: str,
|
|
54
|
+
query: str,
|
|
55
|
+
bindings: Any | None = None,
|
|
56
|
+
) -> str:
|
|
57
|
+
"""Submit an async query and return the ``job_id``."""
|
|
58
|
+
payload: dict[str, Any] = {"query": query}
|
|
59
|
+
if bindings is not None:
|
|
60
|
+
payload["bindings"] = bindings
|
|
61
|
+
|
|
62
|
+
resp = await self._http.post(
|
|
63
|
+
"/query/async",
|
|
64
|
+
json=payload,
|
|
65
|
+
headers=self._headers(conn_token),
|
|
66
|
+
)
|
|
67
|
+
self._check_response(resp)
|
|
68
|
+
return cast(str, resp.json()["job_id"])
|
|
69
|
+
|
|
70
|
+
async def get_job(self, conn_token: str, job_id: str) -> dict[str, Any]:
|
|
71
|
+
"""Get status/metadata for a specific job."""
|
|
72
|
+
resp = await self._http.get(
|
|
73
|
+
f"/query/jobs/{job_id}",
|
|
74
|
+
headers=self._headers(conn_token),
|
|
75
|
+
)
|
|
76
|
+
self._check_response(resp)
|
|
77
|
+
return cast(dict[str, Any], resp.json())
|
|
78
|
+
|
|
79
|
+
async def get_job_result(self, conn_token: str, job_id: str) -> dict[str, Any]:
|
|
80
|
+
"""Get the result rows for a completed job."""
|
|
81
|
+
resp = await self._http.get(
|
|
82
|
+
f"/query/jobs/{job_id}/result",
|
|
83
|
+
headers=self._headers(conn_token),
|
|
84
|
+
)
|
|
85
|
+
self._check_response(resp)
|
|
86
|
+
return cast(dict[str, Any], resp.json())
|
|
87
|
+
|
|
88
|
+
async def cancel_job(self, conn_token: str, job_id: str) -> None:
|
|
89
|
+
"""Request cancellation of a pending/running job."""
|
|
90
|
+
resp = await self._http.post(
|
|
91
|
+
f"/query/jobs/{job_id}/cancel",
|
|
92
|
+
headers=self._headers(conn_token),
|
|
93
|
+
)
|
|
94
|
+
self._check_response(resp)
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# Helpers
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _check_response(resp: httpx.Response) -> None:
|
|
102
|
+
if resp.status_code == 401:
|
|
103
|
+
raise AuthenticationError("Connection token invalid or expired")
|
|
104
|
+
if resp.status_code >= 400:
|
|
105
|
+
try:
|
|
106
|
+
body = resp.json()
|
|
107
|
+
msg = body.get("error", {}).get("message", resp.text)
|
|
108
|
+
except Exception:
|
|
109
|
+
msg = resp.text
|
|
110
|
+
raise JobError(f"REST request failed ({resp.status_code}): {msg}")
|
|
111
|
+
|
|
112
|
+
async def close(self) -> None:
|
|
113
|
+
if not self._closed:
|
|
114
|
+
await self._http.aclose()
|
|
115
|
+
self._closed = True
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def closed(self) -> bool:
|
|
119
|
+
return self._closed
|
duckgresql/_types.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Internal types and constants for the DuckGresQL Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JobStatus(enum.Enum):
|
|
9
|
+
"""Status of an async query job."""
|
|
10
|
+
|
|
11
|
+
PENDING = "pending"
|
|
12
|
+
RUNNING = "running"
|
|
13
|
+
COMPLETED = "completed"
|
|
14
|
+
FAILED = "failed"
|
|
15
|
+
CANCELLED = "cancelled"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# SQL prefixes that indicate a read query (returns rows).
|
|
19
|
+
_READ_PREFIXES = frozenset({
|
|
20
|
+
"SELECT",
|
|
21
|
+
"WITH",
|
|
22
|
+
"EXPLAIN",
|
|
23
|
+
"SHOW",
|
|
24
|
+
"DESCRIBE",
|
|
25
|
+
"PRAGMA",
|
|
26
|
+
"TABLE",
|
|
27
|
+
"FROM",
|
|
28
|
+
"VALUES",
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_read_query(sql: str) -> bool:
|
|
33
|
+
"""Return True if *sql* looks like a read query (SELECT, etc.)."""
|
|
34
|
+
first_word = sql.lstrip().split(None, 1)[0].upper() if sql.strip() else ""
|
|
35
|
+
return first_word in _READ_PREFIXES
|
duckgresql/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.4.4.0"
|