singlestoredb 1.14.2__cp38-abi3-win_amd64.whl → 1.15.1__cp38-abi3-win_amd64.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_accel.pyd +0 -0
- singlestoredb/__init__.py +2 -2
- singlestoredb/ai/chat.py +14 -0
- singlestoredb/apps/_python_udfs.py +3 -3
- singlestoredb/config.py +11 -0
- singlestoredb/docstring/__init__.py +33 -0
- singlestoredb/docstring/attrdoc.py +126 -0
- singlestoredb/docstring/common.py +230 -0
- singlestoredb/docstring/epydoc.py +267 -0
- singlestoredb/docstring/google.py +412 -0
- singlestoredb/docstring/numpydoc.py +562 -0
- singlestoredb/docstring/parser.py +100 -0
- singlestoredb/docstring/py.typed +1 -0
- singlestoredb/docstring/rest.py +256 -0
- singlestoredb/docstring/tests/__init__.py +1 -0
- singlestoredb/docstring/tests/_pydoctor.py +21 -0
- singlestoredb/docstring/tests/test_epydoc.py +729 -0
- singlestoredb/docstring/tests/test_google.py +1007 -0
- singlestoredb/docstring/tests/test_numpydoc.py +1100 -0
- singlestoredb/docstring/tests/test_parse_from_object.py +109 -0
- singlestoredb/docstring/tests/test_parser.py +248 -0
- singlestoredb/docstring/tests/test_rest.py +547 -0
- singlestoredb/docstring/tests/test_util.py +70 -0
- singlestoredb/docstring/util.py +141 -0
- singlestoredb/functions/decorator.py +51 -31
- singlestoredb/functions/ext/asgi.py +381 -35
- singlestoredb/functions/ext/timer.py +98 -0
- singlestoredb/functions/signature.py +374 -241
- singlestoredb/functions/typing/numpy.py +20 -0
- singlestoredb/functions/typing/pandas.py +2 -0
- singlestoredb/functions/typing/polars.py +2 -0
- singlestoredb/functions/typing/pyarrow.py +2 -0
- singlestoredb/fusion/handlers/files.py +4 -4
- singlestoredb/fusion/handlers/models.py +1 -1
- singlestoredb/fusion/handlers/stage.py +4 -4
- singlestoredb/magics/run_personal.py +82 -1
- singlestoredb/magics/run_shared.py +82 -1
- singlestoredb/management/__init__.py +1 -0
- singlestoredb/management/cluster.py +1 -1
- singlestoredb/management/manager.py +15 -5
- singlestoredb/management/region.py +104 -2
- singlestoredb/management/workspace.py +174 -3
- singlestoredb/tests/ext_funcs/__init__.py +133 -55
- singlestoredb/tests/test.sql +22 -0
- singlestoredb/tests/test_connection.py +18 -8
- singlestoredb/tests/test_ext_func.py +90 -0
- singlestoredb/tests/test_management.py +190 -0
- singlestoredb/tests/test_udf.py +43 -15
- {singlestoredb-1.14.2.dist-info → singlestoredb-1.15.1.dist-info}/METADATA +1 -1
- {singlestoredb-1.14.2.dist-info → singlestoredb-1.15.1.dist-info}/RECORD +55 -31
- /singlestoredb/functions/{typing.py → typing/__init__.py} +0 -0
- {singlestoredb-1.14.2.dist-info → singlestoredb-1.15.1.dist-info}/LICENSE +0 -0
- {singlestoredb-1.14.2.dist-info → singlestoredb-1.15.1.dist-info}/WHEEL +0 -0
- {singlestoredb-1.14.2.dist-info → singlestoredb-1.15.1.dist-info}/entry_points.txt +0 -0
- {singlestoredb-1.14.2.dist-info → singlestoredb-1.15.1.dist-info}/top_level.txt +0 -0
|
@@ -24,7 +24,10 @@ Example
|
|
|
24
24
|
"""
|
|
25
25
|
import argparse
|
|
26
26
|
import asyncio
|
|
27
|
+
import contextvars
|
|
27
28
|
import dataclasses
|
|
29
|
+
import datetime
|
|
30
|
+
import functools
|
|
28
31
|
import importlib.util
|
|
29
32
|
import inspect
|
|
30
33
|
import io
|
|
@@ -37,8 +40,11 @@ import secrets
|
|
|
37
40
|
import sys
|
|
38
41
|
import tempfile
|
|
39
42
|
import textwrap
|
|
43
|
+
import threading
|
|
44
|
+
import time
|
|
40
45
|
import typing
|
|
41
46
|
import urllib
|
|
47
|
+
import uuid
|
|
42
48
|
import zipfile
|
|
43
49
|
import zipimport
|
|
44
50
|
from types import ModuleType
|
|
@@ -66,6 +72,9 @@ from ..signature import get_signature
|
|
|
66
72
|
from ..signature import signature_to_sql
|
|
67
73
|
from ..typing import Masked
|
|
68
74
|
from ..typing import Table
|
|
75
|
+
from .timer import Timer
|
|
76
|
+
from singlestoredb.docstring.parser import parse
|
|
77
|
+
from singlestoredb.functions.dtypes import escape_name
|
|
69
78
|
|
|
70
79
|
try:
|
|
71
80
|
import cloudpickle
|
|
@@ -95,6 +104,15 @@ else:
|
|
|
95
104
|
func_map = itertools.starmap
|
|
96
105
|
|
|
97
106
|
|
|
107
|
+
async def to_thread(
|
|
108
|
+
func: Any, /, *args: Any, **kwargs: Dict[str, Any],
|
|
109
|
+
) -> Any:
|
|
110
|
+
loop = asyncio.get_running_loop()
|
|
111
|
+
ctx = contextvars.copy_context()
|
|
112
|
+
func_call = functools.partial(ctx.run, func, *args, **kwargs)
|
|
113
|
+
return await loop.run_in_executor(None, func_call)
|
|
114
|
+
|
|
115
|
+
|
|
98
116
|
# Use negative values to indicate unsigned ints / binary data / usec time precision
|
|
99
117
|
rowdat_1_type_map = {
|
|
100
118
|
'bool': ft.LONGLONG,
|
|
@@ -251,6 +269,32 @@ def build_tuple(x: Any) -> Any:
|
|
|
251
269
|
return tuple(x) if isinstance(x, Masked) else (x, None)
|
|
252
270
|
|
|
253
271
|
|
|
272
|
+
def cancel_on_event(
|
|
273
|
+
cancel_event: threading.Event,
|
|
274
|
+
) -> None:
|
|
275
|
+
"""
|
|
276
|
+
Cancel the function call if the cancel event is set.
|
|
277
|
+
|
|
278
|
+
Parameters
|
|
279
|
+
----------
|
|
280
|
+
cancel_event : threading.Event
|
|
281
|
+
The event to check for cancellation
|
|
282
|
+
|
|
283
|
+
Raises
|
|
284
|
+
------
|
|
285
|
+
asyncio.CancelledError
|
|
286
|
+
If the cancel event is set
|
|
287
|
+
|
|
288
|
+
"""
|
|
289
|
+
if cancel_event.is_set():
|
|
290
|
+
task = asyncio.current_task()
|
|
291
|
+
if task is not None:
|
|
292
|
+
task.cancel()
|
|
293
|
+
raise asyncio.CancelledError(
|
|
294
|
+
'Function call was cancelled by client',
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
254
298
|
def build_udf_endpoint(
|
|
255
299
|
func: Callable[..., Any],
|
|
256
300
|
returns_data_format: str,
|
|
@@ -273,12 +317,24 @@ def build_udf_endpoint(
|
|
|
273
317
|
"""
|
|
274
318
|
if returns_data_format in ['scalar', 'list']:
|
|
275
319
|
|
|
320
|
+
is_async = asyncio.iscoroutinefunction(func)
|
|
321
|
+
|
|
276
322
|
async def do_func(
|
|
323
|
+
cancel_event: threading.Event,
|
|
324
|
+
timer: Timer,
|
|
277
325
|
row_ids: Sequence[int],
|
|
278
326
|
rows: Sequence[Sequence[Any]],
|
|
279
327
|
) -> Tuple[Sequence[int], List[Tuple[Any, ...]]]:
|
|
280
328
|
'''Call function on given rows of data.'''
|
|
281
|
-
|
|
329
|
+
out = []
|
|
330
|
+
async with timer('call_function'):
|
|
331
|
+
for row in rows:
|
|
332
|
+
cancel_on_event(cancel_event)
|
|
333
|
+
if is_async:
|
|
334
|
+
out.append(await func(*row))
|
|
335
|
+
else:
|
|
336
|
+
out.append(func(*row))
|
|
337
|
+
return row_ids, list(zip(out))
|
|
282
338
|
|
|
283
339
|
return do_func
|
|
284
340
|
|
|
@@ -307,8 +363,11 @@ def build_vector_udf_endpoint(
|
|
|
307
363
|
"""
|
|
308
364
|
masks = get_masked_params(func)
|
|
309
365
|
array_cls = get_array_class(returns_data_format)
|
|
366
|
+
is_async = asyncio.iscoroutinefunction(func)
|
|
310
367
|
|
|
311
368
|
async def do_func(
|
|
369
|
+
cancel_event: threading.Event,
|
|
370
|
+
timer: Timer,
|
|
312
371
|
row_ids: Sequence[int],
|
|
313
372
|
cols: Sequence[Tuple[Sequence[Any], Optional[Sequence[bool]]]],
|
|
314
373
|
) -> Tuple[
|
|
@@ -319,10 +378,19 @@ def build_vector_udf_endpoint(
|
|
|
319
378
|
row_ids = array_cls(row_ids)
|
|
320
379
|
|
|
321
380
|
# Call the function with `cols` as the function parameters
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
381
|
+
async with timer('call_function'):
|
|
382
|
+
if cols and cols[0]:
|
|
383
|
+
if is_async:
|
|
384
|
+
out = await func(*[x if m else x[0] for x, m in zip(cols, masks)])
|
|
385
|
+
else:
|
|
386
|
+
out = func(*[x if m else x[0] for x, m in zip(cols, masks)])
|
|
387
|
+
else:
|
|
388
|
+
if is_async:
|
|
389
|
+
out = await func()
|
|
390
|
+
else:
|
|
391
|
+
out = func()
|
|
392
|
+
|
|
393
|
+
cancel_on_event(cancel_event)
|
|
326
394
|
|
|
327
395
|
# Single masked value
|
|
328
396
|
if isinstance(out, Masked):
|
|
@@ -360,7 +428,11 @@ def build_tvf_endpoint(
|
|
|
360
428
|
"""
|
|
361
429
|
if returns_data_format in ['scalar', 'list']:
|
|
362
430
|
|
|
431
|
+
is_async = asyncio.iscoroutinefunction(func)
|
|
432
|
+
|
|
363
433
|
async def do_func(
|
|
434
|
+
cancel_event: threading.Event,
|
|
435
|
+
timer: Timer,
|
|
364
436
|
row_ids: Sequence[int],
|
|
365
437
|
rows: Sequence[Sequence[Any]],
|
|
366
438
|
) -> Tuple[Sequence[int], List[Tuple[Any, ...]]]:
|
|
@@ -368,9 +440,15 @@ def build_tvf_endpoint(
|
|
|
368
440
|
out_ids: List[int] = []
|
|
369
441
|
out = []
|
|
370
442
|
# Call function on each row of data
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
443
|
+
async with timer('call_function'):
|
|
444
|
+
for i, row in zip(row_ids, rows):
|
|
445
|
+
cancel_on_event(cancel_event)
|
|
446
|
+
if is_async:
|
|
447
|
+
res = await func(*row)
|
|
448
|
+
else:
|
|
449
|
+
res = func(*row)
|
|
450
|
+
out.extend(as_list_of_tuples(res))
|
|
451
|
+
out_ids.extend([row_ids[i]] * (len(out)-len(out_ids)))
|
|
374
452
|
return out_ids, out
|
|
375
453
|
|
|
376
454
|
return do_func
|
|
@@ -402,6 +480,8 @@ def build_vector_tvf_endpoint(
|
|
|
402
480
|
array_cls = get_array_class(returns_data_format)
|
|
403
481
|
|
|
404
482
|
async def do_func(
|
|
483
|
+
cancel_event: threading.Event,
|
|
484
|
+
timer: Timer,
|
|
405
485
|
row_ids: Sequence[int],
|
|
406
486
|
cols: Sequence[Tuple[Sequence[Any], Optional[Sequence[bool]]]],
|
|
407
487
|
) -> Tuple[
|
|
@@ -413,13 +493,28 @@ def build_vector_tvf_endpoint(
|
|
|
413
493
|
# each result row, so we just have to use the same
|
|
414
494
|
# row ID for all rows in the result.
|
|
415
495
|
|
|
496
|
+
is_async = asyncio.iscoroutinefunction(func)
|
|
497
|
+
|
|
416
498
|
# Call function on each column of data
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
499
|
+
async with timer('call_function'):
|
|
500
|
+
if cols and cols[0]:
|
|
501
|
+
if is_async:
|
|
502
|
+
func_res = await func(
|
|
503
|
+
*[x if m else x[0] for x, m in zip(cols, masks)],
|
|
504
|
+
)
|
|
505
|
+
else:
|
|
506
|
+
func_res = func(
|
|
507
|
+
*[x if m else x[0] for x, m in zip(cols, masks)],
|
|
508
|
+
)
|
|
509
|
+
else:
|
|
510
|
+
if is_async:
|
|
511
|
+
func_res = await func()
|
|
512
|
+
else:
|
|
513
|
+
func_res = func()
|
|
514
|
+
|
|
515
|
+
res = get_dataframe_columns(func_res)
|
|
516
|
+
|
|
517
|
+
cancel_on_event(cancel_event)
|
|
423
518
|
|
|
424
519
|
# Generate row IDs
|
|
425
520
|
if isinstance(res[0], Masked):
|
|
@@ -445,6 +540,8 @@ def make_func(
|
|
|
445
540
|
Name of the function to create
|
|
446
541
|
func : Callable
|
|
447
542
|
The function to call as the endpoint
|
|
543
|
+
database : str, optional
|
|
544
|
+
The database to use for the function definition
|
|
448
545
|
|
|
449
546
|
Returns
|
|
450
547
|
-------
|
|
@@ -458,6 +555,10 @@ def make_func(
|
|
|
458
555
|
function_type = sig.get('function_type', 'udf')
|
|
459
556
|
args_data_format = sig.get('args_data_format', 'scalar')
|
|
460
557
|
returns_data_format = sig.get('returns_data_format', 'scalar')
|
|
558
|
+
timeout = (
|
|
559
|
+
func._singlestoredb_attrs.get('timeout') or # type: ignore
|
|
560
|
+
get_option('external_function.timeout')
|
|
561
|
+
)
|
|
461
562
|
|
|
462
563
|
if function_type == 'tvf':
|
|
463
564
|
do_func = build_tvf_endpoint(func, returns_data_format)
|
|
@@ -477,6 +578,12 @@ def make_func(
|
|
|
477
578
|
# Set function type
|
|
478
579
|
info['function_type'] = function_type
|
|
479
580
|
|
|
581
|
+
# Set timeout
|
|
582
|
+
info['timeout'] = max(timeout, 1)
|
|
583
|
+
|
|
584
|
+
# Set async flag
|
|
585
|
+
info['is_async'] = asyncio.iscoroutinefunction(func)
|
|
586
|
+
|
|
480
587
|
# Setup argument types for rowdat_1 parser
|
|
481
588
|
colspec = []
|
|
482
589
|
for x in sig['args']:
|
|
@@ -498,6 +605,43 @@ def make_func(
|
|
|
498
605
|
return do_func, info
|
|
499
606
|
|
|
500
607
|
|
|
608
|
+
async def cancel_on_timeout(timeout: int) -> None:
|
|
609
|
+
"""Cancel request if it takes too long."""
|
|
610
|
+
await asyncio.sleep(timeout)
|
|
611
|
+
raise asyncio.CancelledError(
|
|
612
|
+
'Function call was cancelled due to timeout',
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
async def cancel_on_disconnect(
|
|
617
|
+
receive: Callable[..., Awaitable[Any]],
|
|
618
|
+
) -> None:
|
|
619
|
+
"""Cancel request if client disconnects."""
|
|
620
|
+
while True:
|
|
621
|
+
message = await receive()
|
|
622
|
+
if message.get('type', '') == 'http.disconnect':
|
|
623
|
+
raise asyncio.CancelledError(
|
|
624
|
+
'Function call was cancelled by client',
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
async def cancel_all_tasks(tasks: Iterable[asyncio.Task[Any]]) -> None:
|
|
629
|
+
"""Cancel all tasks."""
|
|
630
|
+
for task in tasks:
|
|
631
|
+
task.cancel()
|
|
632
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def start_counter() -> float:
|
|
636
|
+
"""Start a timer and return the start time."""
|
|
637
|
+
return time.perf_counter()
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def end_counter(start: float) -> float:
|
|
641
|
+
"""End a timer and return the elapsed time."""
|
|
642
|
+
return time.perf_counter() - start
|
|
643
|
+
|
|
644
|
+
|
|
501
645
|
class Application(object):
|
|
502
646
|
"""
|
|
503
647
|
Create an external function application.
|
|
@@ -534,6 +678,8 @@ class Application(object):
|
|
|
534
678
|
link_credentials : Dict[str, Any], optional
|
|
535
679
|
The CREDENTIALS section of a LINK definition. This dictionary gets
|
|
536
680
|
converted to JSON for the CREATE LINK call.
|
|
681
|
+
function_database : str, optional
|
|
682
|
+
The database to use for external function definitions.
|
|
537
683
|
|
|
538
684
|
"""
|
|
539
685
|
|
|
@@ -676,6 +822,7 @@ class Application(object):
|
|
|
676
822
|
invoke_path = ('invoke',)
|
|
677
823
|
show_create_function_path = ('show', 'create_function')
|
|
678
824
|
show_function_info_path = ('show', 'function_info')
|
|
825
|
+
status = ('status',)
|
|
679
826
|
|
|
680
827
|
def __init__(
|
|
681
828
|
self,
|
|
@@ -698,6 +845,7 @@ class Application(object):
|
|
|
698
845
|
link_credentials: Optional[Dict[str, Any]] = None,
|
|
699
846
|
name_prefix: str = get_option('external_function.name_prefix'),
|
|
700
847
|
name_suffix: str = get_option('external_function.name_suffix'),
|
|
848
|
+
function_database: Optional[str] = None,
|
|
701
849
|
) -> None:
|
|
702
850
|
if link_name and (link_config or link_credentials):
|
|
703
851
|
raise ValueError(
|
|
@@ -804,6 +952,7 @@ class Application(object):
|
|
|
804
952
|
self.link_credentials = link_credentials
|
|
805
953
|
self.endpoints = endpoints
|
|
806
954
|
self.external_functions = external_functions
|
|
955
|
+
self.function_database = function_database
|
|
807
956
|
|
|
808
957
|
async def __call__(
|
|
809
958
|
self,
|
|
@@ -824,6 +973,21 @@ class Application(object):
|
|
|
824
973
|
Function to send response information
|
|
825
974
|
|
|
826
975
|
'''
|
|
976
|
+
request_id = str(uuid.uuid4())
|
|
977
|
+
|
|
978
|
+
timer = Timer(
|
|
979
|
+
id=request_id,
|
|
980
|
+
timestamp=datetime.datetime.now(
|
|
981
|
+
datetime.timezone.utc,
|
|
982
|
+
).strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
|
|
983
|
+
)
|
|
984
|
+
call_timer = Timer(
|
|
985
|
+
id=request_id,
|
|
986
|
+
timestamp=datetime.datetime.now(
|
|
987
|
+
datetime.timezone.utc,
|
|
988
|
+
).strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
|
|
989
|
+
)
|
|
990
|
+
|
|
827
991
|
assert scope['type'] == 'http'
|
|
828
992
|
|
|
829
993
|
method = scope['method']
|
|
@@ -837,6 +1001,10 @@ class Application(object):
|
|
|
837
1001
|
accepts = headers.get(b'accepts', content_type)
|
|
838
1002
|
func_name = headers.get(b's2-ef-name', b'')
|
|
839
1003
|
func_endpoint = self.endpoints.get(func_name)
|
|
1004
|
+
ignore_cancel = headers.get(b's2-ef-ignore-cancel', b'false') == b'true'
|
|
1005
|
+
|
|
1006
|
+
timer.metadata['function'] = func_name.decode('utf-8') if func_name else ''
|
|
1007
|
+
call_timer.metadata['function'] = timer.metadata['function']
|
|
840
1008
|
|
|
841
1009
|
func = None
|
|
842
1010
|
func_info: Dict[str, Any] = {}
|
|
@@ -845,35 +1013,121 @@ class Application(object):
|
|
|
845
1013
|
|
|
846
1014
|
# Call the endpoint
|
|
847
1015
|
if method == 'POST' and func is not None and path == self.invoke_path:
|
|
1016
|
+
|
|
1017
|
+
logger.info(
|
|
1018
|
+
json.dumps({
|
|
1019
|
+
'type': 'function_call',
|
|
1020
|
+
'id': request_id,
|
|
1021
|
+
'name': func_name.decode('utf-8'),
|
|
1022
|
+
'content_type': content_type.decode('utf-8'),
|
|
1023
|
+
'accepts': accepts.decode('utf-8'),
|
|
1024
|
+
}),
|
|
1025
|
+
)
|
|
1026
|
+
|
|
848
1027
|
args_data_format = func_info['args_data_format']
|
|
849
1028
|
returns_data_format = func_info['returns_data_format']
|
|
850
1029
|
data = []
|
|
851
1030
|
more_body = True
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
1031
|
+
with timer('receive_data'):
|
|
1032
|
+
while more_body:
|
|
1033
|
+
request = await receive()
|
|
1034
|
+
if request.get('type', '') == 'http.disconnect':
|
|
1035
|
+
raise RuntimeError('client disconnected')
|
|
1036
|
+
data.append(request['body'])
|
|
1037
|
+
more_body = request.get('more_body', False)
|
|
856
1038
|
|
|
857
1039
|
data_version = headers.get(b's2-ef-version', b'')
|
|
858
1040
|
input_handler = self.handlers[(content_type, data_version, args_data_format)]
|
|
859
1041
|
output_handler = self.handlers[(accepts, data_version, returns_data_format)]
|
|
860
1042
|
|
|
861
1043
|
try:
|
|
862
|
-
|
|
863
|
-
|
|
1044
|
+
all_tasks = []
|
|
1045
|
+
result = []
|
|
1046
|
+
|
|
1047
|
+
cancel_event = threading.Event()
|
|
1048
|
+
|
|
1049
|
+
with timer('parse_input'):
|
|
1050
|
+
inputs = input_handler['load']( # type: ignore
|
|
864
1051
|
func_info['colspec'], b''.join(data),
|
|
1052
|
+
)
|
|
1053
|
+
|
|
1054
|
+
func_task = asyncio.create_task(
|
|
1055
|
+
func(cancel_event, call_timer, *inputs)
|
|
1056
|
+
if func_info['is_async']
|
|
1057
|
+
else to_thread(
|
|
1058
|
+
lambda: asyncio.run(
|
|
1059
|
+
func(cancel_event, call_timer, *inputs),
|
|
1060
|
+
),
|
|
865
1061
|
),
|
|
866
1062
|
)
|
|
867
|
-
|
|
868
|
-
|
|
1063
|
+
disconnect_task = asyncio.create_task(
|
|
1064
|
+
asyncio.sleep(int(1e9))
|
|
1065
|
+
if ignore_cancel else cancel_on_disconnect(receive),
|
|
1066
|
+
)
|
|
1067
|
+
timeout_task = asyncio.create_task(
|
|
1068
|
+
cancel_on_timeout(func_info['timeout']),
|
|
869
1069
|
)
|
|
1070
|
+
|
|
1071
|
+
all_tasks += [func_task, disconnect_task, timeout_task]
|
|
1072
|
+
|
|
1073
|
+
async with timer('function_wrapper'):
|
|
1074
|
+
done, pending = await asyncio.wait(
|
|
1075
|
+
all_tasks, return_when=asyncio.FIRST_COMPLETED,
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
await cancel_all_tasks(pending)
|
|
1079
|
+
|
|
1080
|
+
for task in done:
|
|
1081
|
+
if task is disconnect_task:
|
|
1082
|
+
cancel_event.set()
|
|
1083
|
+
raise asyncio.CancelledError(
|
|
1084
|
+
'Function call was cancelled by client disconnect',
|
|
1085
|
+
)
|
|
1086
|
+
|
|
1087
|
+
elif task is timeout_task:
|
|
1088
|
+
cancel_event.set()
|
|
1089
|
+
raise asyncio.TimeoutError(
|
|
1090
|
+
'Function call was cancelled due to timeout',
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
elif task is func_task:
|
|
1094
|
+
result.extend(task.result())
|
|
1095
|
+
|
|
1096
|
+
with timer('format_output'):
|
|
1097
|
+
body = output_handler['dump'](
|
|
1098
|
+
[x[1] for x in func_info['returns']], *result, # type: ignore
|
|
1099
|
+
)
|
|
1100
|
+
|
|
870
1101
|
await send(output_handler['response'])
|
|
871
1102
|
|
|
1103
|
+
except asyncio.TimeoutError:
|
|
1104
|
+
logging.exception(
|
|
1105
|
+
'Timeout in function call: ' + func_name.decode('utf-8'),
|
|
1106
|
+
)
|
|
1107
|
+
body = (
|
|
1108
|
+
'[TimeoutError] Function call timed out after ' +
|
|
1109
|
+
str(func_info['timeout']) +
|
|
1110
|
+
' seconds'
|
|
1111
|
+
).encode('utf-8')
|
|
1112
|
+
await send(self.error_response_dict)
|
|
1113
|
+
|
|
1114
|
+
except asyncio.CancelledError:
|
|
1115
|
+
logging.exception(
|
|
1116
|
+
'Function call cancelled: ' + func_name.decode('utf-8'),
|
|
1117
|
+
)
|
|
1118
|
+
body = b'[CancelledError] Function call was cancelled'
|
|
1119
|
+
await send(self.error_response_dict)
|
|
1120
|
+
|
|
872
1121
|
except Exception as e:
|
|
873
|
-
logging.exception(
|
|
1122
|
+
logging.exception(
|
|
1123
|
+
'Error in function call: ' + func_name.decode('utf-8'),
|
|
1124
|
+
)
|
|
874
1125
|
body = f'[{type(e).__name__}] {str(e).strip()}'.encode('utf-8')
|
|
875
1126
|
await send(self.error_response_dict)
|
|
876
1127
|
|
|
1128
|
+
finally:
|
|
1129
|
+
await cancel_all_tasks(all_tasks)
|
|
1130
|
+
|
|
877
1131
|
# Handle api reflection
|
|
878
1132
|
elif method == 'GET' and path == self.show_create_function_path:
|
|
879
1133
|
host = headers.get(b'host', b'localhost:80')
|
|
@@ -887,6 +1141,7 @@ class Application(object):
|
|
|
887
1141
|
endpoint_info['signature'],
|
|
888
1142
|
url=self.url or reflected_url,
|
|
889
1143
|
data_format=self.data_format,
|
|
1144
|
+
database=self.function_database or None,
|
|
890
1145
|
),
|
|
891
1146
|
)
|
|
892
1147
|
body = '\n'.join(syntax).encode('utf-8')
|
|
@@ -899,15 +1154,26 @@ class Application(object):
|
|
|
899
1154
|
body = json.dumps(dict(functions=functions)).encode('utf-8')
|
|
900
1155
|
await send(self.text_response_dict)
|
|
901
1156
|
|
|
1157
|
+
# Return status
|
|
1158
|
+
elif method == 'GET' and path == self.status:
|
|
1159
|
+
body = json.dumps(dict(status='ok')).encode('utf-8')
|
|
1160
|
+
await send(self.text_response_dict)
|
|
1161
|
+
|
|
902
1162
|
# Path not found
|
|
903
1163
|
else:
|
|
904
1164
|
body = b''
|
|
905
1165
|
await send(self.path_not_found_response_dict)
|
|
906
1166
|
|
|
907
1167
|
# Send body
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1168
|
+
with timer('send_response'):
|
|
1169
|
+
out = self.body_response_dict.copy()
|
|
1170
|
+
out['body'] = body
|
|
1171
|
+
await send(out)
|
|
1172
|
+
|
|
1173
|
+
for k, v in call_timer.metrics.items():
|
|
1174
|
+
timer.metrics[k] = v
|
|
1175
|
+
|
|
1176
|
+
timer.finish()
|
|
911
1177
|
|
|
912
1178
|
def _create_link(
|
|
913
1179
|
self,
|
|
@@ -935,20 +1201,27 @@ class Application(object):
|
|
|
935
1201
|
def _locate_app_functions(self, cur: Any) -> Tuple[Set[str], Set[str]]:
|
|
936
1202
|
"""Locate all current functions and links belonging to this app."""
|
|
937
1203
|
funcs, links = set(), set()
|
|
938
|
-
|
|
1204
|
+
if self.function_database:
|
|
1205
|
+
database_prefix = escape_name(self.function_database) + '.'
|
|
1206
|
+
cur.execute(f'SHOW FUNCTIONS IN {escape_name(self.function_database)}')
|
|
1207
|
+
else:
|
|
1208
|
+
database_prefix = ''
|
|
1209
|
+
cur.execute('SHOW FUNCTIONS')
|
|
1210
|
+
|
|
939
1211
|
for row in list(cur):
|
|
940
1212
|
name, ftype, link = row[0], row[1], row[-1]
|
|
941
1213
|
# Only look at external functions
|
|
942
1214
|
if 'external' not in ftype.lower():
|
|
943
1215
|
continue
|
|
944
1216
|
# See if function URL matches url
|
|
945
|
-
cur.execute(f'SHOW CREATE FUNCTION
|
|
1217
|
+
cur.execute(f'SHOW CREATE FUNCTION {database_prefix}{escape_name(name)}')
|
|
946
1218
|
for fname, _, code, *_ in list(cur):
|
|
947
1219
|
m = re.search(r" (?:\w+) (?:SERVICE|MANAGED) '([^']+)'", code)
|
|
948
1220
|
if m and m.group(1) == self.url:
|
|
949
|
-
funcs.add(fname)
|
|
1221
|
+
funcs.add(f'{database_prefix}{escape_name(fname)}')
|
|
950
1222
|
if link and re.match(r'^py_ext_func_link_\S{14}$', link):
|
|
951
1223
|
links.add(link)
|
|
1224
|
+
|
|
952
1225
|
return funcs, links
|
|
953
1226
|
|
|
954
1227
|
def get_function_info(
|
|
@@ -964,20 +1237,73 @@ class Application(object):
|
|
|
964
1237
|
functions = {}
|
|
965
1238
|
no_default = object()
|
|
966
1239
|
|
|
967
|
-
for
|
|
1240
|
+
# Generate CREATE FUNCTION SQL for each function using get_create_functions
|
|
1241
|
+
create_sqls = self.get_create_functions(replace=True)
|
|
1242
|
+
sql_map = {}
|
|
1243
|
+
for (_, info), sql in zip(self.endpoints.values(), create_sqls):
|
|
1244
|
+
sig = info['signature']
|
|
1245
|
+
sql_map[sig['name']] = sql
|
|
1246
|
+
|
|
1247
|
+
for key, (func, info) in self.endpoints.items():
|
|
1248
|
+
# Get info from docstring
|
|
1249
|
+
doc_summary = ''
|
|
1250
|
+
doc_long_description = ''
|
|
1251
|
+
doc_params = {}
|
|
1252
|
+
doc_returns = None
|
|
1253
|
+
doc_examples = []
|
|
1254
|
+
if func.__doc__:
|
|
1255
|
+
try:
|
|
1256
|
+
docs = parse(func.__doc__)
|
|
1257
|
+
doc_params = {p.arg_name: p for p in docs.params}
|
|
1258
|
+
doc_returns = docs.returns
|
|
1259
|
+
if not docs.short_description and docs.long_description:
|
|
1260
|
+
doc_summary = docs.long_description or ''
|
|
1261
|
+
else:
|
|
1262
|
+
doc_summary = docs.short_description or ''
|
|
1263
|
+
doc_long_description = docs.long_description or ''
|
|
1264
|
+
for ex in docs.examples:
|
|
1265
|
+
ex_dict: Dict[str, Any] = {
|
|
1266
|
+
'description': None,
|
|
1267
|
+
'code': None,
|
|
1268
|
+
'output': None,
|
|
1269
|
+
}
|
|
1270
|
+
if ex.description:
|
|
1271
|
+
ex_dict['description'] = ex.description
|
|
1272
|
+
if ex.snippet:
|
|
1273
|
+
code, output = [], []
|
|
1274
|
+
for line in ex.snippet.split('\n'):
|
|
1275
|
+
line = line.rstrip()
|
|
1276
|
+
if re.match(r'^(\w+>|>>>|\.\.\.)', line):
|
|
1277
|
+
code.append(line)
|
|
1278
|
+
else:
|
|
1279
|
+
output.append(line)
|
|
1280
|
+
ex_dict['code'] = '\n'.join(code) or None
|
|
1281
|
+
ex_dict['output'] = '\n'.join(output) or None
|
|
1282
|
+
if ex.post_snippet:
|
|
1283
|
+
ex_dict['postscript'] = ex.post_snippet
|
|
1284
|
+
doc_examples.append(ex_dict)
|
|
1285
|
+
|
|
1286
|
+
except Exception as e:
|
|
1287
|
+
logger.warning(
|
|
1288
|
+
f'Could not parse docstring for function {key}: {e}',
|
|
1289
|
+
)
|
|
1290
|
+
|
|
968
1291
|
if not func_name or key == func_name:
|
|
969
1292
|
sig = info['signature']
|
|
970
1293
|
args = []
|
|
971
1294
|
|
|
972
1295
|
# Function arguments
|
|
973
|
-
for a in sig.get('args', []):
|
|
1296
|
+
for i, a in enumerate(sig.get('args', [])):
|
|
1297
|
+
name = a['name']
|
|
974
1298
|
dtype = a['dtype']
|
|
975
1299
|
nullable = '?' in dtype
|
|
976
1300
|
args.append(
|
|
977
1301
|
dict(
|
|
978
|
-
name=
|
|
1302
|
+
name=name,
|
|
979
1303
|
dtype=dtype.replace('?', ''),
|
|
980
1304
|
nullable=nullable,
|
|
1305
|
+
description=(doc_params[name].description or '')
|
|
1306
|
+
if name in doc_params else '',
|
|
981
1307
|
),
|
|
982
1308
|
)
|
|
983
1309
|
if a.get('default', no_default) is not no_default:
|
|
@@ -994,6 +1320,8 @@ class Application(object):
|
|
|
994
1320
|
dict(
|
|
995
1321
|
dtype=dtype.replace('?', ''),
|
|
996
1322
|
nullable=nullable,
|
|
1323
|
+
description=doc_returns.description
|
|
1324
|
+
if doc_returns else '',
|
|
997
1325
|
),
|
|
998
1326
|
)
|
|
999
1327
|
if a.get('name', None):
|
|
@@ -1001,8 +1329,15 @@ class Application(object):
|
|
|
1001
1329
|
if a.get('default', no_default) is not no_default:
|
|
1002
1330
|
returns[-1]['default'] = a['default']
|
|
1003
1331
|
|
|
1332
|
+
sql = sql_map.get(sig['name'], '')
|
|
1004
1333
|
functions[sig['name']] = dict(
|
|
1005
|
-
args=args,
|
|
1334
|
+
args=args,
|
|
1335
|
+
returns=returns,
|
|
1336
|
+
function_type=info['function_type'],
|
|
1337
|
+
sql_statement=sql,
|
|
1338
|
+
summary=doc_summary,
|
|
1339
|
+
long_description=doc_long_description,
|
|
1340
|
+
examples=doc_examples,
|
|
1006
1341
|
)
|
|
1007
1342
|
|
|
1008
1343
|
return functions
|
|
@@ -1043,6 +1378,7 @@ class Application(object):
|
|
|
1043
1378
|
app_mode=self.app_mode,
|
|
1044
1379
|
replace=replace,
|
|
1045
1380
|
link=link or None,
|
|
1381
|
+
database=self.function_database or None,
|
|
1046
1382
|
),
|
|
1047
1383
|
)
|
|
1048
1384
|
|
|
@@ -1072,7 +1408,7 @@ class Application(object):
|
|
|
1072
1408
|
if replace:
|
|
1073
1409
|
funcs, links = self._locate_app_functions(cur)
|
|
1074
1410
|
for fname in funcs:
|
|
1075
|
-
cur.execute(f'DROP FUNCTION IF EXISTS
|
|
1411
|
+
cur.execute(f'DROP FUNCTION IF EXISTS {fname}')
|
|
1076
1412
|
for link in links:
|
|
1077
1413
|
cur.execute(f'DROP LINK {link}')
|
|
1078
1414
|
for func in self.get_create_functions(replace=replace):
|
|
@@ -1098,7 +1434,7 @@ class Application(object):
|
|
|
1098
1434
|
with conn.cursor() as cur:
|
|
1099
1435
|
funcs, links = self._locate_app_functions(cur)
|
|
1100
1436
|
for fname in funcs:
|
|
1101
|
-
cur.execute(f'DROP FUNCTION IF EXISTS
|
|
1437
|
+
cur.execute(f'DROP FUNCTION IF EXISTS {fname}')
|
|
1102
1438
|
for link in links:
|
|
1103
1439
|
cur.execute(f'DROP LINK {link}')
|
|
1104
1440
|
|
|
@@ -1155,6 +1491,7 @@ class Application(object):
|
|
|
1155
1491
|
b'accepts': accepts[data_format.lower()],
|
|
1156
1492
|
b's2-ef-name': name.encode('utf-8'),
|
|
1157
1493
|
b's2-ef-version': data_version.encode('utf-8'),
|
|
1494
|
+
b's2-ef-ignore-cancel': b'true',
|
|
1158
1495
|
},
|
|
1159
1496
|
)
|
|
1160
1497
|
|
|
@@ -1419,6 +1756,14 @@ def main(argv: Optional[List[str]] = None) -> None:
|
|
|
1419
1756
|
),
|
|
1420
1757
|
help='Suffix to add to function names',
|
|
1421
1758
|
)
|
|
1759
|
+
parser.add_argument(
|
|
1760
|
+
'--function-database', metavar='function_database',
|
|
1761
|
+
default=defaults.get(
|
|
1762
|
+
'function_database',
|
|
1763
|
+
get_option('external_function.function_database'),
|
|
1764
|
+
),
|
|
1765
|
+
help='Database to use for the function definition',
|
|
1766
|
+
)
|
|
1422
1767
|
parser.add_argument(
|
|
1423
1768
|
'functions', metavar='module.or.func.path', nargs='*',
|
|
1424
1769
|
help='functions or modules to export in UDF server',
|
|
@@ -1518,6 +1863,7 @@ def main(argv: Optional[List[str]] = None) -> None:
|
|
|
1518
1863
|
app_mode='remote',
|
|
1519
1864
|
name_prefix=args.name_prefix,
|
|
1520
1865
|
name_suffix=args.name_suffix,
|
|
1866
|
+
function_database=args.function_database or None,
|
|
1521
1867
|
)
|
|
1522
1868
|
|
|
1523
1869
|
funcs = app.get_create_functions(replace=args.replace_existing)
|