opteryx-sqlalchemy 0.0.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,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: opteryx-sqlalchemy
3
+ Version: 0.0.0
4
+ Summary: Opteryx SQLAlchemy Dialect
5
+ Requires-Python: >=3.11
6
+ License-File: LICENSE
7
+ Requires-Dist: brotli-asgi
8
+ Requires-Dist: python-jose[cryptography]==3.3.0
9
+ Requires-Dist: cryptography>=40.0.0
10
+ Requires-Dist: requests>=2.30.0
11
+ Requires-Dist: python-multipart>=0.0.6
12
+ Requires-Dist: sqlalchemy>=2.0.0
13
+ Dynamic: license-file
@@ -0,0 +1,9 @@
1
+ opteryx_sqlalchemy-0.0.0.dist-info/licenses/LICENSE,sha256=m8Z1IUiQRdgLd9_9LtMRjZSStEy0pZXJqy_2KTv14r4,11341
2
+ sqlalchemy_dialect/__init__.py,sha256=5OAksQuB-yEkNv9DQO3Qet149Spdj5Fb5rAkJereeXw,491
3
+ sqlalchemy_dialect/dbapi.py,sha256=LXBSHG1h0nBZEdqZm593hc7fbfzu0_OIUdFkCn85h4M,22683
4
+ sqlalchemy_dialect/dialect.py,sha256=pFE_6OrwurpQLR-BtF40bYqoc1dzQEEho_oSyodLApc,10573
5
+ opteryx_sqlalchemy-0.0.0.dist-info/METADATA,sha256=QHwEv2t3xLv3lPmYE5hutvlxbznUyD71_isLaknvNGk,381
6
+ opteryx_sqlalchemy-0.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
+ opteryx_sqlalchemy-0.0.0.dist-info/entry_points.txt,sha256=bli1cTeTUJrjjOE1a5Frpz2WHJsn8k4sOOC7yjvIPPo,74
8
+ opteryx_sqlalchemy-0.0.0.dist-info/top_level.txt,sha256=MXcCVk91HI49RJztG1b06V8eU0kEFraCdIxvtJb1cTQ,19
9
+ opteryx_sqlalchemy-0.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [sqlalchemy.dialects]
2
+ opteryx = sqlalchemy_dialect.dialect:OptetyxDialect
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2025 Justin Joyce
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1 @@
1
+ sqlalchemy_dialect
@@ -0,0 +1,18 @@
1
+ """SQLAlchemy dialect for Opteryx data service.
2
+
3
+ This module provides a SQLAlchemy dialect that connects to the Opteryx
4
+ data service via its HTTP API, enabling use of SQLAlchemy's ORM and
5
+ SQL expression language with Opteryx.
6
+
7
+ Usage:
8
+ from sqlalchemy import create_engine
9
+
10
+ # Connect to Opteryx data service
11
+ engine = create_engine(
12
+ "opteryx://user:password@data.opteryx.app:443/default?ssl=true"
13
+ )
14
+ """
15
+
16
+ from .dialect import OptetyxDialect
17
+
18
+ __all__ = ["OptetyxDialect"]
@@ -0,0 +1,651 @@
1
+ """DBAPI 2.0 (PEP 249) compliant interface for Opteryx data service.
2
+
3
+ This module implements a minimal DBAPI 2.0 interface that communicates
4
+ with the Opteryx data service via HTTP. It provides Connection and Cursor
5
+ classes that translate SQL queries into HTTP requests.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ from typing import Any
13
+ from typing import Dict
14
+ from typing import List
15
+ from typing import Optional
16
+ from typing import Sequence
17
+ from typing import Tuple
18
+ from typing import Union
19
+ from urllib.parse import urljoin
20
+
21
+ import requests
22
+
23
+ # Module globals required by PEP 249
24
+ apilevel = "2.0"
25
+ threadsafety = 1 # Threads may share the module, but not connections
26
+ paramstyle = "named" # Named style: WHERE name=:name
27
+
28
+
29
+ class Error(Exception):
30
+ """Base exception for DBAPI errors."""
31
+
32
+
33
+ class Warning(Exception): # noqa: A001
34
+ """Warning exception."""
35
+
36
+
37
+ class InterfaceError(Error):
38
+ """Exception for interface errors."""
39
+
40
+
41
+ class DatabaseError(Error):
42
+ """Exception for database errors."""
43
+
44
+
45
+ class DataError(DatabaseError):
46
+ """Exception for data errors."""
47
+
48
+
49
+ class OperationalError(DatabaseError):
50
+ """Exception for operational errors."""
51
+
52
+
53
+ class IntegrityError(DatabaseError):
54
+ """Exception for integrity constraint errors."""
55
+
56
+
57
+ class InternalError(DatabaseError):
58
+ """Exception for internal errors."""
59
+
60
+
61
+ class ProgrammingError(DatabaseError):
62
+ """Exception for programming errors."""
63
+
64
+
65
+ class NotSupportedError(DatabaseError):
66
+ """Exception for not supported operations."""
67
+
68
+
69
+ # Type constructors (required by PEP 249)
70
+ def Date(year: int, month: int, day: int) -> str:
71
+ """Construct a date value."""
72
+ return f"{year:04d}-{month:02d}-{day:02d}"
73
+
74
+
75
+ def Time(hour: int, minute: int, second: int) -> str:
76
+ """Construct a time value."""
77
+ return f"{hour:02d}:{minute:02d}:{second:02d}"
78
+
79
+
80
+ def Timestamp(year: int, month: int, day: int, hour: int, minute: int, second: int) -> str:
81
+ """Construct a timestamp value."""
82
+ return f"{year:04d}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}"
83
+
84
+
85
+ def Binary(string: bytes) -> bytes:
86
+ """Construct a binary value."""
87
+ return string
88
+
89
+
90
+ STRING = str
91
+ BINARY = bytes
92
+ NUMBER = float
93
+ DATETIME = str
94
+ ROWID = str
95
+
96
+
97
+ class Cursor:
98
+ """DBAPI 2.0 Cursor implementation for Opteryx."""
99
+
100
+ def __init__(self, connection: "Connection") -> None:
101
+ self._connection = connection
102
+ self._jwt_token: Optional[str] = None
103
+ self._description: Optional[
104
+ List[Tuple[str, Any, None, None, None, None, Optional[bool]]]
105
+ ] = None
106
+ self._rowcount = -1
107
+ self._rows: List[Tuple[Any, ...]] = []
108
+ self._row_index = 0
109
+ self._arraysize = 1
110
+ self._closed = False
111
+ self._statement_handle: Optional[str] = None
112
+
113
+ # Try to authenticate using client credentials (client credentials flow)
114
+ # client_id is connection._username and client_secret is connection._token
115
+ try:
116
+ username = getattr(self._connection, "_username", None)
117
+ secret = getattr(self._connection, "_token", None)
118
+ if username and secret:
119
+ host = getattr(self._connection, "_host", "localhost")
120
+ # Normalize domain and build auth host (auth.domain)
121
+ try:
122
+ domain = self._connection._normalize_domain(host)
123
+ except Exception:
124
+ domain = host
125
+ # Only add auth. prefix when domain looks like a DNS name (not 'localhost')
126
+ if "." in domain and not domain.startswith("localhost"):
127
+ auth_host = f"auth.{domain}"
128
+ else:
129
+ auth_host = domain
130
+ scheme = "https" if getattr(self._connection, "_ssl", False) else "http"
131
+ auth_url = f"{scheme}://{auth_host}/token"
132
+
133
+ # Build form-encoded payload
134
+ payload = {
135
+ "grant_type": "client_credentials",
136
+ "client_id": username,
137
+ "client_secret": secret,
138
+ }
139
+ # Use the connection session for auth so auth header set for all subsequent calls
140
+ sess = getattr(self._connection, "_session", requests.Session())
141
+ headers = {
142
+ "accept": "application/json",
143
+ "Content-Type": "application/x-www-form-urlencoded",
144
+ }
145
+ resp = sess.post(
146
+ auth_url,
147
+ data=payload,
148
+ headers=headers,
149
+ timeout=getattr(self._connection, "_timeout", 30),
150
+ )
151
+ resp.raise_for_status()
152
+ body = resp.json() if resp.text else {}
153
+ token = body.get("access_token") or body.get("token") or body.get("jwt")
154
+ if token:
155
+ self._jwt_token = token
156
+ # Set Authorization header for subsequent requests via the connection session
157
+ try:
158
+ self._connection._session.headers["Authorization"] = f"Bearer {token}"
159
+ except Exception:
160
+ # If connection session is not available or some other issue, ignore gracefully
161
+ pass
162
+ except requests.exceptions.RequestException:
163
+ # Authentication failed — don't raise here; we will attempt queries without the JWT
164
+ self._jwt_token = None
165
+ except Exception:
166
+ # Any unexpected failure in auth should not crash cursor creation
167
+ self._jwt_token = None
168
+
169
+ @property
170
+ def description(
171
+ self,
172
+ ) -> Optional[List[Tuple[str, Any, None, None, None, None, Optional[bool]]]]:
173
+ """Column description as required by PEP 249."""
174
+ return self._description
175
+
176
+ @property
177
+ def rowcount(self) -> int:
178
+ """Number of rows affected by the last operation."""
179
+ return self._rowcount
180
+
181
+ @property
182
+ def arraysize(self) -> int:
183
+ """Number of rows to fetch at a time."""
184
+ return self._arraysize
185
+
186
+ @arraysize.setter
187
+ def arraysize(self, value: int) -> None:
188
+ self._arraysize = value
189
+
190
+ def close(self) -> None:
191
+ """Close the cursor."""
192
+ self._closed = True
193
+ self._rows = []
194
+ self._description = None
195
+
196
+ def _check_closed(self) -> None:
197
+ """Raise exception if cursor is closed."""
198
+ if self._closed:
199
+ raise ProgrammingError("Cursor is closed")
200
+
201
+ def execute(
202
+ self,
203
+ operation: str,
204
+ parameters: Optional[Union[Dict[str, Any], Sequence[Any]]] = None,
205
+ ) -> "Cursor":
206
+ """Execute a SQL statement.
207
+
208
+ Args:
209
+ operation: SQL statement to execute
210
+ parameters: Optional parameters for the statement
211
+
212
+ Returns:
213
+ Self for method chaining
214
+ """
215
+ self._check_closed()
216
+ self._rows = []
217
+ self._row_index = 0
218
+ self._description = None
219
+ self._rowcount = -1
220
+
221
+ # Convert sequence parameters to dict if needed
222
+ params_dict: Optional[Dict[str, Any]] = None
223
+ if parameters is not None:
224
+ if isinstance(parameters, dict):
225
+ params_dict = parameters
226
+ else:
227
+ # Convert positional to named parameters
228
+ params_dict = {f"p{i}": v for i, v in enumerate(parameters)}
229
+ # Replace ? placeholders with :p0, :p1, etc.
230
+ for i in range(len(parameters)):
231
+ operation = operation.replace("?", f":p{i}", 1)
232
+
233
+ # Submit the statement
234
+ response = self._connection._submit_statement(operation, params_dict)
235
+ self._statement_handle = response.get("execution_id")
236
+
237
+ if not self._statement_handle:
238
+ raise DatabaseError("No statement handle returned from server")
239
+
240
+ # Poll for completion
241
+ self._poll_for_results()
242
+
243
+ return self
244
+
245
+ def _poll_for_results(self) -> None:
246
+ """Poll the server until statement execution completes."""
247
+ if not self._statement_handle:
248
+ return
249
+
250
+ max_wait = 300 # Maximum wait time in seconds
251
+ poll_interval = 0.5
252
+ elapsed = 0.0
253
+
254
+ while elapsed < max_wait:
255
+ status = self._connection._get_statement_status(self._statement_handle)
256
+ raw_state = status.get("status")
257
+
258
+ if isinstance(raw_state, dict):
259
+ state_value = raw_state.get("state")
260
+ status_details = raw_state
261
+ else:
262
+ state_value = raw_state
263
+ status_details = {}
264
+
265
+ if not state_value:
266
+ state_value = status.get("state")
267
+
268
+ normalized_state = (state_value or "UNKNOWN").upper()
269
+
270
+ if normalized_state in ("COMPLETED", "SUCCEEDED", "INCHOATE"):
271
+ self._fetch_results()
272
+ return
273
+ if normalized_state in ("FAILED", "CANCELLED"):
274
+ description = (
275
+ status_details.get("description")
276
+ or status.get("description")
277
+ or status.get("detail")
278
+ or "Unknown error"
279
+ )
280
+ raise DatabaseError(f"Statement execution failed: {description}")
281
+ if normalized_state in ("UNKNOWN", "SUBMITTED", "EXECUTING", "RUNNING"):
282
+ time.sleep(poll_interval)
283
+ elapsed += poll_interval
284
+ poll_interval = min(poll_interval * 1.5, 2.5)
285
+ continue
286
+
287
+ raise DatabaseError(f"Unknown statement state: {state_value}")
288
+
289
+ raise OperationalError("Statement execution timed out")
290
+
291
+ @staticmethod
292
+ def _rows_from_columnar_data(column_data: Sequence[Dict[str, Any]]) -> List[Tuple[Any, ...]]:
293
+ """Convert column-oriented payloads into row tuples."""
294
+ column_values: List[List[Any]] = []
295
+ for column in column_data:
296
+ if not isinstance(column, dict):
297
+ continue
298
+ values = column.get("values") or []
299
+ column_values.append(list(values))
300
+ if not column_values:
301
+ return []
302
+ max_rows = max((len(values) for values in column_values), default=0)
303
+ return [
304
+ tuple(
305
+ column_values[col_index][row_index]
306
+ if row_index < len(column_values[col_index])
307
+ else None
308
+ for col_index in range(len(column_values))
309
+ )
310
+ for row_index in range(max_rows)
311
+ ]
312
+
313
+ def _fetch_results(self) -> None:
314
+ """Fetch results from a completed statement."""
315
+ if not self._statement_handle:
316
+ return
317
+
318
+ page_size = max(self._opteryx_max_row_buffer or 10, self._arraysize)
319
+ offset = 0
320
+ has_description = False
321
+ rows: List[Tuple[Any, ...]] = []
322
+ total_rows: Optional[int] = None
323
+
324
+ def process_result_page(result: Dict[str, Any]) -> int:
325
+ nonlocal has_description, total_rows
326
+ new_rows = 0
327
+ if total_rows is None and "total_rows" in result:
328
+ try:
329
+ total_rows = int(result.get("total_rows", 0))
330
+ except (TypeError, ValueError):
331
+ total_rows = None
332
+
333
+ columns_meta = result.get("columns", [])
334
+ if columns_meta and not has_description:
335
+ self._description = [
336
+ (col.get("name", f"col{i}"), None, None, None, None, None, None)
337
+ for i, col in enumerate(columns_meta)
338
+ ]
339
+ has_description = True
340
+
341
+ data = result.get("data", [])
342
+ if data:
343
+ if isinstance(data[0], dict) and "values" in data[0]:
344
+ if not has_description:
345
+ self._description = [
346
+ (col.get("name", f"col{i}"), None, None, None, None, None, None)
347
+ for i, col in enumerate(data)
348
+ ]
349
+ has_description = True
350
+ column_rows = self._rows_from_columnar_data(data)
351
+ rows.extend(column_rows)
352
+ new_rows = len(column_rows)
353
+ else:
354
+ for row in data:
355
+ rows.append(tuple(row))
356
+ new_rows = len(data)
357
+
358
+ return new_rows
359
+
360
+ status_result = self._connection._get_statement_status(self._statement_handle) # pylint: disable=protected-access
361
+ process_result_page(status_result)
362
+ offset = len(rows)
363
+
364
+ while True:
365
+ if total_rows is not None and offset >= total_rows:
366
+ break
367
+
368
+ result = self._connection._get_statement_results( # pylint: disable=protected-access
369
+ self._statement_handle, num_rows=page_size, offset=offset
370
+ )
371
+
372
+ fetched_this_page = process_result_page(result)
373
+ if fetched_this_page <= 0:
374
+ break
375
+
376
+ offset += fetched_this_page
377
+ if fetched_this_page < page_size:
378
+ break
379
+
380
+ # Finalize rows and counts
381
+ self._rows = rows
382
+ self._rowcount = len(self._rows)
383
+
384
+ def executemany(
385
+ self,
386
+ operation: str,
387
+ seq_of_parameters: Sequence[Union[Dict[str, Any], Sequence[Any]]],
388
+ ) -> "Cursor":
389
+ """Execute a SQL statement multiple times with different parameters."""
390
+ self._check_closed()
391
+ for parameters in seq_of_parameters:
392
+ self.execute(operation, parameters)
393
+ return self
394
+
395
+ def fetchone(self) -> Optional[Tuple[Any, ...]]:
396
+ """Fetch the next row of a query result set."""
397
+ self._check_closed()
398
+ if self._row_index >= len(self._rows):
399
+ return None
400
+ row = self._rows[self._row_index]
401
+ self._row_index += 1
402
+ return row
403
+
404
+ def fetchmany(self, size: Optional[int] = None) -> List[Tuple[Any, ...]]:
405
+ """Fetch the next set of rows."""
406
+ self._check_closed()
407
+ if size is None:
408
+ size = self._arraysize
409
+ rows = self._rows[self._row_index : self._row_index + size]
410
+ self._row_index += len(rows)
411
+ return rows
412
+
413
+ def fetchall(self) -> List[Tuple[Any, ...]]:
414
+ """Fetch all remaining rows."""
415
+ self._check_closed()
416
+ rows = self._rows[self._row_index :]
417
+ self._row_index = len(self._rows)
418
+ return rows
419
+
420
+ def setinputsizes(self, sizes: Sequence[Any]) -> None:
421
+ """Set input sizes (no-op, but required by PEP 249)."""
422
+ _ = sizes
423
+ return None
424
+
425
+ def setoutputsize(self, size: int, column: Optional[int] = None) -> None:
426
+ """Set output size (no-op, but required by PEP 249)."""
427
+ _ = size
428
+ _ = column
429
+ return None
430
+
431
+ def __iter__(self) -> "Cursor":
432
+ """Make cursor iterable."""
433
+ return self
434
+
435
+ def __next__(self) -> Tuple[Any, ...]:
436
+ """Get next row."""
437
+ row = self.fetchone()
438
+ if row is None:
439
+ raise StopIteration
440
+ return row
441
+
442
+
443
+ class Connection:
444
+ """DBAPI 2.0 Connection implementation for Opteryx.
445
+
446
+ Manages HTTP connections to the Opteryx data service.
447
+ """
448
+
449
+ def __init__(
450
+ self,
451
+ host: str = "localhost",
452
+ port: int = 8000,
453
+ username: Optional[str] = None,
454
+ token: Optional[str] = None,
455
+ database: Optional[str] = None,
456
+ ssl: bool = False,
457
+ timeout: float = 30.0,
458
+ ) -> None:
459
+ """Initialize connection to Opteryx data service.
460
+
461
+ Args:
462
+ host: Hostname of the Opteryx data service
463
+ port: Port number
464
+ username: Username for authentication (optional)
465
+ token: Bearer token for authentication
466
+ database: Database/schema name (optional)
467
+ ssl: Whether to use HTTPS
468
+ timeout: Request timeout in seconds
469
+ """
470
+ self._host = host
471
+ self._port = port
472
+ self._username = username
473
+ self._token = token
474
+ self._database = database
475
+ self._ssl = ssl
476
+ self._timeout = timeout
477
+ self._closed = False
478
+
479
+ # Build base URL
480
+ scheme = "https" if ssl else "http"
481
+ if (ssl and port == 443) or (not ssl and port == 80):
482
+ self._base_url = f"{scheme}://{host}"
483
+ else:
484
+ self._base_url = f"{scheme}://{host}:{port}"
485
+
486
+ # Create session for connection pooling
487
+ self._session = requests.Session()
488
+ if token:
489
+ self._session.headers["Authorization"] = f"Bearer {token}"
490
+ self._session.headers["Content-Type"] = "application/json"
491
+
492
+ def _normalize_domain(self, host: str) -> str:
493
+ """Return the base domain for the given host by stripping known subdomain prefixes.
494
+
495
+ Examples:
496
+ 'data.opteryx.app' -> 'opteryx.app'
497
+ 'auth.opteryx.app' -> 'opteryx.app'
498
+ 'opteryx.app' -> 'opteryx.app'
499
+ 'localhost' -> 'localhost'
500
+ """
501
+ domain = host
502
+ for p in ("data.", "auth."):
503
+ if domain.startswith(p):
504
+ domain = domain[len(p) :]
505
+ return domain
506
+
507
+ def _data_base_url(self) -> str:
508
+ """Construct a base URL that targets the 'data' subdomain for API requests."""
509
+ scheme = "https" if self._ssl else "http"
510
+ domain = self._normalize_domain(self._host)
511
+ # Only add subdomain prefix for DNS-style hosts (e.g. example.com), not for localhost or IPs
512
+ if "." in domain and not domain.startswith("localhost"):
513
+ data_host = f"data.{domain}"
514
+ else:
515
+ data_host = domain
516
+ if (self._ssl and self._port == 443) or (not self._ssl and self._port == 80):
517
+ return f"{scheme}://{data_host}"
518
+ return f"{scheme}://{data_host}:{self._port}"
519
+
520
+ def _check_closed(self) -> None:
521
+ """Raise exception if connection is closed."""
522
+ if self._closed:
523
+ raise ProgrammingError("Connection is closed")
524
+
525
+ def _submit_statement(
526
+ self, sql: str, parameters: Optional[Dict[str, Any]] = None
527
+ ) -> Dict[str, Any]:
528
+ """Submit a SQL statement to the data service."""
529
+ self._check_closed()
530
+
531
+ url = urljoin(self._data_base_url() + "/", "api/v1/statements")
532
+ payload: Dict[str, Any] = {"sqlText": sql}
533
+ if parameters:
534
+ payload["parameters"] = parameters
535
+
536
+ try:
537
+ response = self._session.post(url, json=payload, timeout=self._timeout)
538
+ response.raise_for_status()
539
+ return response.json()
540
+ except requests.exceptions.HTTPError as e:
541
+ if e.response is not None:
542
+ try:
543
+ detail = e.response.json().get("detail", str(e))
544
+ except (ValueError, json.JSONDecodeError):
545
+ detail = e.response.text or str(e)
546
+ raise DatabaseError(f"HTTP error: {detail}") from e
547
+ raise DatabaseError(f"HTTP error: {e}") from e
548
+ except requests.exceptions.RequestException as e:
549
+ raise OperationalError(f"Connection error: {e}") from e
550
+
551
+ def _get_statement_status(self, statement_handle: str) -> Dict[str, Any]:
552
+ """Get the status of a submitted statement."""
553
+ self._check_closed()
554
+
555
+ url = urljoin(self._data_base_url() + "/", f"api/v1/statements/{statement_handle}/status")
556
+
557
+ try:
558
+ response = self._session.get(url, timeout=self._timeout)
559
+ response.raise_for_status()
560
+ return response.json()
561
+ except requests.exceptions.HTTPError as e:
562
+ if e.response is not None and e.response.status_code == 404:
563
+ raise ProgrammingError("Statement not found") from e
564
+ raise DatabaseError(f"HTTP error: {e}") from e
565
+ except requests.exceptions.RequestException as e:
566
+ raise OperationalError(f"Connection error: {e}") from e
567
+
568
+ def _get_statement_results(
569
+ self, statement_handle: str, num_rows: Optional[int] = None, offset: Optional[int] = None
570
+ ) -> Dict[str, Any]:
571
+ """Get results for a completed statement.
572
+
573
+ Note: The current API may return results as part of status response
574
+ or via a separate results endpoint. This method handles both cases.
575
+ """
576
+ url = urljoin(self._data_base_url() + "/", f"api/v1/statements/{statement_handle}/results")
577
+ params: Dict[str, Any] = {}
578
+ if num_rows is not None:
579
+ params["num_rows"] = int(num_rows)
580
+ if offset is not None:
581
+ params["offset"] = int(offset)
582
+ try:
583
+ response = self._session.get(url, params=params or None, timeout=self._timeout)
584
+ response.raise_for_status()
585
+ return response.json()
586
+ except requests.exceptions.RequestException:
587
+ pass
588
+
589
+ # Fallback to status endpoint if dedicated results are unavailable
590
+ return self._get_statement_status(statement_handle)
591
+
592
+ def close(self) -> None:
593
+ """Close the connection."""
594
+ if not self._closed:
595
+ self._session.close()
596
+ self._closed = True
597
+
598
+ def commit(self) -> None:
599
+ """Commit transaction (no-op for Opteryx as it's read-only)."""
600
+ self._check_closed()
601
+
602
+ def rollback(self) -> None:
603
+ """Rollback transaction (no-op for Opteryx as it's read-only)."""
604
+ self._check_closed()
605
+
606
+ def cursor(self) -> Cursor:
607
+ """Create a new cursor object."""
608
+ self._check_closed()
609
+ return Cursor(self)
610
+
611
+ def __enter__(self) -> "Connection":
612
+ """Context manager entry."""
613
+ return self
614
+
615
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
616
+ """Context manager exit."""
617
+ self.close()
618
+
619
+
620
+ def connect(
621
+ host: str = "localhost",
622
+ port: int = 8000,
623
+ username: Optional[str] = None,
624
+ token: Optional[str] = None,
625
+ database: Optional[str] = None,
626
+ ssl: bool = False,
627
+ timeout: float = 30.0,
628
+ ) -> Connection:
629
+ """Create a new connection to the Opteryx data service.
630
+
631
+ Args:
632
+ host: Hostname of the Opteryx data service
633
+ port: Port number
634
+ username: Username for authentication (optional)
635
+ token: Bearer token for authentication
636
+ database: Database/schema name (optional)
637
+ ssl: Whether to use HTTPS
638
+ timeout: Request timeout in seconds
639
+
640
+ Returns:
641
+ A new Connection object
642
+ """
643
+ return Connection(
644
+ host=host,
645
+ port=port,
646
+ username=username,
647
+ token=token,
648
+ database=database,
649
+ ssl=ssl,
650
+ timeout=timeout,
651
+ )
@@ -0,0 +1,346 @@
1
+ """SQLAlchemy dialect for Opteryx data service.
2
+
3
+ This module provides a SQLAlchemy dialect implementation that enables
4
+ connecting to the Opteryx data service using SQLAlchemy's engine and
5
+ ORM capabilities.
6
+
7
+ Connection URL format:
8
+ opteryx://[username:token@]host[:port]/[database][?ssl=true]
9
+
10
+ Examples:
11
+ opteryx://data.opteryx.app/default
12
+ opteryx://user:mytoken@data.opteryx.app:443/default?ssl=true
13
+ opteryx://localhost:8000/default
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from typing import Any
20
+ from typing import Optional
21
+ from typing import Tuple
22
+
23
+ from sqlalchemy import types as sqltypes
24
+ from sqlalchemy.engine import default
25
+ from sqlalchemy.engine.interfaces import ExecutionContext
26
+ from sqlalchemy.engine.url import URL
27
+
28
+ from . import dbapi
29
+
30
+
31
+ def _quote_identifier(identifier: str) -> str:
32
+ """Safely quote a SQL identifier to prevent SQL injection.
33
+
34
+ Args:
35
+ identifier: The identifier (table name, column name, etc.) to quote
36
+
37
+ Returns:
38
+ A safely quoted identifier string
39
+
40
+ Raises:
41
+ ValueError: If the identifier contains invalid characters
42
+ """
43
+ # Validate identifier format - alphanumeric, underscores, and dots only
44
+ if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", identifier):
45
+ raise ValueError(f"Invalid identifier: {identifier}")
46
+ # Return double-quoted identifier (standard SQL quoting)
47
+ return f'"{identifier}"'
48
+
49
+
50
+ class OptetyxDialect(default.DefaultDialect):
51
+ """SQLAlchemy dialect for Opteryx data service.
52
+
53
+ This dialect communicates with the Opteryx data service via HTTP,
54
+ translating SQLAlchemy operations into API calls.
55
+ """
56
+
57
+ name = "opteryx"
58
+ driver = "http"
59
+
60
+ # Capabilities
61
+ supports_alter = False
62
+ supports_pk_autoincrement = False
63
+ supports_default_values = False
64
+ supports_empty_insert = False
65
+ supports_sequences = False
66
+ sequences_optional = True
67
+ supports_native_boolean = True
68
+ supports_native_decimal = True
69
+ supports_statement_cache = False
70
+ postfetch_lastrowid = False
71
+
72
+ # Opteryx is read-only (analytics engine)
73
+ supports_sane_rowcount = False
74
+ supports_sane_multi_rowcount = False
75
+
76
+ # Default SQL features
77
+ default_paramstyle = "named"
78
+ supports_native_enum = False
79
+ supports_simple_order_by_label = True
80
+ supports_comments = False
81
+ inline_comments = False
82
+
83
+ # Required for SQLAlchemy
84
+ preexecute_autoincrement_sequences = False
85
+ implicit_returning = False
86
+ full_returning = False
87
+
88
+ # Type mapping
89
+ colspecs = {}
90
+ ischema_names = {
91
+ "VARCHAR": sqltypes.String,
92
+ "STRING": sqltypes.String,
93
+ "TEXT": sqltypes.Text,
94
+ "INTEGER": sqltypes.Integer,
95
+ "INT": sqltypes.Integer,
96
+ "BIGINT": sqltypes.BigInteger,
97
+ "SMALLINT": sqltypes.SmallInteger,
98
+ "FLOAT": sqltypes.Float,
99
+ "DOUBLE": sqltypes.Float,
100
+ "REAL": sqltypes.Float,
101
+ "DECIMAL": sqltypes.Numeric,
102
+ "NUMERIC": sqltypes.Numeric,
103
+ "BOOLEAN": sqltypes.Boolean,
104
+ "BOOL": sqltypes.Boolean,
105
+ "DATE": sqltypes.Date,
106
+ "TIME": sqltypes.Time,
107
+ "TIMESTAMP": sqltypes.DateTime,
108
+ "DATETIME": sqltypes.DateTime,
109
+ "BLOB": sqltypes.LargeBinary,
110
+ "BINARY": sqltypes.LargeBinary,
111
+ }
112
+
113
+ @classmethod
114
+ def dbapi(cls) -> Any:
115
+ """Return the DBAPI module."""
116
+ return dbapi
117
+
118
+ @classmethod
119
+ def import_dbapi(cls) -> Any:
120
+ """Import and return the DBAPI module."""
121
+ return dbapi
122
+
123
+ def create_connect_args(self, url: URL) -> Tuple[list, dict]:
124
+ """Create connection arguments from SQLAlchemy URL.
125
+
126
+ Args:
127
+ url: SQLAlchemy URL object
128
+
129
+ Returns:
130
+ Tuple of (positional args, keyword args) for dbapi.connect()
131
+ """
132
+ opts = {}
133
+
134
+ # Host
135
+ if url.host:
136
+ opts["host"] = url.host
137
+
138
+ # Port
139
+ if url.port:
140
+ opts["port"] = url.port
141
+ else:
142
+ # Default ports based on SSL setting
143
+ query = dict(url.query) if url.query else {}
144
+ ssl = query.get("ssl", "").lower() in ("true", "1", "yes")
145
+ opts["port"] = 443 if ssl else 8000
146
+
147
+ # Username and token (password field used for token)
148
+ if url.username:
149
+ opts["username"] = url.username
150
+ if url.password:
151
+ opts["token"] = url.password
152
+
153
+ # Database
154
+ if url.database:
155
+ opts["database"] = url.database
156
+
157
+ # Query parameters
158
+ if url.query:
159
+ query = dict(url.query)
160
+ if "ssl" in query:
161
+ opts["ssl"] = query["ssl"].lower() in ("true", "1", "yes")
162
+ if "timeout" in query:
163
+ try:
164
+ opts["timeout"] = float(query["timeout"])
165
+ except ValueError:
166
+ pass
167
+
168
+ return ([], opts)
169
+
170
+ def do_execute(
171
+ self,
172
+ cursor: Any,
173
+ statement: str,
174
+ parameters: Optional[Any],
175
+ context: Optional[ExecutionContext] = None,
176
+ ) -> Any:
177
+ """Propagate execution options so downstream code can react to them."""
178
+ execution_options = getattr(context, "execution_options", {}) if context is not None else {}
179
+ streaming_requested = bool(execution_options.get("stream_results"))
180
+ max_row_buffer = execution_options.get("max_row_buffer")
181
+
182
+ # Attach the parsed streaming hints to the DBAPI cursor for later use.
183
+ cursor._opteryx_execution_options = dict(execution_options)
184
+ cursor._opteryx_stream_results_requested = streaming_requested
185
+ cursor._opteryx_max_row_buffer = max_row_buffer
186
+
187
+ return super().do_execute(cursor, statement, parameters, context=context)
188
+
189
+ def do_ping(self, dbapi_connection: Any) -> bool:
190
+ """Check if the connection is still alive.
191
+
192
+ Args:
193
+ dbapi_connection: The DBAPI connection object
194
+
195
+ Returns:
196
+ True if the connection is alive, False otherwise
197
+ """
198
+ try:
199
+ cursor = dbapi_connection.cursor()
200
+ cursor.execute("SELECT 1")
201
+ cursor.fetchone()
202
+ cursor.close()
203
+ return True
204
+ except Exception:
205
+ return False
206
+
207
+ def get_isolation_level(self, dbapi_connection: Any) -> str:
208
+ """Return the isolation level.
209
+
210
+ Opteryx doesn't support transactions, so we return a nominal value.
211
+ """
212
+ return "AUTOCOMMIT"
213
+
214
+ def has_table(
215
+ self, connection: Any, table_name: str, schema: Optional[str] = None, **kw: Any
216
+ ) -> bool:
217
+ """Check if a table exists.
218
+
219
+ Args:
220
+ connection: SQLAlchemy connection
221
+ table_name: Name of the table
222
+ schema: Optional schema name
223
+
224
+ Returns:
225
+ True if the table exists
226
+ """
227
+ # Try to query the table with a limit of 0 to check existence
228
+ try:
229
+ # Safely quote identifiers to prevent SQL injection
230
+ quoted_table = _quote_identifier(table_name)
231
+ if schema:
232
+ quoted_schema = _quote_identifier(schema)
233
+ full_name = f"{quoted_schema}.{quoted_table}"
234
+ else:
235
+ full_name = quoted_table
236
+ result = connection.execute(f"SELECT * FROM {full_name} LIMIT 0")
237
+ result.close()
238
+ return True
239
+ except (ValueError, Exception):
240
+ # ValueError from invalid identifier, or database error
241
+ return False
242
+
243
+ def get_columns(
244
+ self,
245
+ connection: Any,
246
+ table_name: str,
247
+ schema: Optional[str] = None,
248
+ **kw: Any,
249
+ ) -> list:
250
+ """Get column information for a table.
251
+
252
+ Returns an empty list as Opteryx may not support full introspection.
253
+ """
254
+ return []
255
+
256
+ def get_pk_constraint(
257
+ self,
258
+ connection: Any,
259
+ table_name: str,
260
+ schema: Optional[str] = None,
261
+ **kw: Any,
262
+ ) -> dict:
263
+ """Get primary key constraint.
264
+
265
+ Opteryx doesn't have primary keys in the traditional sense.
266
+ """
267
+ return {"constrained_columns": [], "name": None}
268
+
269
+ def get_foreign_keys(
270
+ self,
271
+ connection: Any,
272
+ table_name: str,
273
+ schema: Optional[str] = None,
274
+ **kw: Any,
275
+ ) -> list:
276
+ """Get foreign key information.
277
+
278
+ Opteryx doesn't support foreign keys.
279
+ """
280
+ return []
281
+
282
+ def get_indexes(
283
+ self,
284
+ connection: Any,
285
+ table_name: str,
286
+ schema: Optional[str] = None,
287
+ **kw: Any,
288
+ ) -> list:
289
+ """Get index information.
290
+
291
+ Opteryx doesn't expose index information.
292
+ """
293
+ return []
294
+
295
+ def get_table_names(self, connection: Any, schema: Optional[str] = None, **kw: Any) -> list:
296
+ """Get list of table names.
297
+
298
+ Attempts to query Opteryx for available tables.
299
+ """
300
+ try:
301
+ result = connection.execute("SHOW TABLES")
302
+ tables = [row[0] for row in result.fetchall()]
303
+ result.close()
304
+ return tables
305
+ except Exception:
306
+ return []
307
+
308
+ def get_view_names(self, connection: Any, schema: Optional[str] = None, **kw: Any) -> list:
309
+ """Get list of view names.
310
+
311
+ Opteryx may not distinguish between tables and views.
312
+ """
313
+ return []
314
+
315
+ def get_schema_names(self, connection: Any, **kw: Any) -> list:
316
+ """Get list of schema names."""
317
+ try:
318
+ result = connection.execute("SHOW SCHEMAS")
319
+ schemas = [row[0] for row in result.fetchall()]
320
+ result.close()
321
+ return schemas
322
+ except Exception:
323
+ return ["default"]
324
+
325
+ def _get_server_version_info(self, connection: Any) -> Tuple[int, ...]:
326
+ """Get server version information."""
327
+ return (0, 26, 1) # Match Opteryx version in pyproject.toml
328
+
329
+ def _check_unicode_returns(
330
+ self, connection: Any, additional_tests: Optional[list] = None
331
+ ) -> bool:
332
+ """Check if the connection returns unicode strings."""
333
+ return True
334
+
335
+ def _check_unicode_description(self, connection: Any) -> bool:
336
+ """Check if column descriptions are unicode."""
337
+ return True
338
+
339
+
340
+ # Register the dialect
341
+ def register_dialect() -> None:
342
+ """Register the Opteryx dialect with SQLAlchemy."""
343
+ from sqlalchemy.dialects import registry
344
+
345
+ registry.register("opteryx", "app.sqlalchemy_dialect.dialect", "OptetyxDialect")
346
+ registry.register("opteryx.http", "app.sqlalchemy_dialect.dialect", "OptetyxDialect")