singlestoredb 0.4.0__py3-none-any.whl → 1.0.4__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.

Potentially problematic release.


This version of singlestoredb might be problematic. Click here for more details.

Files changed (120) hide show
  1. singlestoredb/__init__.py +33 -1
  2. singlestoredb/alchemy/__init__.py +90 -0
  3. singlestoredb/auth.py +5 -1
  4. singlestoredb/config.py +116 -14
  5. singlestoredb/connection.py +483 -516
  6. singlestoredb/converters.py +238 -135
  7. singlestoredb/exceptions.py +30 -2
  8. singlestoredb/functions/__init__.py +1 -0
  9. singlestoredb/functions/decorator.py +142 -0
  10. singlestoredb/functions/dtypes.py +1639 -0
  11. singlestoredb/functions/ext/__init__.py +2 -0
  12. singlestoredb/functions/ext/arrow.py +375 -0
  13. singlestoredb/functions/ext/asgi.py +661 -0
  14. singlestoredb/functions/ext/json.py +427 -0
  15. singlestoredb/functions/ext/mmap.py +306 -0
  16. singlestoredb/functions/ext/rowdat_1.py +744 -0
  17. singlestoredb/functions/signature.py +673 -0
  18. singlestoredb/fusion/__init__.py +11 -0
  19. singlestoredb/fusion/graphql.py +213 -0
  20. singlestoredb/fusion/handler.py +621 -0
  21. singlestoredb/fusion/handlers/stage.py +257 -0
  22. singlestoredb/fusion/handlers/utils.py +162 -0
  23. singlestoredb/fusion/handlers/workspace.py +412 -0
  24. singlestoredb/fusion/registry.py +164 -0
  25. singlestoredb/fusion/result.py +399 -0
  26. singlestoredb/http/__init__.py +27 -0
  27. singlestoredb/{http.py → http/connection.py} +555 -154
  28. singlestoredb/management/__init__.py +3 -0
  29. singlestoredb/management/billing_usage.py +148 -0
  30. singlestoredb/management/cluster.py +14 -6
  31. singlestoredb/management/manager.py +100 -38
  32. singlestoredb/management/organization.py +188 -0
  33. singlestoredb/management/region.py +5 -5
  34. singlestoredb/management/utils.py +281 -2
  35. singlestoredb/management/workspace.py +1344 -49
  36. singlestoredb/{clients/pymysqlsv → mysql}/__init__.py +16 -21
  37. singlestoredb/{clients/pymysqlsv → mysql}/_auth.py +39 -8
  38. singlestoredb/{clients/pymysqlsv → mysql}/charset.py +26 -23
  39. singlestoredb/{clients/pymysqlsv/connections.py → mysql/connection.py} +532 -165
  40. singlestoredb/{clients/pymysqlsv → mysql}/constants/CLIENT.py +0 -1
  41. singlestoredb/{clients/pymysqlsv → mysql}/constants/COMMAND.py +0 -1
  42. singlestoredb/{clients/pymysqlsv → mysql}/constants/CR.py +0 -2
  43. singlestoredb/{clients/pymysqlsv → mysql}/constants/ER.py +0 -1
  44. singlestoredb/{clients/pymysqlsv → mysql}/constants/FIELD_TYPE.py +1 -1
  45. singlestoredb/{clients/pymysqlsv → mysql}/constants/FLAG.py +0 -1
  46. singlestoredb/{clients/pymysqlsv → mysql}/constants/SERVER_STATUS.py +0 -1
  47. singlestoredb/mysql/converters.py +271 -0
  48. singlestoredb/{clients/pymysqlsv → mysql}/cursors.py +228 -112
  49. singlestoredb/mysql/err.py +92 -0
  50. singlestoredb/{clients/pymysqlsv → mysql}/optionfile.py +5 -4
  51. singlestoredb/{clients/pymysqlsv → mysql}/protocol.py +49 -20
  52. singlestoredb/mysql/tests/__init__.py +19 -0
  53. singlestoredb/{clients/pymysqlsv → mysql}/tests/base.py +32 -12
  54. singlestoredb/mysql/tests/conftest.py +37 -0
  55. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_DictCursor.py +11 -7
  56. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_SSCursor.py +17 -12
  57. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_basic.py +32 -24
  58. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_connection.py +130 -119
  59. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_converters.py +9 -7
  60. singlestoredb/mysql/tests/test_cursor.py +141 -0
  61. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_err.py +3 -2
  62. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_issues.py +35 -27
  63. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_load_local.py +13 -11
  64. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_nextset.py +7 -3
  65. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_optionfile.py +2 -1
  66. singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/__init__.py +1 -1
  67. singlestoredb/mysql/tests/thirdparty/test_MySQLdb/__init__.py +9 -0
  68. singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/capabilities.py +19 -17
  69. singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/dbapi20.py +31 -22
  70. singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/test_MySQLdb_capabilities.py +3 -4
  71. singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/test_MySQLdb_dbapi20.py +24 -20
  72. singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/test_MySQLdb_nonstandard.py +4 -4
  73. singlestoredb/{clients/pymysqlsv → mysql}/times.py +3 -4
  74. singlestoredb/pytest.py +283 -0
  75. singlestoredb/tests/empty.sql +0 -0
  76. singlestoredb/tests/ext_funcs/__init__.py +385 -0
  77. singlestoredb/tests/test.sql +210 -0
  78. singlestoredb/tests/test2.sql +1 -0
  79. singlestoredb/tests/test_basics.py +482 -115
  80. singlestoredb/tests/test_config.py +13 -13
  81. singlestoredb/tests/test_connection.py +241 -305
  82. singlestoredb/tests/test_dbapi.py +27 -0
  83. singlestoredb/tests/test_ext_func.py +1193 -0
  84. singlestoredb/tests/test_ext_func_data.py +1101 -0
  85. singlestoredb/tests/test_fusion.py +465 -0
  86. singlestoredb/tests/test_http.py +32 -26
  87. singlestoredb/tests/test_management.py +588 -8
  88. singlestoredb/tests/test_plugin.py +33 -0
  89. singlestoredb/tests/test_results.py +11 -12
  90. singlestoredb/tests/test_udf.py +687 -0
  91. singlestoredb/tests/utils.py +3 -2
  92. singlestoredb/utils/config.py +58 -0
  93. singlestoredb/utils/debug.py +13 -0
  94. singlestoredb/utils/mogrify.py +151 -0
  95. singlestoredb/utils/results.py +4 -1
  96. singlestoredb-1.0.4.dist-info/METADATA +139 -0
  97. singlestoredb-1.0.4.dist-info/RECORD +112 -0
  98. {singlestoredb-0.4.0.dist-info → singlestoredb-1.0.4.dist-info}/WHEEL +1 -1
  99. singlestoredb-1.0.4.dist-info/entry_points.txt +2 -0
  100. singlestoredb/clients/pymysqlsv/converters.py +0 -365
  101. singlestoredb/clients/pymysqlsv/err.py +0 -144
  102. singlestoredb/clients/pymysqlsv/tests/__init__.py +0 -19
  103. singlestoredb/clients/pymysqlsv/tests/test_cursor.py +0 -133
  104. singlestoredb/clients/pymysqlsv/tests/thirdparty/test_MySQLdb/__init__.py +0 -9
  105. singlestoredb/drivers/__init__.py +0 -45
  106. singlestoredb/drivers/base.py +0 -198
  107. singlestoredb/drivers/cymysql.py +0 -38
  108. singlestoredb/drivers/http.py +0 -47
  109. singlestoredb/drivers/mariadb.py +0 -40
  110. singlestoredb/drivers/mysqlconnector.py +0 -49
  111. singlestoredb/drivers/mysqldb.py +0 -60
  112. singlestoredb/drivers/pymysql.py +0 -37
  113. singlestoredb/drivers/pymysqlsv.py +0 -35
  114. singlestoredb/drivers/pyodbc.py +0 -65
  115. singlestoredb-0.4.0.dist-info/METADATA +0 -111
  116. singlestoredb-0.4.0.dist-info/RECORD +0 -86
  117. /singlestoredb/{clients → fusion/handlers}/__init__.py +0 -0
  118. /singlestoredb/{clients/pymysqlsv → mysql}/constants/__init__.py +0 -0
  119. {singlestoredb-0.4.0.dist-info → singlestoredb-1.0.4.dist-info}/LICENSE +0 -0
  120. {singlestoredb-0.4.0.dist-info → singlestoredb-1.0.4.dist-info}/top_level.txt +0 -0
