singlestoredb 1.12.4__py3-none-any.whl → 1.13.0__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 (29) hide show
  1. singlestoredb/__init__.py +1 -1
  2. singlestoredb/apps/__init__.py +1 -0
  3. singlestoredb/apps/_config.py +6 -0
  4. singlestoredb/apps/_connection_info.py +8 -0
  5. singlestoredb/apps/_python_udfs.py +85 -0
  6. singlestoredb/config.py +14 -2
  7. singlestoredb/functions/__init__.py +11 -1
  8. singlestoredb/functions/decorator.py +102 -252
  9. singlestoredb/functions/dtypes.py +545 -198
  10. singlestoredb/functions/ext/asgi.py +288 -90
  11. singlestoredb/functions/ext/json.py +29 -36
  12. singlestoredb/functions/ext/mmap.py +1 -1
  13. singlestoredb/functions/ext/rowdat_1.py +50 -70
  14. singlestoredb/functions/signature.py +816 -144
  15. singlestoredb/functions/typing.py +41 -0
  16. singlestoredb/functions/utils.py +342 -0
  17. singlestoredb/http/connection.py +3 -1
  18. singlestoredb/management/manager.py +6 -1
  19. singlestoredb/management/utils.py +2 -2
  20. singlestoredb/tests/ext_funcs/__init__.py +476 -237
  21. singlestoredb/tests/test_ext_func.py +192 -3
  22. singlestoredb/tests/test_udf.py +101 -131
  23. singlestoredb/tests/test_udf_returns.py +459 -0
  24. {singlestoredb-1.12.4.dist-info → singlestoredb-1.13.0.dist-info}/METADATA +2 -1
  25. {singlestoredb-1.12.4.dist-info → singlestoredb-1.13.0.dist-info}/RECORD +29 -25
  26. {singlestoredb-1.12.4.dist-info → singlestoredb-1.13.0.dist-info}/LICENSE +0 -0
  27. {singlestoredb-1.12.4.dist-info → singlestoredb-1.13.0.dist-info}/WHEEL +0 -0
  28. {singlestoredb-1.12.4.dist-info → singlestoredb-1.13.0.dist-info}/entry_points.txt +0 -0
  29. {singlestoredb-1.12.4.dist-info → singlestoredb-1.13.0.dist-info}/top_level.txt +0 -0
@@ -26,6 +26,7 @@ import argparse
26
26
  import asyncio
27
27
  import dataclasses
28
28
  import importlib.util
29
+ import inspect
29
30
  import io
30
31
  import itertools
31
32
  import json
@@ -36,6 +37,7 @@ import secrets
36
37
  import sys
37
38
  import tempfile
38
39
  import textwrap
40
+ import typing
39
41
  import urllib
40
42
  import zipfile
41
43
  import zipimport
@@ -62,6 +64,8 @@ from ...config import get_option
62
64
  from ...mysql.constants import FIELD_TYPE as ft
63
65
  from ..signature import get_signature
64
66
  from ..signature import signature_to_sql
67
+ from ..typing import Masked
68
+ from ..typing import Table
65
69
 
66
70
  try:
67
71
  import cloudpickle
@@ -148,20 +152,100 @@ def as_tuple(x: Any) -> Any:
148
152
  if has_pydantic and isinstance(x, BaseModel):
149
153
  return tuple(x.model_dump().values())
150
154
  if dataclasses.is_dataclass(x):
151
- return dataclasses.astuple(x)
152
- return x
155
+ return dataclasses.astuple(x) # type: ignore
156
+ if isinstance(x, dict):
157
+ return tuple(x.values())
158
+ return tuple(x)
153
159
 
154
160
 
155
161
  def as_list_of_tuples(x: Any) -> Any:
156
162
  """Convert object to a list of tuples."""
163
+ if isinstance(x, Table):
164
+ x = x[0]
157
165
  if isinstance(x, (list, tuple)) and len(x) > 0:
166
+ if isinstance(x[0], (list, tuple)):
167
+ return x
158
168
  if has_pydantic and isinstance(x[0], BaseModel):
