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
@@ -0,0 +1,661 @@
1
+ #!/usr/bin/env python3
2
+ '''
3
+ Web application for SingleStoreDB external functions.
4
+
5
+ This module supplies a function that can create web apps intended for use
6
+ with the external function feature of SingleStoreDB. The application
7
+ function is a standard ASGI <https://asgi.readthedocs.io/en/latest/index.html>
8
+ request handler for use with servers such as Uvicorn <https://www.uvicorn.org>.
9
+
10
+ An external function web application can be created using the `create_app`
11
+ function. By default, the exported Python functions are specified by
12
+ environment variables starting with SINGLESTOREDB_EXT_FUNCTIONS. See the
13
+ documentation in `create_app` for the full syntax. If the application is
14
+ created in Python code rather than from the command-line, exported
15
+ functions can be specified in the parameters.
16
+
17
+ An example of starting a server is shown below.
18
+
19
+ Example
20
+ -------
21
+ $ SINGLESTOREDB_EXT_FUNCTIONS='myfuncs.[percentage_90,percentage_95]' \
22
+ uvicorn --factory singlestoredb.functions.ext:create_app
23
+
24
+ '''
25
+ import importlib.util
26
+ import io
27
+ import itertools
28
+ import json
29
+ import os
30
+ import re
31
+ import secrets
32
+ from types import ModuleType
33
+ from typing import Any
34
+ from typing import Awaitable
35
+ from typing import Callable
36
+ from typing import Dict
37
+ from typing import Iterable
38
+ from typing import List
39
+ from typing import Optional
40
+ from typing import Sequence
41
+ from typing import Set
42
+ from typing import Tuple
43
+ from typing import Union
44
+
45
+ from . import arrow
46
+ from . import json as jdata
47
+ from . import rowdat_1
48
+ from ... import connection
49
+ from ...mysql.constants import FIELD_TYPE as ft
50
+ from ..signature import get_signature
51
+ from ..signature import signature_to_sql
52
+
53
+ # If a number of processes is specified, create a pool of workers
54
+ num_processes = max(0, int(os.environ.get('SINGLESTOREDB_EXT_NUM_PROCESSES', 0)))
55
+ if num_processes > 1:
56
+ try:
57
+ from ray.util.multiprocessing import Pool
58
+ except ImportError:
59
+ from multiprocessing import Pool
60
+ func_map = Pool(num_processes).starmap
61
+ else:
62
+ func_map = itertools.starmap
63
+
64
+
65
+ # Use negative values to indicate unsigned ints / binary data / usec time precision
66
+ rowdat_1_type_map = {
67
+ 'bool': ft.LONGLONG,
68
+ 'int8': ft.LONGLONG,
69
+ 'int16': ft.LONGLONG,
70
+ 'int32': ft.LONGLONG,
71
+ 'int64': ft.LONGLONG,
72
+ 'uint8': -ft.LONGLONG,
73
+ 'uint16': -ft.LONGLONG,
74
+ 'uint32': -ft.LONGLONG,
75
+ 'uint64': -ft.LONGLONG,
76
+ 'float32': ft.DOUBLE,
77
+ 'float64': ft.DOUBLE,
78
+ 'str': ft.STRING,
79
+ 'bytes': -ft.STRING,
80
+ }
81
+
82
+
83
+ def get_func_names(funcs: str) -> List[Tuple[str, str]]:
84
+ '''
85
+ Parse all function names from string.
86
+
87
+ Parameters
88
+ ----------
89
+ func_names : str
90
+ String containing one or more function names. The syntax is
91
+ as follows: [func-name-1@func-alias-1,func-name-2@func-alias-2,...].
92
+ The optional '@name' portion is an alias if you want the function
93
+ to be renamed.
94
+
95
+ Returns
96
+ -------
97
+ List[Tuple[str]] : a list of tuples containing the names and aliases
98
+ of each function.
99
+
100
+ '''
101
+ if funcs.startswith('['):
102
+ func_names = funcs.replace('[', '').replace(']', '').split(',')
103
+ func_names = [x.strip() for x in func_names]
104
+ else:
105
+ func_names = [funcs]
106
+
107
+ out = []
108
+ for name in func_names:
109
+ alias = name
110
+ if '@' in name:
111
+ name, alias = name.split('@', 1)
112
+ out.append((name, alias))
113
+
114
+ return out
115
+
116
+
117
+ def make_func(name: str, func: Callable[..., Any]) -> Callable[..., Any]:
118
+ '''
119
+ Make a function endpoint.
120
+
121
+ Parameters
122
+ ----------
123
+ name : str
124
+ Name of the function to create
125
+ func : Callable
126
+ The function to call as the endpoint
127
+
128
+ Returns
129
+ -------
130
+ Callable
131
+
132
+ '''
133
+ attrs = getattr(func, '_singlestoredb_attrs', {})
134
+ data_format = attrs.get('data_format') or 'python'
135
+ include_masks = attrs.get('include_masks', False)
136
+
137
+ if data_format == 'python':
138
+ async def do_func(
139
+ row_ids: Sequence[int],
140
+ rows: Sequence[Sequence[Any]],
141
+ ) -> Tuple[
142
+ Sequence[int],
143
+ List[Tuple[Any]],
144
+ ]:
145
+ '''Call function on given rows of data.'''
146
+ return row_ids, list(zip(func_map(func, rows)))
147
+
148
+ else:
149
+ # Vector formats use the same function wrapper
150
+ async def do_func( # type: ignore
151
+ row_ids: Sequence[int],
152
+ cols: Sequence[Tuple[Sequence[Any], Optional[Sequence[bool]]]],
153
+ ) -> Tuple[Sequence[int], List[Tuple[Any, ...]]]:
154
+ '''Call function on given cols of data.'''
155
+ # TODO: only supports a single return value
156
+ if include_masks:
157
+ out = func(*cols)
158
+ assert isinstance(out, tuple)
159
+ return row_ids, [out]
160
+ return row_ids, [(func(*[x[0] for x in cols]), None)]
161
+
162
+ do_func.__name__ = name
163
+ do_func.__doc__ = func.__doc__
164
+
165
+ sig = get_signature(func, name=name)
166
+
167
+ # Store signature for generating CREATE FUNCTION calls
168
+ do_func._ext_func_signature = sig # type: ignore
169
+
170
+ # Set data format
171
+ do_func._ext_func_data_format = data_format # type: ignore
172
+
173
+ # Setup argument types for rowdat_1 parser
174
+ colspec = []
175
+ for x in sig['args']:
176
+ dtype = x['dtype'].replace('?', '')
177
+ if dtype not in rowdat_1_type_map:
178
+ raise TypeError(f'no data type mapping for {dtype}')
179
+ colspec.append((x['name'], rowdat_1_type_map[dtype]))
180
+ do_func._ext_func_colspec = colspec # type: ignore
181
+
182
+ # Setup return type
183
+ dtype = sig['returns']['dtype'].replace('?', '')
184
+ if dtype not in rowdat_1_type_map:
185
+ raise TypeError(f'no data type mapping for {dtype}')
186
+ do_func._ext_func_returns = [rowdat_1_type_map[dtype]] # type: ignore
187
+
188
+ return do_func
189
+
190
+
191
+ def create_app( # noqa: C901
192
+ functions: Optional[
193
+ Union[
194
+ str,
195
+ Iterable[str],
196
+ Callable[..., Any],
197
+ Iterable[Callable[..., Any]],
198
+ ModuleType,
199
+ Iterable[ModuleType],
200
+ ]
201
+ ] = None,
202
+ app_mode: str = 'remote',
203
+ url: str = 'http://localhost:8000/invoke',
204
+ data_format: str = 'rowdat_1',
205
+ data_version: str = '1.0',
206
+ link_config: Optional[Dict[str, Any]] = None,
207
+ link_credentials: Optional[Dict[str, Any]] = None,
208
+ ) -> Callable[..., Any]:
209
+ '''
210
+ Create an external function application.
211
+
212
+ If `functions` is None, the environment is searched for function
213
+ specifications in variables starting with `SINGLESTOREDB_EXT_FUNCTIONS`.
214
+ Any number of environment variables can be specified as long as they
215
+ have this prefix. The format of the environment variable value is the
216
+ same as for the `functions` parameter.
217
+
218
+ Parameters
219
+ ----------
220
+ functions : str or Iterable[str], optional
221
+ Python functions are specified using a string format as follows:
222
+ * Single function : <pkg1>.<func1>
223
+ * Multiple functions : <pkg1>.[<func1-name,func2-name,...]
224
+ * Function aliases : <pkg1>.[<func1@alias1,func2@alias2,...]
225
+ * Multiple packages : <pkg1>.<func1>:<pkg2>.<func2>
226
+ app_mode : str, optional
227
+ The mode of operation for the application: remote or collocated
228
+ url : str, optional
229
+ The URL of the function API
230
+ data_format : str, optional
231
+ The format of the data rows: 'rowdat_1' or 'json'
232
+ data_version : str, optional
233
+ The version of the call format to expect: '1.0'
234
+ link_config : Dict[str, Any], optional
235
+ The CONFIG section of a LINK definition. This dictionary gets
236
+ converted to JSON for the CREATE LINK call.
237
+ link_credentials : Dict[str, Any], optional
238
+ The CREDENTIALS section of a LINK definition. This dictionary gets
239
+ converted to JSON for the CREATE LINK call.
240
+
241
+ Returns
242
+ -------
243
+ Callable : the application request handler
244
+
245
+ '''
246
+
247
+ # List of functions specs
248
+ specs: List[Union[str, Callable[..., Any], ModuleType]] = []
249
+
250
+ # Look up Python function specifications
251
+ if functions is None:
252
+ env_vars = [
253
+ x for x in os.environ.keys()
254
+ if x.startswith('SINGLESTOREDB_EXT_FUNCTIONS')
255
+ ]
256
+ if env_vars:
257
+ specs = [os.environ[x] for x in env_vars]
258
+ else:
259
+ import __main__
260
+ specs = [__main__]
261
+
262
+ elif isinstance(functions, ModuleType):
263
+ specs = [functions]
264
+
265
+ elif isinstance(functions, str):
266
+ specs = [functions]
267
+
268
+ elif callable(functions):
269
+ specs = [functions]
270
+
271
+ else:
272
+ specs = list(functions)
273
+
274
+ # Add functions to application
275
+ endpoints = dict()
276
+ for funcs in itertools.chain(specs):
277
+
278
+ if isinstance(funcs, str):
279
+ # Module name
280
+ if importlib.util.find_spec(funcs) is not None:
281
+ items = importlib.import_module(funcs)
282
+ for x in vars(items).values():
283
+ if not hasattr(x, '_singlestoredb_attrs'):
284
+ continue
285
+ name = x._singlestoredb_attrs.get('name', x.__name__)
286
+ func = make_func(name, x)
287
+ endpoints[name.encode('utf-8')] = func
288
+
289
+ # Fully qualified function name
290
+ else:
291
+ pkg_path, func_names = funcs.rsplit('.', 1)
292
+ pkg = importlib.import_module(pkg_path)
293
+
294
+ # Add endpoint for each exported function
295
+ for name, alias in get_func_names(func_names):
296
+ item = getattr(pkg, name)
297
+ func = make_func(alias, item)
298
+ endpoints[alias.encode('utf-8')] = func
299
+
300
+ elif isinstance(funcs, ModuleType):
301
+ for x in vars(funcs).values():
302
+ if not hasattr(x, '_singlestoredb_attrs'):
303
+ continue
304
+ name = x._singlestoredb_attrs.get('name', x.__name__)
305
+ func = make_func(name, x)
306
+ endpoints[name.encode('utf-8')] = func
307
+
308
+ else:
309
+ alias = funcs.__name__
310
+ func = make_func(alias, funcs)
311
+ endpoints[alias.encode('utf-8')] = func
312
+
313
+ # Plain text response start
314
+ text_response_dict: Dict[str, Any] = dict(
315
+ type='http.response.start',
316
+ status=200,
317
+ headers=[(b'content-type', b'text/plain')],
318
+ )
319
+
320
+ # JSON response start
321
+ json_response_dict: Dict[str, Any] = dict(
322
+ type='http.response.start',
323
+ status=200,
324
+ headers=[(b'content-type', b'application/json')],
325
+ )
326
+
327
+ # ROWDAT_1 response start
328
+ rowdat_1_response_dict: Dict[str, Any] = dict(
329
+ type='http.response.start',
330
+ status=200,
331
+ headers=[(b'content-type', b'x-application/rowdat_1')],
332
+ )
333
+
334
+ # Apache Arrow response start
335
+ arrow_response_dict: Dict[str, Any] = dict(
336
+ type='http.response.start',
337
+ status=200,
338
+ headers=[(b'content-type', b'application/vnd.apache.arrow.file')],
339
+ )
340
+
341
+ # Path not found response start
342
+ path_not_found_response_dict: Dict[str, Any] = dict(
343
+ type='http.response.start',
344
+ status=404,
345
+ )
346
+
347
+ # Response body template
348
+ body_response_dict: Dict[str, Any] = dict(
349
+ type='http.response.body',
350
+ )
351
+
352
+ # Data format + version handlers
353
+ handlers = {
354
+ (b'application/octet-stream', b'1.0', 'python'): dict(
355
+ load=rowdat_1.load,
356
+ dump=rowdat_1.dump,
357
+ response=rowdat_1_response_dict,
358
+ ),
359
+ (b'application/octet-stream', b'1.0', 'pandas'): dict(
360
+ load=rowdat_1.load_pandas,
361
+ dump=rowdat_1.dump_pandas,
362
+ response=rowdat_1_response_dict,
363
+ ),
364
+ (b'application/octet-stream', b'1.0', 'numpy'): dict(
365
+ load=rowdat_1.load_numpy,
366
+ dump=rowdat_1.dump_numpy,
367
+ response=rowdat_1_response_dict,
368
+ ),
369
+ (b'application/octet-stream', b'1.0', 'polars'): dict(
370
+ load=rowdat_1.load_polars,
371
+ dump=rowdat_1.dump_polars,
372
+ response=rowdat_1_response_dict,
373
+ ),
374
+ (b'application/octet-stream', b'1.0', 'arrow'): dict(
375
+ load=rowdat_1.load_arrow,
376
+ dump=rowdat_1.dump_arrow,
377
+ response=rowdat_1_response_dict,
378
+ ),
379
+ (b'application/json', b'1.0', 'python'): dict(
380
+ load=jdata.load,
381
+ dump=jdata.dump,
382
+ response=json_response_dict,
383
+ ),
384
+ (b'application/json', b'1.0', 'pandas'): dict(
385
+ load=jdata.load_pandas,
386
+ dump=jdata.dump_pandas,
387
+ response=json_response_dict,
388
+ ),
389
+ (b'application/json', b'1.0', 'numpy'): dict(
390
+ load=jdata.load_numpy,
391
+ dump=jdata.dump_numpy,
392
+ response=json_response_dict,
393
+ ),
394
+ (b'application/json', b'1.0', 'polars'): dict(
395
+ load=jdata.load_polars,
396
+ dump=jdata.dump_polars,
397
+ response=json_response_dict,
398
+ ),
399
+ (b'application/json', b'1.0', 'arrow'): dict(
400
+ load=jdata.load_arrow,
401
+ dump=jdata.dump_arrow,
402
+ response=json_response_dict,
403
+ ),
404
+ (b'application/vnd.apache.arrow.file', b'1.0', 'python'): dict(
405
+ load=arrow.load,
406
+ dump=arrow.dump,
407
+ response=arrow_response_dict,
408
+ ),
409
+ (b'application/vnd.apache.arrow.file', b'1.0', 'pandas'): dict(
410
+ load=arrow.load_pandas,
411
+ dump=arrow.dump_pandas,
412
+ response=arrow_response_dict,
413
+ ),
414
+ (b'application/vnd.apache.arrow.file', b'1.0', 'numpy'): dict(
415
+ load=arrow.load_numpy,
416
+ dump=arrow.dump_numpy,
417
+ response=arrow_response_dict,
418
+ ),
419
+ (b'application/vnd.apache.arrow.file', b'1.0', 'polars'): dict(
420
+ load=arrow.load_polars,
421
+ dump=arrow.dump_polars,
422
+ response=arrow_response_dict,
423
+ ),
424
+ (b'application/vnd.apache.arrow.file', b'1.0', 'arrow'): dict(
425
+ load=arrow.load_arrow,
426
+ dump=arrow.dump_arrow,
427
+ response=arrow_response_dict,
428
+ ),
429
+ }
430
+
431
+ # Valid URL paths
432
+ invoke_path = ('invoke',)
433
+ show_create_function_path = ('show', 'create_function')
434
+
435
+ async def app(
436
+ scope: Dict[str, Any],
437
+ receive: Callable[..., Awaitable[Any]],
438
+ send: Callable[..., Awaitable[Any]],
439
+ ) -> None:
440
+ '''
441
+ Application request handler.
442
+
443
+ Parameters
444
+ ----------
445
+ scope : dict
446
+ ASGI request scope
447
+ receive : Callable
448
+ Function to receieve request information
449
+ send : Callable
450
+ Function to send response information
451
+
452
+ '''
453
+ assert scope['type'] == 'http'
454
+
455
+ method = scope['method']
456
+ path = tuple(x for x in scope['path'].split('/') if x)
457
+ headers = dict(scope['headers'])
458
+
459
+ content_type = headers.get(
460
+ b'content-type',
461
+ b'application/octet-stream',
462
+ )
463
+ accepts = headers.get(b'accepts', content_type)
464
+ func_name = headers.get(b's2-ef-name', b'')
465
+ func = endpoints.get(func_name)
466
+
467
+ # Call the endpoint
468
+ if method == 'POST' and func is not None and path == invoke_path:
469
+ data_format = func._ext_func_data_format # type: ignore
470
+ data = []
471
+ more_body = True
472
+ while more_body:
473
+ request = await receive()
474
+ data.append(request['body'])
475
+ more_body = request.get('more_body', False)
476
+
477
+ data_version = headers.get(b's2-ef-version', b'')
478
+ input_handler = handlers[(content_type, data_version, data_format)]
479
+ output_handler = handlers[(accepts, data_version, data_format)]
480
+
481
+ out = await func(
482
+ *input_handler['load'](
483
+ func._ext_func_colspec, b''.join(data), # type: ignore
484
+ ),
485
+ )
486
+ body = output_handler['dump'](func._ext_func_returns, *out) # type: ignore
487
+
488
+ await send(output_handler['response'])
489
+
490
+ # Handle api reflection
491
+ elif method == 'GET' and path == show_create_function_path:
492
+ host = headers.get(b'host', b'localhost:80')
493
+ reflected_url = f'{scope["scheme"]}://{host.decode("utf-8")}/invoke'
494
+ data_format = 'json' if b'json' in content_type else 'rowdat_1'
495
+
496
+ syntax = []
497
+ for key, endpoint in endpoints.items():
498
+ if not func_name or key == func_name:
499
+ syntax.append(
500
+ signature_to_sql(
501
+ endpoint._ext_func_signature, # type: ignore
502
+ url=url or reflected_url,
503
+ data_format=data_format,
504
+ ),
505
+ )
506
+ body = '\n'.join(syntax).encode('utf-8')
507
+
508
+ await send(text_response_dict)
509
+
510
+ # Path not found
511
+ else:
512
+ body = b''
513
+ await send(path_not_found_response_dict)
514
+
515
+ # Send body
516
+ out = body_response_dict.copy()
517
+ out['body'] = body
518
+ await send(out)
519
+
520
+ def _create_link(
521
+ config: Optional[Dict[str, Any]],
522
+ credentials: Optional[Dict[str, Any]],
523
+ ) -> Tuple[str, str]:
524
+ """Generate CREATE LINK command."""
525
+ if not config and not credentials:
526
+ return '', ''
527
+
528
+ link_name = f'link_{secrets.token_hex(16)}'
529
+ out = [f'CREATE LINK {link_name} AS HTTP']
530
+
531
+ if config:
532
+ out.append(f"CONFIG '{json.dumps(config)}'")
533
+
534
+ if credentials:
535
+ out.append(f"CREDENTIALS '{json.dumps(credentials)}'")
536
+
537
+ return link_name, ' '.join(out) + ';'
538
+
539
+ def _locate_app_functions(cur: Any) -> Tuple[Set[str], Set[str]]:
540
+ """Locate all current functions and links belonging to this app."""
541
+ funcs, links = set(), set()
542
+ cur.execute('SHOW FUNCTIONS')
543
+ for name, ftype, _, _, _, link in list(cur):
544
+ # Only look at external functions
545
+ if 'external' not in ftype.lower():
546
+ continue
547
+ # See if function URL matches url
548
+ cur.execute(f'SHOW CREATE FUNCTION `{name}`')
549
+ for fname, _, code, *_ in list(cur):
550
+ m = re.search(r" (?:\w+) SERVICE '([^']+)'", code)
551
+ if m and m.group(1) == url:
552
+ funcs.add(fname)
553
+ if link:
554
+ links.add(link)
555
+ return funcs, links
556
+
557
+ def show_create_functions(
558
+ replace: bool = False,
559
+ ) -> List[str]:
560
+ """Generate CREATE FUNCTION calls."""
561
+ if not endpoints:
562
+ return []
563
+
564
+ out = []
565
+ link = ''
566
+ if app_mode.lower() == 'remote':
567
+ link, link_str = _create_link(link_config, link_credentials)
568
+ if link and link_str:
569
+ out.append(link_str)
570
+
571
+ for key, endpoint in endpoints.items():
572
+ out.append(
573
+ signature_to_sql(
574
+ endpoint._ext_func_signature, # type: ignore
575
+ url=url,
576
+ data_format=data_format,
577
+ app_mode=app_mode,
578
+ replace=replace,
579
+ link=link or None,
580
+ ),
581
+ )
582
+
583
+ return out
584
+
585
+ app.show_create_functions = show_create_functions # type: ignore
586
+
587
+ def register_functions(
588
+ *connection_args: Any,
589
+ replace: bool = False,
590
+ **connection_kwargs: Any,
591
+ ) -> None:
592
+ """Register functions with the database."""
593
+ with connection.connect(*connection_args, **connection_kwargs) as conn:
594
+ with conn.cursor() as cur:
595
+ if replace:
596
+ funcs, links = _locate_app_functions(cur)
597
+ for fname in funcs:
598
+ cur.execute(f'DROP FUNCTION IF EXISTS `{fname}`')
599
+ for link in links:
600
+ cur.execute(f'DROP LINK {link}')
601
+ for func in app.show_create_functions(replace=replace): # type: ignore
602
+ cur.execute(func)
603
+
604
+ app.register_functions = register_functions # type: ignore
605
+
606
+ def drop_functions(
607
+ *connection_args: Any,
608
+ **connection_kwargs: Any,
609
+ ) -> None:
610
+ """Drop registered functions from database."""
611
+ with connection.connect(*connection_args, **connection_kwargs) as conn:
612
+ with conn.cursor() as cur:
613
+ funcs, links = _locate_app_functions(cur)
614
+ for fname in funcs:
615
+ cur.execute(f'DROP FUNCTION IF EXISTS `{fname}`')
616
+ for link in links:
617
+ cur.execute(f'DROP LINK {link}')
618
+
619
+ app.drop_functions = drop_functions # type: ignore
620
+
621
+ async def call(
622
+ name: str,
623
+ data_in: io.BytesIO,
624
+ data_out: io.BytesIO,
625
+ data_format: str = data_format,
626
+ data_version: str = data_version,
627
+ ) -> None:
628
+
629
+ async def receive() -> Dict[str, Any]:
630
+ return dict(body=data_in.read())
631
+
632
+ async def send(content: Dict[str, Any]) -> None:
633
+ status = content.get('status', 200)
634
+ if status != 200:
635
+ raise KeyError(f'error occurred when calling `{name}`: {status}')
636
+ data_out.write(content.get('body', b''))
637
+
638
+ accepts = dict(
639
+ json=b'application/json',
640
+ rowdat_1=b'application/octet-stream',
641
+ arrow=b'application/vnd.apache.arrow.file',
642
+ )
643
+
644
+ # Mock an ASGI scope
645
+ scope = dict(
646
+ type='http',
647
+ path='invoke',
648
+ method='POST',
649
+ headers={
650
+ b'content-type': accepts[data_format.lower()],
651
+ b'accepts': accepts[data_format.lower()],
652
+ b's2-ef-name': name.encode('utf-8'),
653
+ b's2-ef-version': data_version.encode('utf-8'),
654
+ },
655
+ )
656
+
657
+ await app(scope, receive, send)
658
+
659
+ app.call = call # type: ignore
660
+
661
+ return app