@@ -1,8 +1,13 @@
1
1
  #!/usr/bin/env python
2
2
  """SingleStoreDB HTTP API interface."""
3
+ import datetime
4
+ import decimal
3
5
  import functools
4
6
  import json
7
+ import math
8
+ import os
5
9
  import re
10
+ import time
6
11
  from base64 import b64decode
7
12
  from typing import Any
8
13
  from typing import Callable
@@ -10,41 +15,62 @@ from typing import Dict
10
15
  from typing import Iterable
11
16
  from typing import List
12
17
  from typing import Optional
18
+ from typing import Sequence
13
19
  from typing import Tuple
14
20
  from typing import Union
15
21
  from urllib.parse import urljoin
22
+ from urllib.parse import urlparse
16
23
 
17
24
  import requests
18
25
 
19
- from . import types
20
- from .config import get_option
21
- from .converters import converters
22
- from .exceptions import DatabaseError # noqa: F401
23
- from .exceptions import DataError
24
- from .exceptions import Error
25
- from .exceptions import IntegrityError
26
- from .exceptions import InterfaceError
27
- from .exceptions import InternalError
28
- from .exceptions import NotSupportedError
29
- from .exceptions import OperationalError
30
- from .exceptions import ProgrammingError
31
- from .exceptions import Warning # noqa: F401
32
- from .utils.convert_rows import convert_rows
33
- from .utils.results import Result
26
+ try:
27
+ import numpy as np
28
+ has_numpy = True
29
+ except ImportError:
30
+ has_numpy = False
31
+
32
+ try:
33
+ import pygeos
34
+ has_pygeos = True
35
+ except ImportError:
36
+ has_pygeos = False
37
+
38
+ try:
39
+ import shapely.geometry
40
+ import shapely.wkt
41
+ has_shapely = True
42
+ except ImportError:
43
+ has_shapely = False
44
+
45
+ from .. import connection
46
+ from .. import fusion
47
+ from .. import types
48
+ from ..config import get_option
49
+ from ..converters import converters
50
+ from ..exceptions import DatabaseError # noqa: F401
51
+ from ..exceptions import DataError
52
+ from ..exceptions import Error # noqa: F401
53
+ from ..exceptions import IntegrityError
54
+ from ..exceptions import InterfaceError
55
+ from ..exceptions import InternalError
56
+ from ..exceptions import NotSupportedError
57
+ from ..exceptions import OperationalError
58
+ from ..exceptions import ProgrammingError
59
+ from ..exceptions import Warning # noqa: F401
60
+ from ..utils.convert_rows import convert_rows
61
+ from ..utils.debug import log_query
62
+ from ..utils.mogrify import mogrify
63
+ from ..utils.results import Description
64
+ from ..utils.results import format_results
65
+ from ..utils.results import Result
34
66
 
35
67
 
36
68
  # DB-API settings
37
69
  apilevel = '2.0'
38
- paramstyle = 'qmark'
70
+ paramstyle = 'named'
39
71
  threadsafety = 1
40
72
 
41
73
 