159
169
  return [tuple(y.model_dump().values()) for y in x]
160
170
  if dataclasses.is_dataclass(x[0]):
161
171
  return [dataclasses.astuple(y) for y in x]
172
+ if isinstance(x[0], dict):
173
+ return [tuple(y.values()) for y in x]
174
+ return [(y,) for y in x]
162
175
  return x
163
176
 
164
177
 
178
+ def get_dataframe_columns(df: Any) -> List[Any]:
179
+ """Return columns of data from a dataframe/table."""
180
+ if isinstance(df, Table):
181
+ if len(df) == 1:
182
+ df = df[0]
183
+ else:
184
+ return list(df)
185
+
186
+ if isinstance(df, Masked):
187
+ return [df]
188
+
189
+ if isinstance(df, tuple):
190
+ return list(df)
191
+
192
+ rtype = str(type(df)).lower()
193
+ if 'dataframe' in rtype:
194
+ return [df[x] for x in df.columns]
195
+ elif 'table' in rtype:
196
+ return df.columns
197
+ elif 'series' in rtype:
198
+ return [df]
199
+ elif 'array' in rtype:
200
+ return [df]
201
+ elif 'tuple' in rtype:
202
+ return list(df)
203
+
204
+ raise TypeError(
205
+ 'Unsupported data type for dataframe columns: '
206
+ f'{rtype}',
207
+ )
208
+
209
+
210
+ def get_array_class(data_format: str) -> Callable[..., Any]:
211
+ """
212
+ Get the array class for the current data format.
213
+
214
+ """
215
+ if data_format == 'polars':
216
+ import polars as pl
217
+ array_cls = pl.Series
218
+ elif data_format == 'arrow':
219
+ import pyarrow as pa
220
+ array_cls = pa.array
221
+ elif data_format == 'pandas':
222
+ import pandas as pd
223
+ array_cls = pd.Series
224
+ else:
225
+ import numpy as np
226
+ array_cls = np.array
227
+ return array_cls
228
+
229
+
230
+ def get_masked_params(func: Callable[..., Any]) -> List[bool]:
231
+ """
232
+ Get the list of masked parameters for the function.
233
+
234
+ Parameters
235
+ ----------
236
+ func : Callable
237
+ The function to call as the endpoint
238
+
239
+ Returns
240
+ -------
241
+ List[bool]
242
+ Boolean list of masked parameters
243
+
244
+ """
245
+ params = inspect.signature(func).parameters
246
+ return [typing.get_origin(x.annotation) is Masked for x in params.values()]
247
+
248
+
165
249
  def make_func(
166
250
  name: str,
167
251
  func: Callable[..., Any],
@@ -181,104 +265,107 @@ def make_func(
181
265
  (Callable, Dict[str, Any])
182
266
 
183
267
  """
184
- attrs = getattr(func, '_singlestoredb_attrs', {})
185
- data_format = attrs.get('data_format') or 'python'
186
- include_masks = attrs.get('include_masks', False)
187
- function_type = attrs.get('function_type', 'udf').lower()
188
268
  info: Dict[str, Any] = {}
189
269
 
270
+ sig = get_signature(func, func_name=name)
271
+
272
+ function_type = sig.get('function_type', 'udf')
273
+ args_data_format = sig.get('args_data_format', 'scalar')
274
+ returns_data_format = sig.get('returns_data_format', 'scalar')
275
+
276
+ masks = get_masked_params(func)
277
+
190
278
  if function_type == 'tvf':
191
- if data_format == 'python':
279
+ # Scalar / list types (row-based)
280
+ if returns_data_format in ['scalar', 'list']:
192
281
  async def do_func(
193
282
  row_ids: Sequence[int],
194
283
  rows: Sequence[Sequence[Any]],
195
- ) -> Tuple[
196
- Sequence[int],
197
- List[Tuple[Any]],
198
- ]:
284
+ ) -> Tuple[Sequence[int], List[Tuple[Any, ...]]]:
199
285
  '''Call function on given rows of data.'''
200
286
  out_ids: List[int] = []
201
287
  out = []
288
+ # Call function on each row of data
202
289
  for i, res in zip(row_ids, func_map(func, rows)):
203
290
  out.extend(as_list_of_tuples(res))
204
291
  out_ids.extend([row_ids[i]] * (len(out)-len(out_ids)))
205
292
  return out_ids, out
206
293
 
294
+ # Vector formats (column-based)
207
295
  else:
208
- # Vector formats use the same function wrapper
296
+ array_cls = get_array_class(returns_data_format)
297
+
209
298
  async def do_func( # type: ignore
210
299
  row_ids: Sequence[int],
211
300
  cols: Sequence[Tuple[Sequence[Any], Optional[Sequence[bool]]]],
212
- ) -> Tuple[Sequence[int], List[Tuple[Any, ...]]]:
301
+ ) -> Tuple[
302
+ Sequence[int],
303
+ List[Tuple[Sequence[Any], Optional[Sequence[bool]]]],
304
+ ]:
213
305
  '''Call function on given cols of data.'''
214
- if include_masks:
215
- out = func(*cols)
216
- assert isinstance(out, tuple)
217
- return row_ids, [out]
218
-
219
- out = []
220
- res = func(*[x[0] for x in cols])
221
- rtype = str(type(res)).lower()
222
-
223
- # Map tables / dataframes to a list of columns
224
- if 'dataframe' in rtype:
225
- res = [res[x] for x in res.columns]
226
- elif 'table' in rtype:
227
- res = res.columns
228
-
229
- for vec in res:
230
- # C extension only supports Python objects as strings
231
- if data_format == 'numpy' and str(vec.dtype)[:2] in ['<U', '<S']:
232
- vec = vec.astype(object)
233
- out.append((vec, None))
234
-
235
306
  # NOTE: There is no way to determine which row ID belongs to
236
307
  # each result row, so we just have to use the same
237
308
  # row ID for all rows in the result.
238
- if data_format == 'polars':
239
- import polars as pl
240
- array_cls = pl.Series
241
- elif data_format == 'arrow':
242
- import pyarrow as pa
243
- array_cls = pa.array
244
- elif data_format == 'pandas':
245
- import pandas as pd
246
- array_cls = pd.Series
309
+
310
+ def build_tuple(x: Any) -> Any:
311
+ return tuple(x) if isinstance(x, Masked) else (x, None)
312
+
313
+ # Call function on each column of data
314
+ if cols and cols[0]:
315
+ res = get_dataframe_columns(
316
+ func(*[x if m else x[0] for x, m in zip(cols, masks)]),
317
+ )
318
+ else:
319
+ res = get_dataframe_columns(func())
320
+
321
+ # Generate row IDs
322
+ if isinstance(res[0], Masked):
323
+ row_ids = array_cls([row_ids[0]] * len(res[0][0]))
247
324
  else:
248
- import numpy as np
249
- array_cls = np.array
325
+ row_ids = array_cls([row_ids[0]] * len(res[0]))
250
326
 
251
- return array_cls([row_ids[0]] * len(out[0][0])), out
327
+ return row_ids, [build_tuple(x) for x in res]
252
328
 
253
329
  else:
254
- if data_format == 'python':
330
+ # Scalar / list types (row-based)
331
+ if returns_data_format in ['scalar', 'list']:
255
332
  async def do_func(
256
333
  row_ids: Sequence[int],
257
334
  rows: Sequence[Sequence[Any]],
258
- ) -> Tuple[
259
- Sequence[int],
260
- List[Tuple[Any]],
261
- ]:
335
+ ) -> Tuple[Sequence[int], List[Tuple[Any, ...]]]:
262
336
  '''Call function on given rows of data.'''
263
337
  return row_ids, [as_tuple(x) for x in zip(func_map(func, rows))]
264
338
 
339
+ # Vector formats (column-based)
265
340
  else:
266
- # Vector formats use the same function wrapper
341
+ array_cls = get_array_class(returns_data_format)
342
+
267
343
  async def do_func( # type: ignore
268
344
  row_ids: Sequence[int],
269
345
  cols: Sequence[Tuple[Sequence[Any], Optional[Sequence[bool]]]],
270
- ) -> Tuple[Sequence[int], List[Tuple[Any, ...]]]:
346
+ ) -> Tuple[
347
+ Sequence[int],
348
+ List[Tuple[Sequence[Any], Optional[Sequence[bool]]]],
349
+ ]:
271
350
  '''Call function on given cols of data.'''
272
- if include_masks:
273
- out = func(*cols)
274
- assert isinstance(out, tuple)
275
- return row_ids, [out]
351
+ row_ids = array_cls(row_ids)
352
+
353
+ def build_tuple(x: Any) -> Any:
354
+ return tuple(x) if isinstance(x, Masked) else (x, None)
355
+
356
+ # Call the function with `cols` as the function parameters
357
+ if cols and cols[0]:
358
+ out = func(*[x if m else x[0] for x, m in zip(cols, masks)])
359
+ else:
360
+ out = func()
276
361
 
277
- out = func(*[x[0] for x in cols])
362
+ # Single masked value
363
+ if isinstance(out, Masked):
364
+ return row_ids, [tuple(out)]
278
365
 
279
366
  # Multiple return values
280
367
  if isinstance(out, tuple):
281
- return row_ids, [(x, None) for x in out]
368
+ return row_ids, [build_tuple(x) for x in out]
282
369
 
283
370
  # Single return value
284
371
  return row_ids, [(out, None)]
@@ -286,13 +373,12 @@ def make_func(
286
373
  do_func.__name__ = name
287
374
  do_func.__doc__ = func.__doc__
288
375
 
289
- sig = get_signature(func, name=name)
290
-
291
376
  # Store signature for generating CREATE FUNCTION calls
292
377
  info['signature'] = sig
293
378
 
294
379
  # Set data format
295
- info['data_format'] = data_format
380
+ info['args_data_format'] = args_data_format
381
+ info['returns_data_format'] = returns_data_format
296
382
 
297
383
  # Set function type
298
384
  info['function_type'] = function_type
@@ -306,20 +392,13 @@ def make_func(
306
392
  colspec.append((x['name'], rowdat_1_type_map[dtype]))
307
393
  info['colspec'] = colspec
308
394
 
309
- def parse_return_type(s: str) -> List[str]:
310
- if s.startswith('tuple['):
311
- return s[6:-1].split(',')
312
- if s.startswith('array[tuple['):
313
- return s[12:-2].split(',')
314
- return [s]
315
-
316
395
  # Setup return type
317
396
  returns = []
318
- for x in parse_return_type(sig['returns']['dtype']):
319
- dtype = x.replace('?', '')
397
+ for x in sig['returns']:
398
+ dtype = x['dtype'].replace('?', '')
320
399
  if dtype not in rowdat_1_type_map:
321
400
  raise TypeError(f'no data type mapping for {dtype}')
322
- returns.append(rowdat_1_type_map[dtype])
401
+ returns.append((x['name'], rowdat_1_type_map[dtype]))
323
402
  info['returns'] = returns
324
403
 
325
404
  return do_func, info
@@ -371,6 +450,13 @@ class Application(object):
371
450
  headers=[(b'content-type', b'text/plain')],
372
451
  )
373
452
 
453
+ # Error response start
454
+ error_response_dict: Dict[str, Any] = dict(
455
+ type='http.response.start',
456
+ status=401,
457
+ headers=[(b'content-type', b'text/plain')],
458
+ )
459
+
374
460
  # JSON response start
375
461
  json_response_dict: Dict[str, Any] = dict(
376
462
  type='http.response.start',
@@ -405,7 +491,12 @@ class Application(object):
405
491
 
406
492
  # Data format + version handlers
407
493
  handlers = {
408
- (b'application/octet-stream', b'1.0', 'python'): dict(
494
+ (b'application/octet-stream', b'1.0', 'scalar'): dict(
495
+ load=rowdat_1.load,
496
+ dump=rowdat_1.dump,
497
+ response=rowdat_1_response_dict,
498
+ ),
499
+ (b'application/octet-stream', b'1.0', 'list'): dict(
409
500
  load=rowdat_1.load,
410
501
  dump=rowdat_1.dump,
411
502
  response=rowdat_1_response_dict,
@@ -430,7 +521,12 @@ class Application(object):
430
521
  dump=rowdat_1.dump_arrow,
431
522
  response=rowdat_1_response_dict,
432
523
  ),
433
- (b'application/json', b'1.0', 'python'): dict(
524
+ (b'application/json', b'1.0', 'scalar'): dict(
525
+ load=jdata.load,
526
+ dump=jdata.dump,
527
+ response=json_response_dict,
528
+ ),
529
+ (b'application/json', b'1.0', 'list'): dict(
434
530
  load=jdata.load,
435
531
  dump=jdata.dump,
436
532
  response=json_response_dict,
@@ -455,7 +551,7 @@ class Application(object):
455
551
  dump=jdata.dump_arrow,
456
552
  response=json_response_dict,
457
553
  ),
458
- (b'application/vnd.apache.arrow.file', b'1.0', 'python'): dict(
554
+ (b'application/vnd.apache.arrow.file', b'1.0', 'scalar'): dict(
459
555
  load=arrow.load,
460
556
  dump=arrow.dump,
461
557
  response=arrow_response_dict,
@@ -485,6 +581,7 @@ class Application(object):
485
581
  # Valid URL paths
486
582
  invoke_path = ('invoke',)
487
583
  show_create_function_path = ('show', 'create_function')
584
+ show_function_info_path = ('show', 'function_info')
488
585
 
489
586
  def __init__(
490
587
  self,
@@ -505,6 +602,8 @@ class Application(object):
505
602
  link_name: Optional[str] = get_option('external_function.link_name'),
506
603
  link_config: Optional[Dict[str, Any]] = None,
507
604
  link_credentials: Optional[Dict[str, Any]] = None,
605
+ name_prefix: str = get_option('external_function.name_prefix'),
606
+ name_suffix: str = get_option('external_function.name_suffix'),
508
607
  ) -> None:
509
608
  if link_name and (link_config or link_credentials):
510
609
  raise ValueError(
@@ -561,6 +660,7 @@ class Application(object):
561
660
  if not hasattr(x, '_singlestoredb_attrs'):
562
661
  continue
563
662
  name = x._singlestoredb_attrs.get('name', x.__name__)
663
+ name = f'{name_prefix}{name}{name_suffix}'
564
664
  external_functions[x.__name__] = x
565
665
  func, info = make_func(name, x)
566
666
  endpoints[name.encode('utf-8')] = func, info
@@ -576,6 +676,7 @@ class Application(object):
576
676
  # Add endpoint for each exported function
577
677
  for name, alias in get_func_names(func_names):
578
678
  item = getattr(pkg, name)
679
+ alias = f'{name_prefix}{name}{name_suffix}'
579
680
  external_functions[name] = item
580
681
  func, info = make_func(alias, item)
581
682
  endpoints[alias.encode('utf-8')] = func, info
@@ -588,6 +689,7 @@ class Application(object):
588
689
  if not hasattr(x, '_singlestoredb_attrs'):
589
690
  continue
590
691
  name = x._singlestoredb_attrs.get('name', x.__name__)
692
+ name = f'{name_prefix}{name}{name_suffix}'
591
693
  external_functions[x.__name__] = x
592
694
  func, info = make_func(name, x)
593
695
  endpoints[name.encode('utf-8')] = func, info
@@ -595,6 +697,7 @@ class Application(object):
595
697
  else:
596
698
  alias = funcs.__name__
597
699
  external_functions[funcs.__name__] = funcs
700
+ alias = f'{name_prefix}{alias}{name_suffix}'
598
701
  func, info = make_func(alias, funcs)
599
702
  endpoints[alias.encode('utf-8')] = func, info
600
703
 
@@ -648,7 +751,8 @@ class Application(object):
648
751
 
649
752
  # Call the endpoint
650
753
  if method == 'POST' and func is not None and path == self.invoke_path:
651
- data_format = func_info['data_format']
754
+ args_data_format = func_info['args_data_format']
755
+ returns_data_format = func_info['returns_data_format']
652
756
  data = []
653
757
  more_body = True
654
758
  while more_body:
@@ -657,17 +761,24 @@ class Application(object):
657
761
  more_body = request.get('more_body', False)
658
762
 
659
763
  data_version = headers.get(b's2-ef-version', b'')
660
- input_handler = self.handlers[(content_type, data_version, data_format)]
661
- output_handler = self.handlers[(accepts, data_version, data_format)]
764
+ input_handler = self.handlers[(content_type, data_version, args_data_format)]
765
+ output_handler = self.handlers[(accepts, data_version, returns_data_format)]
662
766
 
663
- out = await func(
664
- *input_handler['load']( # type: ignore
665
- func_info['colspec'], b''.join(data),
666
- ),
667
- )
668
- body = output_handler['dump'](func_info['returns'], *out) # type: ignore
767
+ try:
768
+ out = await func(
769
+ *input_handler['load']( # type: ignore
770
+ func_info['colspec'], b''.join(data),
771
+ ),
772
+ )
773
+ body = output_handler['dump'](
774
+ [x[1] for x in func_info['returns']], *out, # type: ignore
775
+ )
776
+ await send(output_handler['response'])
669
777
 
670
- await send(output_handler['response'])
778
+ except Exception as e:
779
+ logging.exception('Error in function call')
780
+ body = f'[{type(e).__name__}] {str(e).strip()}'.encode('utf-8')
781
+ await send(self.error_response_dict)
671
782
 
672
783
  # Handle api reflection
673
784
  elif method == 'GET' and path == self.show_create_function_path:
@@ -688,6 +799,12 @@ class Application(object):
688
799
 
689
800
  await send(self.text_response_dict)
690
801
 
802
+ # Return function info
803
+ elif method == 'GET' and (path == self.show_function_info_path or not path):
804
+ functions = self.get_function_info()
805
+ body = json.dumps(dict(functions=functions)).encode('utf-8')
806
+ await send(self.text_response_dict)
807
+
691
808
  # Path not found
692
809
  else:
693
810
  body = b''
@@ -725,21 +842,78 @@ class Application(object):
725
842
  """Locate all current functions and links belonging to this app."""
726
843
  funcs, links = set(), set()
727
844
  cur.execute('SHOW FUNCTIONS')
728
- for name, ftype, _, _, _, link in list(cur):
845
+ for row in list(cur):
846
+ name, ftype, link = row[0], row[1], row[-1]
729
847
  # Only look at external functions
730
848
  if 'external' not in ftype.lower():
731
849
  continue
732
850
  # See if function URL matches url
733
851
  cur.execute(f'SHOW CREATE FUNCTION `{name}`')
734
852
  for fname, _, code, *_ in list(cur):
735
- m = re.search(r" (?:\w+) SERVICE '([^']+)'", code)
853
+ m = re.search(r" (?:\w+) (?:SERVICE|MANAGED) '([^']+)'", code)
736
854
  if m and m.group(1) == self.url:
737
855
  funcs.add(fname)
738
856
  if link and re.match(r'^py_ext_func_link_\S{14}$', link):
739
857
  links.add(link)
740
858
  return funcs, links
741
859
 
742
- def show_create_functions(
860
+ def get_function_info(
861
+ self,
862
+ func_name: Optional[str] = None,
863
+ ) -> Dict[str, Any]:
864
+ """
865
+ Return the functions and function signature information.
866
+ Returns
867
+ -------
868
+ Dict[str, Any]
869
+ """
870
+ functions = {}
871
+ no_default = object()
872
+
873
+ for key, (_, info) in self.endpoints.items():
874
+ if not func_name or key == func_name:
875
+ sig = info['signature']
876
+ args = []
877
+
878
+ # Function arguments
879
+ for a in sig.get('args', []):
880
+ dtype = a['dtype']
881
+ nullable = '?' in dtype
882
+ args.append(
883
+ dict(
884
+ name=a['name'],
885
+ dtype=dtype.replace('?', ''),
886
+ nullable=nullable,
887
+ ),
888
+ )
889
+ if a.get('default', no_default) is not no_default:
890
+ args[-1]['default'] = a['default']
891
+
892
+ # Return values
893
+ ret = sig.get('returns', [])
894
+ returns = []
895
+
896
+ for a in ret:
897
+ dtype = a['dtype']
898
+ nullable = '?' in dtype
899
+ returns.append(
900
+ dict(
901
+ dtype=dtype.replace('?', ''),
902
+ nullable=nullable,
903
+ ),
904
+ )
905
+ if a.get('name', None):
906
+ returns[-1]['name'] = a['name']
907
+ if a.get('default', no_default) is not no_default:
908
+ returns[-1]['default'] = a['default']
909
+
910
+ functions[sig['name']] = dict(
911
+ args=args, returns=returns, function_type=info['function_type'],
912
+ )
913
+
914
+ return functions
915
+
916
+ def get_create_functions(
743
917
  self,
744
918
  replace: bool = False,
745
919
  ) -> List[str]:
@@ -807,7 +981,7 @@ class Application(object):
807
981
  cur.execute(f'DROP FUNCTION IF EXISTS `{fname}`')
808
982
  for link in links:
809
983
  cur.execute(f'DROP LINK {link}')
810
- for func in self.show_create_functions(replace=replace):
984
+ for func in self.get_create_functions(replace=replace):
811
985
  cur.execute(func)
812
986
 
813
987
  def drop_functions(
@@ -1135,6 +1309,22 @@ def main(argv: Optional[List[str]] = None) -> None:
1135
1309
  ),
1136
1310
  help='logging level',
1137
1311
  )
1312
+ parser.add_argument(
1313
+ '--name-prefix', metavar='name_prefix',
1314
+ default=defaults.get(
1315
+ 'name_prefix',
1316
+ get_option('external_function.name_prefix'),
1317
+ ),
1318
+ help='Prefix to add to function names',
1319
+ )
1320
+ parser.add_argument(
1321
+ '--name-suffix', metavar='name_suffix',
1322
+ default=defaults.get(
1323
+ 'name_suffix',
1324
+ get_option('external_function.name_suffix'),
1325
+ ),
1326
+ help='Suffix to add to function names',
1327
+ )
1138
1328
  parser.add_argument(
1139
1329
  'functions', metavar='module.or.func.path', nargs='*',
1140
1330
  help='functions or modules to export in UDF server',
@@ -1217,6 +1407,11 @@ def main(argv: Optional[List[str]] = None) -> None:
1217
1407
  or defaults.get('replace_existing') \
1218
1408
  or get_option('external_function.replace_existing')
1219
1409
 
1410
+ # Substitute in host / port if specified
1411
+ if args.host != defaults.get('host') or args.port != defaults.get('port'):
1412
+ u = urllib.parse.urlparse(args.url)
1413
+ args.url = u._replace(netloc=f'{args.host}:{args.port}').geturl()
1414
+
1220
1415
  # Create application from functions / module
1221
1416
  app = Application(
1222
1417
  functions=args.functions,
@@ -1227,9 +1422,11 @@ def main(argv: Optional[List[str]] = None) -> None:
1227
1422
  link_config=json.loads(args.link_config) or None,
1228
1423
  link_credentials=json.loads(args.link_credentials) or None,
1229
1424
  app_mode='remote',
1425
+ name_prefix=args.name_prefix,
1426
+ name_suffix=args.name_suffix,
1230
1427
  )
1231
1428
 
1232
- funcs = app.show_create_functions(replace=args.replace_existing)
1429
+ funcs = app.get_create_functions(replace=args.replace_existing)
1233
1430
  if not funcs:
1234
1431
  raise RuntimeError('no functions specified')
1235
1432
 
@@ -1249,6 +1446,7 @@ def main(argv: Optional[List[str]] = None) -> None:
1249
1446
  host=args.host or None,
1250
1447
  port=args.port or None,
1251
1448
  log_level=args.log_level,
1449
+ lifespan='off',
1252
1450
  ).items() if v is not None
1253
1451
  }
1254
1452