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.
- singlestoredb/__init__.py +33 -1
- singlestoredb/alchemy/__init__.py +90 -0
- singlestoredb/auth.py +5 -1
- singlestoredb/config.py +116 -14
- singlestoredb/connection.py +483 -516
- singlestoredb/converters.py +238 -135
- singlestoredb/exceptions.py +30 -2
- singlestoredb/functions/__init__.py +1 -0
- singlestoredb/functions/decorator.py +142 -0
- singlestoredb/functions/dtypes.py +1639 -0
- singlestoredb/functions/ext/__init__.py +2 -0
- singlestoredb/functions/ext/arrow.py +375 -0
- singlestoredb/functions/ext/asgi.py +661 -0
- singlestoredb/functions/ext/json.py +427 -0
- singlestoredb/functions/ext/mmap.py +306 -0
- singlestoredb/functions/ext/rowdat_1.py +744 -0
- singlestoredb/functions/signature.py +673 -0
- singlestoredb/fusion/__init__.py +11 -0
- singlestoredb/fusion/graphql.py +213 -0
- singlestoredb/fusion/handler.py +621 -0
- singlestoredb/fusion/handlers/stage.py +257 -0
- singlestoredb/fusion/handlers/utils.py +162 -0
- singlestoredb/fusion/handlers/workspace.py +412 -0
- singlestoredb/fusion/registry.py +164 -0
- singlestoredb/fusion/result.py +399 -0
- singlestoredb/http/__init__.py +27 -0
- singlestoredb/{http.py → http/connection.py} +555 -154
- singlestoredb/management/__init__.py +3 -0
- singlestoredb/management/billing_usage.py +148 -0
- singlestoredb/management/cluster.py +14 -6
- singlestoredb/management/manager.py +100 -38
- singlestoredb/management/organization.py +188 -0
- singlestoredb/management/region.py +5 -5
- singlestoredb/management/utils.py +281 -2
- singlestoredb/management/workspace.py +1344 -49
- singlestoredb/{clients/pymysqlsv → mysql}/__init__.py +16 -21
- singlestoredb/{clients/pymysqlsv → mysql}/_auth.py +39 -8
- singlestoredb/{clients/pymysqlsv → mysql}/charset.py +26 -23
- singlestoredb/{clients/pymysqlsv/connections.py → mysql/connection.py} +532 -165
- singlestoredb/{clients/pymysqlsv → mysql}/constants/CLIENT.py +0 -1
- singlestoredb/{clients/pymysqlsv → mysql}/constants/COMMAND.py +0 -1
- singlestoredb/{clients/pymysqlsv → mysql}/constants/CR.py +0 -2
- singlestoredb/{clients/pymysqlsv → mysql}/constants/ER.py +0 -1
- singlestoredb/{clients/pymysqlsv → mysql}/constants/FIELD_TYPE.py +1 -1
- singlestoredb/{clients/pymysqlsv → mysql}/constants/FLAG.py +0 -1
- singlestoredb/{clients/pymysqlsv → mysql}/constants/SERVER_STATUS.py +0 -1
- singlestoredb/mysql/converters.py +271 -0
- singlestoredb/{clients/pymysqlsv → mysql}/cursors.py +228 -112
- singlestoredb/mysql/err.py +92 -0
- singlestoredb/{clients/pymysqlsv → mysql}/optionfile.py +5 -4
- singlestoredb/{clients/pymysqlsv → mysql}/protocol.py +49 -20
- singlestoredb/mysql/tests/__init__.py +19 -0
- singlestoredb/{clients/pymysqlsv → mysql}/tests/base.py +32 -12
- singlestoredb/mysql/tests/conftest.py +37 -0
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_DictCursor.py +11 -7
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_SSCursor.py +17 -12
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_basic.py +32 -24
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_connection.py +130 -119
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_converters.py +9 -7
- singlestoredb/mysql/tests/test_cursor.py +141 -0
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_err.py +3 -2
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_issues.py +35 -27
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_load_local.py +13 -11
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_nextset.py +7 -3
- singlestoredb/{clients/pymysqlsv → mysql}/tests/test_optionfile.py +2 -1
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/__init__.py +1 -1
- singlestoredb/mysql/tests/thirdparty/test_MySQLdb/__init__.py +9 -0
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/capabilities.py +19 -17
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/dbapi20.py +31 -22
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/test_MySQLdb_capabilities.py +3 -4
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/test_MySQLdb_dbapi20.py +24 -20
- singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/test_MySQLdb_nonstandard.py +4 -4
- singlestoredb/{clients/pymysqlsv → mysql}/times.py +3 -4
- singlestoredb/pytest.py +283 -0
- singlestoredb/tests/empty.sql +0 -0
- singlestoredb/tests/ext_funcs/__init__.py +385 -0
- singlestoredb/tests/test.sql +210 -0
- singlestoredb/tests/test2.sql +1 -0
- singlestoredb/tests/test_basics.py +482 -115
- singlestoredb/tests/test_config.py +13 -13
- singlestoredb/tests/test_connection.py +241 -305
- singlestoredb/tests/test_dbapi.py +27 -0
- singlestoredb/tests/test_ext_func.py +1193 -0
- singlestoredb/tests/test_ext_func_data.py +1101 -0
- singlestoredb/tests/test_fusion.py +465 -0
- singlestoredb/tests/test_http.py +32 -26
- singlestoredb/tests/test_management.py +588 -8
- singlestoredb/tests/test_plugin.py +33 -0
- singlestoredb/tests/test_results.py +11 -12
- singlestoredb/tests/test_udf.py +687 -0
- singlestoredb/tests/utils.py +3 -2
- singlestoredb/utils/config.py +58 -0
- singlestoredb/utils/debug.py +13 -0
- singlestoredb/utils/mogrify.py +151 -0
- singlestoredb/utils/results.py +4 -1
- singlestoredb-1.0.4.dist-info/METADATA +139 -0
- singlestoredb-1.0.4.dist-info/RECORD +112 -0
- {singlestoredb-0.4.0.dist-info → singlestoredb-1.0.4.dist-info}/WHEEL +1 -1
- singlestoredb-1.0.4.dist-info/entry_points.txt +2 -0
- singlestoredb/clients/pymysqlsv/converters.py +0 -365
- singlestoredb/clients/pymysqlsv/err.py +0 -144
- singlestoredb/clients/pymysqlsv/tests/__init__.py +0 -19
- singlestoredb/clients/pymysqlsv/tests/test_cursor.py +0 -133
- singlestoredb/clients/pymysqlsv/tests/thirdparty/test_MySQLdb/__init__.py +0 -9
- singlestoredb/drivers/__init__.py +0 -45
- singlestoredb/drivers/base.py +0 -198
- singlestoredb/drivers/cymysql.py +0 -38
- singlestoredb/drivers/http.py +0 -47
- singlestoredb/drivers/mariadb.py +0 -40
- singlestoredb/drivers/mysqlconnector.py +0 -49
- singlestoredb/drivers/mysqldb.py +0 -60
- singlestoredb/drivers/pymysql.py +0 -37
- singlestoredb/drivers/pymysqlsv.py +0 -35
- singlestoredb/drivers/pyodbc.py +0 -65
- singlestoredb-0.4.0.dist-info/METADATA +0 -111
- singlestoredb-0.4.0.dist-info/RECORD +0 -86
- /singlestoredb/{clients → fusion/handlers}/__init__.py +0 -0
- /singlestoredb/{clients/pymysqlsv → mysql}/constants/__init__.py +0 -0
- {singlestoredb-0.4.0.dist-info → singlestoredb-1.0.4.dist-info}/LICENSE +0 -0
- {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
|