42
- Description = Tuple[
43
- str, int, Optional[int], Optional[int], Optional[int],
44
- Optional[int], bool,
45
- ]
46
-
47
-
48
74
  _interface_errors = set([
49
75
  0,
50
76
  2013, # CR_SERVER_LOST
@@ -145,6 +171,125 @@ def b64decode_converter(
145
171
  return converter(b64decode(x))
146
172
 
147
173
 
174
+ def encode_timedelta(obj: datetime.timedelta) -> str:
175
+ """Encode timedelta as str."""
176
+ seconds = int(obj.seconds) % 60
177
+ minutes = int(obj.seconds // 60) % 60
178
+ hours = int(obj.seconds // 3600) % 24 + int(obj.days) * 24
179
+ if obj.microseconds:
180
+ fmt = '{0:02d}:{1:02d}:{2:02d}.{3:06d}'
181
+ else:
182
+ fmt = '{0:02d}:{1:02d}:{2:02d}'
183
+ return fmt.format(hours, minutes, seconds, obj.microseconds)
184
+
185
+
186
+ def encode_time(obj: datetime.time) -> str:
187
+ """Encode time as str."""
188
+ if obj.microsecond:
189
+ fmt = '{0.hour:02}:{0.minute:02}:{0.second:02}.{0.microsecond:06}'
190
+ else:
191
+ fmt = '{0.hour:02}:{0.minute:02}:{0.second:02}'
192
+ return fmt.format(obj)
193
+
194
+
195
+ def encode_datetime(obj: datetime.datetime) -> str:
196
+ """Encode datetime as str."""
197
+ if obj.microsecond:
198
+ fmt = '{0.year:04}-{0.month:02}-{0.day:02} ' \
199
+ '{0.hour:02}:{0.minute:02}:{0.second:02}.{0.microsecond:06}'
200
+ else:
201
+ fmt = '{0.year:04}-{0.month:02}-{0.day:02} ' \
202
+ '{0.hour:02}:{0.minute:02}:{0.second:02}'
203
+ return fmt.format(obj)
204
+
205
+
206
+ def encode_date(obj: datetime.date) -> str:
207
+ """Encode date as str."""
208
+ fmt = '{0.year:04}-{0.month:02}-{0.day:02}'
209
+ return fmt.format(obj)
210
+
211
+
212
+ def encode_struct_time(obj: time.struct_time) -> str:
213
+ """Encode time struct to str."""
214
+ return encode_datetime(datetime.datetime(*obj[:6]))
215
+
216
+
217
+ def encode_decimal(o: decimal.Decimal) -> str:
218
+ """Encode decimal to str."""
219
+ return format(o, 'f')
220
+
221
+
222
+ # Most argument encoding is done by the JSON encoder, but these
223
+ # are exceptions to the rule.
224
+ encoders = {
225
+ datetime.datetime: encode_datetime,
226
+ datetime.date: encode_date,
227
+ datetime.time: encode_time,
228
+ datetime.timedelta: encode_timedelta,
229
+ time.struct_time: encode_struct_time,
230
+ decimal.Decimal: encode_decimal,
231
+ }
232
+
233
+
234
+ if has_shapely:
235
+ encoders[shapely.geometry.Point] = shapely.wkt.dumps
236
+ encoders[shapely.geometry.Polygon] = shapely.wkt.dumps
237
+ encoders[shapely.geometry.LineString] = shapely.wkt.dumps
238
+
239
+ if has_numpy:
240
+
241
+ def encode_ndarray(obj: np.ndarray) -> bytes: # type: ignore
242
+ """Encode an ndarray as bytes."""
243
+ return obj.tobytes()
244
+
245
+ encoders[np.ndarray] = encode_ndarray
246
+
247
+ if has_pygeos:
248
+ encoders[pygeos.Geometry] = pygeos.io.to_wkt
249
+
250
+
251
+ def convert_special_type(
252
+ arg: Any,
253
+ nan_as_null: bool = False,
254
+ inf_as_null: bool = False,
255
+ ) -> Any:
256
+ """Convert special data type objects."""
257
+ dtype = type(arg)
258
+ if dtype is float or \
259
+ (
260
+ has_numpy and dtype in (
261
+ np.float16, np.float32, np.float64,
262
+ getattr(np, 'float128', np.float64),
263
+ )
264
+ ):
265
+ if nan_as_null and math.isnan(arg):
266
+ return None
267
+ if inf_as_null and math.isinf(arg):
268
+ return None
269
+ func = encoders.get(dtype, None)
270
+ if func is not None:
271
+ return func(arg) # type: ignore
272
+ return arg
273
+
274
+
275
+ def convert_special_params(
276
+ params: Optional[Union[Sequence[Any], Dict[str, Any]]] = None,
277
+ nan_as_null: bool = False,
278
+ inf_as_null: bool = False,
279
+ ) -> Optional[Union[Sequence[Any], Dict[str, Any]]]:
280
+ """Convert parameters of special data types."""
281
+ if params is None:
282
+ return params
283
+ converter = functools.partial(
284
+ convert_special_type,
285
+ nan_as_null=nan_as_null,
286
+ inf_as_null=inf_as_null,
287
+ )
288
+ if isinstance(params, Dict):
289
+ return {k: converter(v) for k, v in params.items()}
290
+ return tuple(map(converter, params))
291
+
292
+
148
293
  class PyMyField(object):
149
294
  """Field for PyMySQL compatibility."""
150
295
 
@@ -159,12 +304,13 @@ class PyMyResult(object):
159
304
 
160
305
  def __init__(self) -> None:
161
306
  self.fields: List[PyMyField] = []
307
+ self.unbuffered_active = False
162
308
 
163
309
  def append(self, item: PyMyField) -> None:
164
310
  self.fields.append(item)
165
311
 
166
312
 
167
- class Cursor(object):
313
+ class Cursor(connection.Cursor):
168
314
  """
169
315
  SingleStoreDB HTTP database cursor.
170
316
 
@@ -178,17 +324,20 @@ class Cursor(object):
178
324
 
179
325
  """
180
326
 
181
- def __init__(self, connection: 'Connection'):
182
- self.connection: Optional[Connection] = connection
327
+ def __init__(self, conn: 'Connection'):
328
+ connection.Cursor.__init__(self, conn)
329
+ self._connection: Optional[Connection] = conn
183
330
  self._results: List[List[Tuple[Any, ...]]] = [[]]
331
+ self._results_type: str = self._connection._results_type \
332
+ if self._connection is not None else 'tuples'
184
333
  self._row_idx: int = -1
185
334
  self._result_idx: int = -1
186
335
  self._descriptions: List[List[Description]] = []
187
- self.arraysize: int = 1000
336
+ self.arraysize: int = get_option('results.arraysize')
188
337
  self.rowcount: int = 0
189
- self.messages: List[Tuple[int, str]] = []
190
338
  self.lastrowid: Optional[int] = None
191
339
  self._pymy_results: List[PyMyResult] = []
340
+ self._expect_results: bool = False
192
341
 
193
342
  @property
194
343
  def _result(self) -> Optional[PyMyResult]:
@@ -224,13 +373,13 @@ class Cursor(object):
224
373
  requests.Response
225
374
 
226
375
  """
227
- if self.connection is None:
228
- raise InterfaceError(errno=2048, msg='Connection is closed.')
229
- return self.connection._post(path, *args, **kwargs)
376
+ if self._connection is None:
377
+ raise ProgrammingError(errno=2048, msg='Connection is closed.')
378
+ return self._connection._post(path, *args, **kwargs)
230
379
 
231
380
  def callproc(
232
381
  self, name: str,
233
- params: Union[List[Any], Dict[str, Any]],
382
+ params: Optional[Sequence[Any]] = None,
234
383
  ) -> None:
235
384
  """
236
385
  Call a stored procedure.
@@ -239,47 +388,140 @@ class Cursor(object):
239
388
  ----------
240
389
  name : str
241
390
  Name of the stored procedure
242
- params : iterable or dict, optional
391
+ params : sequence, optional
243
392
  Parameters to the stored procedure
244
393
 
245
394
  """
246
- if self.connection is None:
247
- raise InterfaceError(errno=2048, msg='Connection is closed.')
248
- raise NotImplementedError
395
+ if self._connection is None:
396
+ raise ProgrammingError(errno=2048, msg='Connection is closed.')
397
+
398
+ name = connection._name_check(name)
399
+
400
+ if not params:
401
+ self._execute(f'CALL {name}();', is_callproc=True)
402
+ else:
403
+ keys = ', '.join(['%s' for i in range(len(params))])
404
+ self._execute(f'CALL {name}({keys});', params, is_callproc=True)
249
405
 
250
406
  def close(self) -> None:
251
407
  """Close the cursor."""
252
- if self.connection is not None:
253
- self.connection = None
408
+ self._connection = None
254
409
 
255
410
  def execute(
256
411
  self, query: str,
257
- params: Optional[Union[List[Any], Dict[str, Any]]] = None,
258
- ) -> None:
412
+ args: Optional[Union[Sequence[Any], Dict[str, Any]]] = None,
413
+ ) -> int:
259
414
  """
260
415
  Execute a SQL statement.
261
416
 
262
417
  Parameters
263
418
  ----------
264
- oper : str
419
+ query : str
265
420
  The SQL statement to execute
266
- params : iterable or dict, optional
421
+ args : iterable or dict, optional
267
422
  Parameters to substitute into the SQL code
268
423
 
269
424
  """
270
- if self.connection is None:
271
- raise InterfaceError(errno=2048, msg='Connection is closed.')
425
+ return self._execute(query, args)
272
426
 
273
- data: Dict[str, Any] = dict(sql=query)
274
- if params is not None:
275
- data['args'] = params
276
- if self.connection._database:
277
- data['database'] = self.connection._database
427
+ def _validate_param_subs(
428
+ self, query: str,
429
+ args: Optional[Union[Sequence[Any], Dict[str, Any]]] = None,
430
+ ) -> None:
431
+ """Make sure the parameter substitions are valid."""
432
+ if args:
433
+ if isinstance(args, Sequence):
434
+ query = query % tuple(args)
435
+ else:
436
+ query = query % args
437
+
438
+ def _execute_fusion_query(
439
+ self,
440
+ oper: Union[str, bytes],
441
+ params: Optional[Union[Sequence[Any], Dict[str, Any]]] = None,
442
+ handler: Any = None,
443
+ ) -> int:
444
+ oper = mogrify(oper, params)
445
+
446
+ if isinstance(oper, bytes):
447
+ oper = oper.decode('utf-8')
448
+
449
+ log_query(oper, None)
450
+
451
+ results_type = self._results_type
452
+ self._results_type = 'tuples'
453
+ try:
454
+ mgmt_res = fusion.execute(
455
+ self._connection, # type: ignore
456
+ oper,
457
+ handler=handler,
458
+ )
459
+ finally:
460
+ self._results_type = results_type
461
+
462
+ self._descriptions.append(list(mgmt_res.description))
463
+ self._results.append(list(mgmt_res.rows))
464
+ self.rowcount = len(self._results[-1])
465
+
466
+ pymy_res = PyMyResult()
467
+ for field in mgmt_res.fields:
468
+ pymy_res.append(
469
+ PyMyField(
470
+ field.name,
471
+ field.flags,
472
+ field.charsetnr,
473
+ ),
474
+ )
475
+
476
+ self._pymy_results.append(pymy_res)
477
+
478
+ if self._results and self._results[0]:
479
+ self._row_idx = 0
480
+ self._result_idx = 0
481
+
482
+ return self.rowcount
483
+
484
+ def _execute(
485
+ self, oper: str,
486
+ params: Optional[Union[Sequence[Any], Dict[str, Any]]] = None,
487
+ is_callproc: bool = False,
488
+ ) -> int:
489
+ self._descriptions = []
490
+ self._results = []
491
+ self._pymy_results = []
492
+ self._row_idx = -1
493
+ self._result_idx = -1
494
+ self.rowcount = 0
495
+ self._expect_results = False
496
+
497
+ if self._connection is None:
498
+ raise ProgrammingError(errno=2048, msg='Connection is closed.')
278
499
 
279
500
  sql_type = 'exec'
280
- if re.match(r'^\s*(select|show|call|echo)\s+', query, flags=re.I):
501
+ if re.match(r'^\s*(select|show|call|echo|describe|with)\s+', oper, flags=re.I):
502
+ self._expect_results = True
281
503
  sql_type = 'query'
282
504
 
505
+ self._validate_param_subs(oper, params)
506
+
507
+ handler = fusion.get_handler(oper)
508
+ if handler is not None:
509
+ return self._execute_fusion_query(oper, params, handler=handler)
510
+
511
+ oper, params = self._connection._convert_params(oper, params)
512
+
513
+ log_query(oper, params)
514
+
515
+ data: Dict[str, Any] = dict(sql=oper)
516
+ if params is not None:
517
+ data['args'] = convert_special_params(
518
+ params,
519
+ nan_as_null=self._connection.connection_params['nan_as_null'],
520
+ inf_as_null=self._connection.connection_params['inf_as_null'],
521
+ )
522
+ if self._connection._database:
523
+ data['database'] = self._connection._database
524
+
283
525
  if sql_type == 'query':
284
526
  res = self._post('query/tuples', json=data)
285
527
  else:
@@ -298,18 +540,18 @@ class Cursor(object):
298
540
 
299
541
  out = json.loads(res.text)
300
542
 
301
- self._descriptions = []
302
- self._results = []
303
- self._row_idx = -1
304
- self._result_idx = -1
305
- self.rowcount = 0
543
+ if 'error' in out:
544
+ raise OperationalError(
545
+ errno=out['error'].get('code', 0),
546
+ msg=out['error'].get('message', 'HTTP Error'),
547
+ )
306
548
 
307
549
  if sql_type == 'query':
308
550
  # description: (name, type_code, display_size, internal_size,
309
551
  # precision, scale, null_ok, column_flags, charset)
310
552
 
311
553
  # Remove converters for things the JSON parser already converted
312
- http_converters = dict(converters)
554
+ http_converters = dict(self._connection.decoders)
313
555
  http_converters.pop(4, None)
314
556
  http_converters.pop(5, None)
315
557
  http_converters.pop(6, None)
@@ -323,6 +565,12 @@ class Cursor(object):
323
565
  http_converters.pop(253, None)
324
566
  http_converters.pop(254, None)
325
567
 
568
+ # Merge passed in converters
569
+ if self._connection._conv:
570
+ for k, v in self._connection._conv.items():
571
+ if isinstance(k, int):
572
+ http_converters[k] = v
573
+
326
574
  results = out['results']
327
575
 
328
576
  # Convert data to Python types
@@ -358,11 +606,14 @@ class Cursor(object):
358
606
  prec += 1 # for decimal
359
607
  if converter is not None:
360
608
  convs.append((i, None, converter))
361
- description.append((
362
- str(col['name']), type_code,
363
- None, None, prec, scale,
364
- col.get('nullable', False),
365
- ))
609
+ description.append(
610
+ Description(
611
+ str(col['name']), type_code,
612
+ None, None, prec, scale,
613
+ col.get('nullable', False),
614
+ flags, charset,
615
+ ),
616
+ )
366
617
  pymy_res.append(PyMyField(col['name'], flags, charset))
367
618
  self._descriptions.append(description)
368
619
 
@@ -371,21 +622,25 @@ class Cursor(object):
371
622
  self._results.append(rows)
372
623
  self._pymy_results.append(pymy_res)
373
624
 
625
+ # For compatibility with PyMySQL/MySQLdb
626
+ if is_callproc:
627
+ self._results.append([])
628
+
374
629
  self.rowcount = len(self._results[0])
630
+
375
631
  else:
632
+ # For compatibility with PyMySQL/MySQLdb
633
+ if is_callproc:
634
+ self._results.append([])
635
+
376
636
  self.rowcount = out['rowsAffected']
377
637
 
638
+ return self.rowcount
639
+
378
640
  def executemany(
379
641
  self, query: str,
380
- param_seq: Optional[
381
- List[
382
- Union[
383
- List[Any],
384
- Dict[str, Any],
385
- ]
386
- ]
387
- ] = None,
388
- ) -> None:
642
+ args: Optional[Sequence[Union[Sequence[Any], Dict[str, Any]]]] = None,
643
+ ) -> int:
389
644
  """
390
645
  Execute SQL code against multiple sets of parameters.
391
646
 
@@ -393,28 +648,38 @@ class Cursor(object):
393
648
  ----------
394
649
  query : str
395
650
  The SQL statement to execute
396
- params_seq : iterable of iterables or dicts, optional
651
+ args : iterable of iterables or dicts, optional
397
652
  Sets of parameters to substitute into the SQL code
398
653
 
399
654
  """
400
- if self.connection is None:
401
- raise InterfaceError(errno=2048, msg='Connection is closed.')
655
+ if self._connection is None:
656
+ raise ProgrammingError(errno=2048, msg='Connection is closed.')
402
657
 
403
658
  results = []
404
- if param_seq:
659
+ rowcount = 0
660
+ if args is not None and len(args) > 0:
405
661
  description = []
406
- for params in param_seq:
662
+ # Detect dataframes
663
+ if hasattr(args, 'itertuples'):
664
+ argiter = args.itertuples(index=False) # type: ignore
665
+ else:
666
+ argiter = iter(args)
667
+ for params in argiter:
407
668
  self.execute(query, params)
408
669
  if self._descriptions:
409
670
  description = self._descriptions[-1]
410
671
  if self._rows is not None:
411
672
  results.append(self._rows)
673
+ rowcount += self.rowcount
412
674
  self._results = results
413
675
  self._descriptions = [description for _ in range(len(results))]
414
- if self._results:
415
- self.rowcount = len(self._results[0])
416
676
  else:
417
677
  self.execute(query)
678
+ rowcount += self.rowcount
679
+
680
+ self.rowcount = rowcount
681
+
682
+ return self.rowcount
418
683
 
419
684
  @property
420
685
  def _has_row(self) -> bool:
@@ -444,11 +709,19 @@ class Cursor(object):
444
709
  If there are no rows left to return
445
710
 
446
711
  """
712
+ if self._connection is None:
713
+ raise ProgrammingError(errno=2048, msg='Connection is closed')
714
+ if not self._expect_results:
715
+ raise self._connection.ProgrammingError(msg='No query has been submitted')
447
716
  if not self._has_row:
448
717
  return None
449
718
  out = self._rows[self._row_idx]
450
719
  self._row_idx += 1
451
- return out
720
+ return format_results(
721
+ self._results_type,
722
+ self.description or [],
723
+ out, single=True,
724
+ )
452
725
 
453
726
  def fetchmany(
454
727
  self,
@@ -465,15 +738,21 @@ class Cursor(object):
465
738
  Values of the returned rows if there are rows remaining
466
739
 
467
740
  """
741
+ if self._connection is None:
742
+ raise ProgrammingError(errno=2048, msg='Connection is closed')
743
+ if not self._expect_results:
744
+ raise self._connection.ProgrammingError(msg='No query has been submitted')
468
745
  if not self._has_row:
469
- return []
746
+ if 'dict' in self._results_type:
747
+ return {}
748
+ return tuple()
470
749
  if not size:
471
750
  size = max(int(self.arraysize), 1)
472
751
  else:
473
752
  size = max(int(size), 1)
474
753
  out = self._rows[self._row_idx:self._row_idx+size]
475
- self._row_idx += size
476
- return out
754
+ self._row_idx += len(out)
755
+ return format_results(self._results_type, self.description or [], out)
477
756
 
478
757
  def fetchall(self) -> Result:
479
758
  """
@@ -485,17 +764,26 @@ class Cursor(object):
485
764
  Values of the returned rows if there are rows remaining
486
765
 
487
766
  """
767
+ if self._connection is None:
768
+ raise ProgrammingError(errno=2048, msg='Connection is closed')
769
+ if not self._expect_results:
770
+ raise self._connection.ProgrammingError(msg='No query has been submitted')
488
771
  if not self._has_row:
489
- return []
490
- out = list(self._rows)
491
- self._row_idx = -1
492
- return out
772
+ if 'dict' in self._results_type:
773
+ return {}
774
+ return tuple()
775
+ out = list(self._rows[self._row_idx:])
776
+ self._row_idx = len(out)
777
+ return format_results(self._results_type, self.description or [], out)
493
778
 
494
779
  def nextset(self) -> Optional[bool]:
495
780
  """Skip to the next available result set."""
781
+ if self._connection is None:
782
+ raise ProgrammingError(errno=2048, msg='Connection is closed')
783
+
496
784
  if self._result_idx < 0:
497
785
  self._row_idx = -1
498
- return False
786
+ return None
499
787
 
500
788
  self._result_idx += 1
501
789
  self._row_idx = 0
@@ -503,13 +791,13 @@ class Cursor(object):
503
791
  if self._result_idx >= len(self._results):
504
792
  self._result_idx = -1
505
793
  self._row_idx = -1
506
- return False
794
+ return None
507
795
 
508
796
  self.rowcount = len(self._results[self._result_idx])
509
797
 
510
798
  return True
511
799
 
512
- def setinputsizes(self, sizes: List[int]) -> None:
800
+ def setinputsizes(self, sizes: Sequence[int]) -> None:
513
801
  """Predefine memory areas for parameters."""
514
802
  pass
515
803
 
@@ -517,7 +805,7 @@ class Cursor(object):
517
805
  """Set a column buffer size for fetches of large columns."""
518
806
  pass
519
807
 
520
- @ property
808
+ @property
521
809
  def rownumber(self) -> Optional[int]:
522
810
  """
523
811
  Return the zero-based index of the cursor in the result set.
@@ -543,6 +831,8 @@ class Cursor(object):
543
831
  Type of move that should be made: 'relative' or 'absolute'
544
832
 
545
833
  """
834
+ if self._connection is None:
835
+ raise ProgrammingError(errno=2048, msg='Connection is closed')
546
836
  if mode == 'relative':
547
837
  self._row_idx += value
548
838
  elif mode == 'absolute':
@@ -565,6 +855,8 @@ class Cursor(object):
565
855
  If no more rows exist
566
856
 
567
857
  """
858
+ if self._connection is None:
859
+ raise InterfaceError(errno=2048, msg='Connection is closed')
568
860
  out = self.fetchone()
569
861
  if out is None:
570
862
  raise StopIteration
@@ -587,6 +879,13 @@ class Cursor(object):
587
879
  """Exit a context."""
588
880
  self.close()
589
881
 
882
+ @property
883
+ def open(self) -> bool:
884
+ """Check if the cursor is still connected."""
885
+ if self._connection is None:
886
+ return False
887
+ return self._connection.is_connected()
888
+
590
889
  def is_connected(self) -> bool:
591
890
  """
592
891
  Check if the cursor is still connected.
@@ -596,12 +895,10 @@ class Cursor(object):
596
895
  bool
597
896
 
598
897
  """
599
- if self.connection is None:
600
- return False
601
- return self.connection.is_connected()
898
+ return self.open
602
899
 
603
900
 
604
- class Connection(object):
901
+ class Connection(connection.Connection):
605
902
  """
606
903
  SingleStoreDB HTTP database connection.
607
904
 
@@ -613,19 +910,13 @@ class Connection(object):
613
910
  `connect`
614
911
 
615
912
  """
616
-
617
- Warning = Warning
618
- Error = Error
619
- InterfaceError = InterfaceError
620
- DatabaseError = DatabaseError
621
- DataError = DataError
622
- OperationalError = OperationalError
623
- IntegrityError = IntegrityError
624
- InternalError = InternalError
625
- ProgrammingError = ProgrammingError
626
- NotSupportedError = NotSupportedError
913
+ driver = 'https'
914
+ paramstyle = 'qmark'
627
915
 
628
916
  def __init__(self, **kwargs: Any):
917
+ from .. import __version__ as client_version
918
+ connection.Connection.__init__(self, **kwargs)
919
+
629
920
  host = kwargs.get('host', get_option('host'))
630
921
  port = kwargs.get('port', get_option('http_port'))
631
922
 
@@ -641,6 +932,7 @@ class Connection(object):
641
932
  'Content-Type': 'application/json',
642
933
  'Accept': 'application/json',
643
934
  'Accept-Encoding': 'compress,identity',
935
+ 'User-Agent': f'SingleStoreDB-Python/{client_version}',
644
936
  })
645
937
 
646
938
  if kwargs.get('ssl_disabled', get_option('ssl_disabled')):
@@ -657,13 +949,104 @@ class Connection(object):
657
949
  if ssl_ca:
658
950
  self._sess.verify = ssl_ca
659
951
 
660
- version = kwargs.get('version', 'v1')
661
- protocol = kwargs.get('protocol', 'https')
952
+ ssl_verify_cert = kwargs.get('ssl_verify_cert', True)
953
+ if not ssl_verify_cert:
954
+ self._sess.verify = False
955
+
956
+ if kwargs.get('multi_statements', False):
957
+ raise self.InterfaceError(
958
+ 0, 'The Data API does not allow multiple '
959
+ 'statements within a query',
960
+ )
961
+
962
+ self._version = kwargs.get('version', 'v2')
963
+ self.driver = kwargs.get('driver', 'https')
964
+
965
+ self.encoders = {k: v for (k, v) in converters.items() if type(k) is not int}
966
+ self.decoders = {k: v for (k, v) in converters.items() if type(k) is int}
662
967
 
663
968
  self._database = kwargs.get('database', get_option('database'))
664
- self._url = f'{protocol}://{host}:{port}/api/{version}/'
665
- self.messages: List[List[Any]] = []
969
+ self._url = f'{self.driver}://{host}:{port}/api/{self._version}/'
970
+ self._host = host
971
+ self._messages: List[Tuple[int, str]] = []
666
972
  self._autocommit: bool = True
973
+ self._conv = kwargs.get('conv', None)
974
+ self._in_sync: bool = False
975
+ self._track_env: bool = kwargs.get('track_env', False) \
976
+ or host == 'singlestore.com'
977
+
978
+ @property
979
+ def messages(self) -> List[Tuple[int, str]]:
980
+ return self._messages
981
+
982
+ def connect(self) -> 'Connection':
983
+ """Connect to the server."""
984
+ return self
985
+
986
+ def _sync_connection(self, kwargs: Dict[str, Any]) -> None:
987
+ """Synchronize connection with env variable."""
988
+ if self._sess is None:
989
+ raise InterfaceError(errno=2048, msg='Connection is closed.')
990
+
991
+ if self._in_sync:
992
+ return
993
+
994
+ if not self._track_env:
995
+ return
996
+
997
+ url = os.environ.get('SINGLESTOREDB_URL')
998
+ if not url:
999
+ if self._host == 'singlestore.com':
1000
+ raise InterfaceError(0, 'Connection URL has not been established')
1001
+ return
1002
+
1003
+ out = {}
1004
+ urlp = connection._parse_url(url)
1005
+ out.update(urlp)
1006
+ out = connection._cast_params(out)
1007
+
1008
+ # Set default port based on driver.
1009
+ if 'port' not in out or not out['port']:
1010
+ if out.get('driver', 'https') == 'http':
1011
+ out['port'] = int(get_option('port') or 80)
1012
+ else:
1013
+ out['port'] = int(get_option('port') or 443)
1014
+
1015
+ # If there is no user and the password is empty, remove the password key.
1016
+ if 'user' not in out and not out.get('password', None):
1017
+ out.pop('password', None)
1018
+
1019
+ if out['host'] == 'singlestore.com':
1020
+ raise InterfaceError(0, 'Connection URL has not been established')
1021
+
1022
+ # Get current connection attributes
1023
+ curr_url = urlparse(self._url, scheme='singlestoredb', allow_fragments=True)
1024
+ if self._sess.auth is not None:
1025
+ auth = tuple(self._sess.auth) # type: ignore
1026
+ else:
1027
+ auth = (None, None) # type: ignore
1028
+
1029
+ # If it's just a password change, we don't need to reconnect
1030
+ if (curr_url.hostname, curr_url.port, auth[0], self._database) == \
1031
+ (out['host'], out['port'], out['user'], out.get('database')):
1032
+ return
1033
+
1034
+ try:
1035
+ self._in_sync = True
1036
+ sess = requests.Session()
1037
+ sess.auth = (out['user'], out['password'])
1038
+ sess.headers.update(self._sess.headers)
1039
+ sess.verify = self._sess.verify
1040
+ sess.cert = self._sess.cert
1041
+ self._database = out.get('database')
1042
+ self._host = out['host']
1043
+ self._url = f'{out.get("driver", "https")}://{out["host"]}:{out["port"]}' \
1044
+ f'/api/{self._version}/'
1045
+ self._sess = sess
1046
+ if self._database:
1047
+ kwargs['json']['database'] = self._database
1048
+ finally:
1049
+ self._in_sync = False
667
1050
 
668
1051
  def _post(self, path: str, *args: Any, **kwargs: Any) -> requests.Response:
669
1052
  """
@@ -685,24 +1068,46 @@ class Connection(object):
685
1068
  """
686
1069
  if self._sess is None:
687
1070
  raise InterfaceError(errno=2048, msg='Connection is closed.')
1071
+
1072
+ self._sync_connection(kwargs)
1073
+
1074
+ if 'timeout' not in kwargs:
1075
+ kwargs['timeout'] = get_option('connect_timeout')
1076
+
688
1077
  return self._sess.post(urljoin(self._url, path), *args, **kwargs)
689
1078
 
690
1079
  def close(self) -> None:
691
1080
  """Close the connection."""
1081
+ if self._host == 'singlestore.com':
1082
+ return
1083
+ if self._sess is None:
1084
+ raise Error(errno=2048, msg='Connection is closed')
692
1085
  self._sess = None
693
1086
 
694
- def autocommit(self, value: bool) -> None:
1087
+ def autocommit(self, value: bool = True) -> None:
695
1088
  """Set autocommit mode."""
1089
+ if self._host == 'singlestore.com':
1090
+ return
1091
+ if self._sess is None:
1092
+ raise InterfaceError(errno=2048, msg='Connection is closed')
696
1093
  self._autocommit = value
697
1094
 
698
1095
  def commit(self) -> None:
699
1096
  """Commit the pending transaction."""
1097
+ if self._host == 'singlestore.com':
1098
+ return
1099
+ if self._sess is None:
1100
+ raise InterfaceError(errno=2048, msg='Connection is closed')
700
1101
  if self._autocommit:
701
1102
  return
702
1103
  raise NotSupportedError(msg='operation not supported')
703
1104
 
704
1105
  def rollback(self) -> None:
705
1106
  """Rollback the pending transaction."""
1107
+ if self._host == 'singlestore.com':
1108
+ return
1109
+ if self._sess is None:
1110
+ raise InterfaceError(errno=2048, msg='Connection is closed')
706
1111
  if self._autocommit:
707
1112
  return
708
1113
  raise NotSupportedError(msg='operation not supported')
@@ -729,6 +1134,17 @@ class Connection(object):
729
1134
  """Exit a context."""
730
1135
  self.close()
731
1136
 
1137
+ @property
1138
+ def open(self) -> bool:
1139
+ """Check if the database is still connected."""
1140
+ if self._sess is None:
1141
+ return False
1142
+ url = '/'.join(self._url.split('/')[:3]) + '/ping'
1143
+ res = self._sess.get(url)
1144
+ if res.status_code <= 400 and res.text == 'pong':
1145
+ return True
1146
+ return False
1147
+
732
1148
  def is_connected(self) -> bool:
733
1149
  """
734
1150
  Check if the database is still connected.
@@ -738,54 +1154,39 @@ class Connection(object):
738
1154
  bool
739
1155
 
740
1156
  """
741
- if self._sess is None:
742
- return False
743
- url = '/'.join(self._url.split('/')[:3]) + '/ping'
744
- res = self._sess.get(url)
745
- if res.status_code <= 400 and res.text == 'pong':
746
- return True
747
- return False
1157
+ return self.open
748
1158
 
749
1159
 
750
1160
  def connect(
751
- host: Optional[str] = None, port: Optional[int] = None,
752
- user: Optional[str] = None, password: Optional[str] = None,
753
- database: Optional[str] = None, protocol: str = 'https', version: str = 'v1',
754
- ssl_key: Optional[str] = None, ssl_cert: Optional[str] = None,
755
- ssl_ca: Optional[str] = None, ssl_disabled: Optional[bool] = None,
1161
+ host: Optional[str] = None,
1162
+ user: Optional[str] = None,
1163
+ password: Optional[str] = None,
1164
+ port: Optional[int] = None,
1165
+ database: Optional[str] = None,
1166
+ driver: Optional[str] = None,
1167
+ pure_python: Optional[bool] = None,
1168
+ local_infile: Optional[bool] = None,
1169
+ charset: Optional[str] = None,
1170
+ ssl_key: Optional[str] = None,
1171
+ ssl_cert: Optional[str] = None,
1172
+ ssl_ca: Optional[str] = None,
1173
+ ssl_disabled: Optional[bool] = None,
1174
+ ssl_cipher: Optional[str] = None,
1175
+ ssl_verify_cert: Optional[bool] = None,
1176
+ ssl_verify_identity: Optional[bool] = None,
1177
+ conv: Optional[Dict[int, Callable[..., Any]]] = None,
1178
+ credential_type: Optional[str] = None,
1179
+ autocommit: Optional[bool] = None,
1180
+ results_type: Optional[str] = None,
1181
+ buffered: Optional[bool] = None,
1182
+ results_format: Optional[str] = None,
1183
+ program_name: Optional[str] = None,
1184
+ conn_attrs: Optional[Dict[str, str]] = None,
1185
+ multi_statements: Optional[bool] = None,
1186
+ connect_timeout: Optional[int] = None,
1187
+ nan_as_null: Optional[bool] = None,
1188
+ inf_as_null: Optional[bool] = None,
1189
+ encoding_errors: Optional[str] = None,
1190
+ track_env: Optional[bool] = None,
756
1191
  ) -> Connection:
757
- """
758
- Connect to a SingleStoreDB using HTTP.
759
-
760
- Parameters
761
- ----------
762
- user : str, optional
763
- Database user name
764
- password : str, optional
765
- Database user password
766
- host : str, optional
767
- Database host name or IP address
768
- port : int, optional
769
- Database port. This defaults to 3306 for non-HTTP connections, 80
770
- for HTTP connections, and 443 for HTTPS connections.
771
- database : str, optional
772
- Database name
773
- protocol : str, optional
774
- HTTP protocol: `http` or `https`
775
- version : str, optional
776
- Version of the HTTP API
777
- ssl_key : str, optional
778
- File containing SSL key
779
- ssl_cert : str, optional
780
- File containing SSL certificate
781
- ssl_ca : str, optional
782
- File containing SSL certificate authority
783
- ssl_disabled : bool, optional
784
- Disable SSL usage
785
-
786
- Returns
787
- -------
788
- Connection
789
-
790
- """
791
1192
  return Connection(**dict(locals()))