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 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"