singlestoredb 1.14.2__cp38-abi3-win32.whl → 1.15.1__cp38-abi3-win32.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 (55) hide show
  1. _singlestoredb_accel.pyd +0 -0
  2. singlestoredb/__init__.py +2 -2
  3. singlestoredb/ai/chat.py +14 -0
  4. singlestoredb/apps/_python_udfs.py +3 -3
  5. singlestoredb/config.py +11 -0
  6. singlestoredb/docstring/__init__.py +33 -0
  7. singlestoredb/docstring/attrdoc.py +126 -0
  8. singlestoredb/docstring/common.py +230 -0
  9. singlestoredb/docstring/epydoc.py +267 -0
  10. singlestoredb/docstring/google.py +412 -0
  11. singlestoredb/docstring/numpydoc.py +562 -0
  12. singlestoredb/docstring/parser.py +100 -0
  13. singlestoredb/docstring/py.typed +1 -0
  14. singlestoredb/docstring/rest.py +256 -0
  15. singlestoredb/docstring/tests/__init__.py +1 -0
  16. singlestoredb/docstring/tests/_pydoctor.py +21 -0
  17. singlestoredb/docstring/tests/test_epydoc.py +729 -0
  18. singlestoredb/docstring/tests/test_google.py +1007 -0
  19. singlestoredb/docstring/tests/test_numpydoc.py +1100 -0
  20. singlestoredb/docstring/tests/test_parse_from_object.py +109 -0
  21. singlestoredb/docstring/tests/test_parser.py +248 -0
  22. singlestoredb/docstring/tests/test_rest.py +547 -0
  23. singlestoredb/docstring/tests/test_util.py +70 -0
  24. singlestoredb/docstring/util.py +141 -0
  25. singlestoredb/functions/decorator.py +51 -31
  26. singlestoredb/functions/ext/asgi.py +381 -35
  27. singlestoredb/functions/ext/timer.py +98 -0
  28. singlestoredb/functions/signature.py +374 -241
  29. singlestoredb/functions/typing/numpy.py +20 -0
  30. singlestoredb/functions/typing/pandas.py +2 -0
  31. singlestoredb/functions/typing/polars.py +2 -0
  32. singlestoredb/functions/typing/pyarrow.py +2 -0
  33. singlestoredb/fusion/handlers/files.py +4 -4
  34. singlestoredb/fusion/handlers/models.py +1 -1
  35. singlestoredb/fusion/handlers/stage.py +4 -4
  36. singlestoredb/magics/run_personal.py +82 -1
  37. singlestoredb/magics/run_shared.py +82 -1
  38. singlestoredb/management/__init__.py +1 -0
  39. singlestoredb/management/cluster.py +1 -1
  40. singlestoredb/management/manager.py +15 -5
  41. singlestoredb/management/region.py +104 -2
  42. singlestoredb/management/workspace.py +174 -3
  43. singlestoredb/tests/ext_funcs/__init__.py +133 -55
  44. singlestoredb/tests/test.sql +22 -0
  45. singlestoredb/tests/test_connection.py +18 -8
  46. singlestoredb/tests/test_ext_func.py +90 -0
  47. singlestoredb/tests/test_management.py +190 -0
  48. singlestoredb/tests/test_udf.py +43 -15
  49. {singlestoredb-1.14.2.dist-info → singlestoredb-1.15.1.dist-info}/METADATA +1 -1
  50. {singlestoredb-1.14.2.dist-info → singlestoredb-1.15.1.dist-info}/RECORD +55 -31
  51. /singlestoredb/functions/{typing.py → typing/__init__.py} +0 -0
  52. {singlestoredb-1.14.2.dist-info → singlestoredb-1.15.1.dist-info}/LICENSE +0 -0
  53. {singlestoredb-1.14.2.dist-info → singlestoredb-1.15.1.dist-info}/WHEEL +0 -0
  54. {singlestoredb-1.14.2.dist-info → singlestoredb-1.15.1.dist-info}/entry_points.txt +0 -0
  55. {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
- return row_ids, [as_tuple(x) for x in zip(func_map(func, rows))]
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
- if cols and cols[0]:
323
- out = func(*[x if m else x[0] for x, m in zip(cols, masks)])
324
- else:
325
- out = func()
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
- for i, res in zip(row_ids, func_map(func, rows)):
372
- out.extend(as_list_of_tuples(res))
373
- out_ids.extend([row_ids[i]] * (len(out)-len(out_ids)))
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
- if cols and cols[0]:
418
- res = get_dataframe_columns(
419
- func(*[x if m else x[0] for x, m in zip(cols, masks)]),
420
- )
421
- else:
422
- res = get_dataframe_columns(func())
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
- while more_body:
853
- request = await receive()
854
- data.append(request['body'])
855
- more_body = request.get('more_body', False)
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
- out = await func(
863
- *input_handler['load']( # type: ignore
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
- body = output_handler['dump'](
868
- [x[1] for x in func_info['returns']], *out, # type: ignore
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('Error in function call')
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
- out = self.body_response_dict.copy()
909
- out['body'] = body
910
- await send(out)
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
- cur.execute('SHOW FUNCTIONS')
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 `{name}`')
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 key, (_, info) in self.endpoints.items():
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=a['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, returns=returns, function_type=info['function_type'],
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 `{fname}`')
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 `{fname}`')
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)