singlestoredb 1.0.3__cp38-abi3-win32.whl → 1.1.0__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 (31) hide show
  1. _singlestoredb_accel.pyd +0 -0
  2. singlestoredb/__init__.py +1 -1
  3. singlestoredb/config.py +125 -0
  4. singlestoredb/functions/dtypes.py +5 -198
  5. singlestoredb/functions/ext/__init__.py +0 -1
  6. singlestoredb/functions/ext/asgi.py +665 -153
  7. singlestoredb/functions/ext/json.py +2 -2
  8. singlestoredb/functions/ext/mmap.py +174 -67
  9. singlestoredb/functions/ext/rowdat_1.py +2 -2
  10. singlestoredb/functions/ext/utils.py +169 -0
  11. singlestoredb/fusion/handler.py +109 -9
  12. singlestoredb/fusion/handlers/stage.py +150 -0
  13. singlestoredb/fusion/handlers/workspace.py +265 -4
  14. singlestoredb/fusion/registry.py +69 -1
  15. singlestoredb/http/connection.py +40 -2
  16. singlestoredb/management/utils.py +30 -0
  17. singlestoredb/management/workspace.py +209 -35
  18. singlestoredb/mysql/connection.py +69 -0
  19. singlestoredb/mysql/cursors.py +176 -4
  20. singlestoredb/tests/test.sql +210 -0
  21. singlestoredb/tests/test_connection.py +1408 -0
  22. singlestoredb/tests/test_ext_func.py +2 -2
  23. singlestoredb/tests/test_ext_func_data.py +1 -1
  24. singlestoredb/utils/dtypes.py +205 -0
  25. singlestoredb/utils/results.py +367 -14
  26. {singlestoredb-1.0.3.dist-info → singlestoredb-1.1.0.dist-info}/METADATA +2 -1
  27. {singlestoredb-1.0.3.dist-info → singlestoredb-1.1.0.dist-info}/RECORD +31 -29
  28. {singlestoredb-1.0.3.dist-info → singlestoredb-1.1.0.dist-info}/LICENSE +0 -0
  29. {singlestoredb-1.0.3.dist-info → singlestoredb-1.1.0.dist-info}/WHEEL +0 -0
  30. {singlestoredb-1.0.3.dist-info → singlestoredb-1.1.0.dist-info}/entry_points.txt +0 -0
  31. {singlestoredb-1.0.3.dist-info → singlestoredb-1.1.0.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- '''
2
+ """
3
3
  Web application for SingleStoreDB external functions.
4
4
 
5
5
  This module supplies a function that can create web apps intended for use
@@ -18,17 +18,26 @@ An example of starting a server is shown below.
18
18
 
19
19
  Example
20
20
  -------
21
- $ SINGLESTOREDB_EXT_FUNCTIONS='myfuncs.[percentage_90,percentage_95]' \
22
- uvicorn --factory singlestoredb.functions.ext:create_app
21
+ > SINGLESTOREDB_EXT_FUNCTIONS='myfuncs.[percentile_90,percentile_95]' \
22
+ python3 -m singlestoredb.functions.ext.asgi
23
23
 
24
- '''
24
+ """
25
+ import argparse
26
+ import asyncio
25
27
  import importlib.util
26
28
  import io
27
29
  import itertools
28
30
  import json
31
+ import logging
29
32
  import os
30
33
  import re
31
34
  import secrets
35
+ import sys
36
+ import tempfile
37
+ import textwrap
38
+ import urllib
39
+ import zipfile
40
+ import zipimport
32
41
  from types import ModuleType
33
42
  from typing import Any
34
43
  from typing import Awaitable
@@ -45,11 +54,24 @@ from typing import Union
45
54
  from . import arrow
46
55
  from . import json as jdata
47
56
  from . import rowdat_1
57
+ from . import utils
48
58
  from ... import connection
59
+ from ... import manage_workspaces
60
+ from ...config import get_option
49
61
  from ...mysql.constants import FIELD_TYPE as ft
50
62
  from ..signature import get_signature
51
63
  from ..signature import signature_to_sql
52
64
 
65
+ try:
66
+ import cloudpickle
67
+ has_cloudpickle = True
68
+ except ImportError:
69
+ has_cloudpickle = False
70
+
71
+
72
+ logger = utils.get_logger('singlestoredb.functions.ext.asgi')
73
+
74
+
53
75
  # If a number of processes is specified, create a pool of workers
54
76
  num_processes = max(0, int(os.environ.get('SINGLESTOREDB_EXT_NUM_PROCESSES', 0)))
55
77
  if num_processes > 1:
@@ -81,7 +103,7 @@ rowdat_1_type_map = {
81
103
 
82
104
 
83
105
  def get_func_names(funcs: str) -> List[Tuple[str, str]]:
84
- '''
106
+ """
85
107
  Parse all function names from string.
86
108
 
87
109
  Parameters
@@ -97,7 +119,7 @@ def get_func_names(funcs: str) -> List[Tuple[str, str]]:
97
119
  List[Tuple[str]] : a list of tuples containing the names and aliases
98
120
  of each function.
99
121
 
100
- '''
122
+ """
101
123
  if funcs.startswith('['):
102
124
  func_names = funcs.replace('[', '').replace(']', '').split(',')
103
125
  func_names = [x.strip() for x in func_names]
@@ -114,8 +136,11 @@ def get_func_names(funcs: str) -> List[Tuple[str, str]]:
114
136
  return out
115
137
 
116
138
 
117
- def make_func(name: str, func: Callable[..., Any]) -> Callable[..., Any]:
118
- '''
139
+ def make_func(
140
+ name: str,
141
+ func: Callable[..., Any],
142
+ ) -> Tuple[Callable[..., Any], Dict[str, Any]]:
143
+ """
119
144
  Make a function endpoint.
120
145
 
121
146
  Parameters
@@ -127,12 +152,13 @@ def make_func(name: str, func: Callable[..., Any]) -> Callable[..., Any]:
127
152
 
128
153
  Returns
129
154
  -------
130
- Callable
155
+ (Callable, Dict[str, Any])
131
156
 
132
- '''
157
+ """
133
158
  attrs = getattr(func, '_singlestoredb_attrs', {})
134
159
  data_format = attrs.get('data_format') or 'python'
135
160
  include_masks = attrs.get('include_masks', False)
161
+ info: Dict[str, Any] = {}
136
162
 
137
163
  if data_format == 'python':
138
164
  async def do_func(
@@ -165,10 +191,10 @@ def make_func(name: str, func: Callable[..., Any]) -> Callable[..., Any]:
165
191
  sig = get_signature(func, name=name)
166
192
 
167
193
  # Store signature for generating CREATE FUNCTION calls
168
- do_func._ext_func_signature = sig # type: ignore
194
+ info['signature'] = sig
169
195
 
170
196
  # Set data format
171
- do_func._ext_func_data_format = data_format # type: ignore
197
+ info['data_format'] = data_format
172
198
 
173
199
  # Setup argument types for rowdat_1 parser
174
200
  colspec = []
@@ -177,36 +203,19 @@ def make_func(name: str, func: Callable[..., Any]) -> Callable[..., Any]:
177
203
  if dtype not in rowdat_1_type_map:
178
204
  raise TypeError(f'no data type mapping for {dtype}')
179
205
  colspec.append((x['name'], rowdat_1_type_map[dtype]))
180
- do_func._ext_func_colspec = colspec # type: ignore
206
+ info['colspec'] = colspec
181
207
 
182
208
  # Setup return type
183
209
  dtype = sig['returns']['dtype'].replace('?', '')
184
210
  if dtype not in rowdat_1_type_map:
185
211
  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
- '''
212
+ info['returns'] = [rowdat_1_type_map[dtype]]
213
+
214
+ return do_func, info
215
+
216
+
217
+ class Application(object):
218
+ """
210
219
  Create an external function application.
211
220
 
212
221
  If `functions` is None, the environment is searched for function
@@ -231,6 +240,10 @@ def create_app( # noqa: C901
231
240
  The format of the data rows: 'rowdat_1' or 'json'
232
241
  data_version : str, optional
233
242
  The version of the call format to expect: '1.0'
243
+ link_name : str, optional
244
+ The link name to use for the external function application. This is
245
+ only for pre-existing links, and can only be used without
246
+ ``link_config`` and ``link_credentials``.
234
247
  link_config : Dict[str, Any], optional
235
248
  The CONFIG section of a LINK definition. This dictionary gets
236
249
  converted to JSON for the CREATE LINK call.
@@ -238,77 +251,7 @@ def create_app( # noqa: C901
238
251
  The CREDENTIALS section of a LINK definition. This dictionary gets
239
252
  converted to JSON for the CREATE LINK call.
240
253
 
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
254
+ """
312
255
 
313
256
  # Plain text response start
314
257
  text_response_dict: Dict[str, Any] = dict(
@@ -432,7 +375,124 @@ def create_app( # noqa: C901
432
375
  invoke_path = ('invoke',)
433
376
  show_create_function_path = ('show', 'create_function')
434
377
 
435
- async def app(
378
+ def __init__(
379
+ self,
380
+ functions: Optional[
381
+ Union[
382
+ str,
383
+ Iterable[str],
384
+ Callable[..., Any],
385
+ Iterable[Callable[..., Any]],
386
+ ModuleType,
387
+ Iterable[ModuleType],
388
+ ]
389
+ ] = None,
390
+ app_mode: str = get_option('external_function.app_mode'),
391
+ url: str = get_option('external_function.url'),
392
+ data_format: str = get_option('external_function.data_format'),
393
+ data_version: str = get_option('external_function.data_version'),
394
+ link_name: Optional[str] = get_option('external_function.link_name'),
395
+ link_config: Optional[Dict[str, Any]] = None,
396
+ link_credentials: Optional[Dict[str, Any]] = None,
397
+ ) -> None:
398
+ if link_name and (link_config or link_credentials):
399
+ raise ValueError(
400
+ '`link_name` can not be used with `link_config` or `link_credentials`',
401
+ )
402
+
403
+ if link_config is None:
404
+ link_config = json.loads(
405
+ get_option('external_function.link_config') or '{}',
406
+ ) or None
407
+
408
+ if link_credentials is None:
409
+ link_credentials = json.loads(
410
+ get_option('external_function.link_credentials') or '{}',
411
+ ) or None
412
+
413
+ # List of functions specs
414
+ specs: List[Union[str, Callable[..., Any], ModuleType]] = []
415
+
416
+ # Look up Python function specifications
417
+ if functions is None:
418
+ env_vars = [
419
+ x for x in os.environ.keys()
420
+ if x.startswith('SINGLESTOREDB_EXT_FUNCTIONS')
421
+ ]
422
+ if env_vars:
423
+ specs = [os.environ[x] for x in env_vars]
424
+ else:
425
+ import __main__
426
+ specs = [__main__]
427
+
428
+ elif isinstance(functions, ModuleType):
429
+ specs = [functions]
430
+
431
+ elif isinstance(functions, str):
432
+ specs = [functions]
433
+
434
+ elif callable(functions):
435
+ specs = [functions]
436
+
437
+ else:
438
+ specs = list(functions)
439
+
440
+ # Add functions to application
441
+ endpoints = dict()
442
+ external_functions = dict()
443
+ for funcs in itertools.chain(specs):
444
+
445
+ if isinstance(funcs, str):
446
+ # Module name
447
+ if importlib.util.find_spec(funcs) is not None:
448
+ items = importlib.import_module(funcs)
449
+ for x in vars(items).values():
450
+ if not hasattr(x, '_singlestoredb_attrs'):
451
+ continue
452
+ name = x._singlestoredb_attrs.get('name', x.__name__)
453
+ external_functions[x.__name__] = x
454
+ func, info = make_func(name, x)
455
+ endpoints[name.encode('utf-8')] = func, info
456
+
457
+ # Fully qualified function name
458
+ else:
459
+ pkg_path, func_names = funcs.rsplit('.', 1)
460
+ pkg = importlib.import_module(pkg_path)
461
+
462
+ # Add endpoint for each exported function
463
+ for name, alias in get_func_names(func_names):
464
+ item = getattr(pkg, name)
465
+ external_functions[name] = item
466
+ func, info = make_func(alias, item)
467
+ endpoints[alias.encode('utf-8')] = func, info
468
+
469
+ elif isinstance(funcs, ModuleType):
470
+ for x in vars(funcs).values():
471
+ if not hasattr(x, '_singlestoredb_attrs'):
472
+ continue
473
+ name = x._singlestoredb_attrs.get('name', x.__name__)
474
+ external_functions[x.__name__] = x
475
+ func, info = make_func(name, x)
476
+ endpoints[name.encode('utf-8')] = func, info
477
+
478
+ else:
479
+ alias = funcs.__name__
480
+ external_functions[funcs.__name__] = funcs
481
+ func, info = make_func(alias, funcs)
482
+ endpoints[alias.encode('utf-8')] = func, info
483
+
484
+ self.app_mode = app_mode
485
+ self.url = url
486
+ self.data_format = data_format
487
+ self.data_version = data_version
488
+ self.link_name = link_name
489
+ self.link_config = link_config
490
+ self.link_credentials = link_credentials
491
+ self.endpoints = endpoints
492
+ self.external_functions = external_functions
493
+
494
+ async def __call__(
495
+ self,
436
496
  scope: Dict[str, Any],
437
497
  receive: Callable[..., Awaitable[Any]],
438
498
  send: Callable[..., Awaitable[Any]],
@@ -462,11 +522,16 @@ def create_app( # noqa: C901
462
522
  )
463
523
  accepts = headers.get(b'accepts', content_type)
464
524
  func_name = headers.get(b's2-ef-name', b'')
465
- func = endpoints.get(func_name)
525
+ func_endpoint = self.endpoints.get(func_name)
526
+
527
+ func = None
528
+ func_info: Dict[str, Any] = {}
529
+ if func_endpoint is not None:
530
+ func, func_info = func_endpoint
466
531
 
467
532
  # 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
533
+ if method == 'POST' and func is not None and path == self.invoke_path:
534
+ data_format = func_info['data_format']
470
535
  data = []
471
536
  more_body = True
472
537
  while more_body:
@@ -475,57 +540,61 @@ def create_app( # noqa: C901
475
540
  more_body = request.get('more_body', False)
476
541
 
477
542
  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)]
543
+ input_handler = self.handlers[(content_type, data_version, data_format)]
544
+ output_handler = self.handlers[(accepts, data_version, data_format)]
480
545
 
481
546
  out = await func(
482
- *input_handler['load'](
483
- func._ext_func_colspec, b''.join(data), # type: ignore
547
+ *input_handler['load']( # type: ignore
548
+ func_info['colspec'], b''.join(data),
484
549
  ),
485
550
  )
486
- body = output_handler['dump'](func._ext_func_returns, *out) # type: ignore
551
+ body = output_handler['dump'](func_info['returns'], *out) # type: ignore
487
552
 
488
553
  await send(output_handler['response'])
489
554
 
490
555
  # Handle api reflection
491
- elif method == 'GET' and path == show_create_function_path:
556
+ elif method == 'GET' and path == self.show_create_function_path:
492
557
  host = headers.get(b'host', b'localhost:80')
493
558
  reflected_url = f'{scope["scheme"]}://{host.decode("utf-8")}/invoke'
494
559
  data_format = 'json' if b'json' in content_type else 'rowdat_1'
495
560
 
496
561
  syntax = []
497
- for key, endpoint in endpoints.items():
562
+ for key, (endpoint, endpoint_info) in self.endpoints.items():
498
563
  if not func_name or key == func_name:
499
564
  syntax.append(
500
565
  signature_to_sql(
501
- endpoint._ext_func_signature, # type: ignore
502
- url=url or reflected_url,
566
+ endpoint_info['signature'],
567
+ url=self.url or reflected_url,
503
568
  data_format=data_format,
504
569
  ),
505
570
  )
506
571
  body = '\n'.join(syntax).encode('utf-8')
507
572
 
508
- await send(text_response_dict)
573
+ await send(self.text_response_dict)
509
574
 
510
575
  # Path not found
511
576
  else:
512
577
  body = b''
513
- await send(path_not_found_response_dict)
578
+ await send(self.path_not_found_response_dict)
514
579
 
515
580
  # Send body
516
- out = body_response_dict.copy()
581
+ out = self.body_response_dict.copy()
517
582
  out['body'] = body
518
583
  await send(out)
519
584
 
520
585
  def _create_link(
586
+ self,
521
587
  config: Optional[Dict[str, Any]],
522
588
  credentials: Optional[Dict[str, Any]],
523
589
  ) -> Tuple[str, str]:
524
590
  """Generate CREATE LINK command."""
591
+ if self.link_name:
592
+ return self.link_name, ''
593
+
525
594
  if not config and not credentials:
526
595
  return '', ''
527
596
 
528
- link_name = f'link_{secrets.token_hex(16)}'
597
+ link_name = f'py_ext_func_link_{secrets.token_hex(14)}'
529
598
  out = [f'CREATE LINK {link_name} AS HTTP']
530
599
 
531
600
  if config:
@@ -536,7 +605,7 @@ def create_app( # noqa: C901
536
605
 
537
606
  return link_name, ' '.join(out) + ';'
538
607
 
539
- def _locate_app_functions(cur: Any) -> Tuple[Set[str], Set[str]]:
608
+ def _locate_app_functions(self, cur: Any) -> Tuple[Set[str], Set[str]]:
540
609
  """Locate all current functions and links belonging to this app."""
541
610
  funcs, links = set(), set()
542
611
  cur.execute('SHOW FUNCTIONS')
@@ -548,33 +617,46 @@ def create_app( # noqa: C901
548
617
  cur.execute(f'SHOW CREATE FUNCTION `{name}`')
549
618
  for fname, _, code, *_ in list(cur):
550
619
  m = re.search(r" (?:\w+) SERVICE '([^']+)'", code)
551
- if m and m.group(1) == url:
620
+ if m and m.group(1) == self.url:
552
621
  funcs.add(fname)
553
- if link:
622
+ if link and re.match(r'^py_ext_func_link_\S{14}$', link):
554
623
  links.add(link)
555
624
  return funcs, links
556
625
 
557
626
  def show_create_functions(
627
+ self,
558
628
  replace: bool = False,
559
629
  ) -> List[str]:
560
- """Generate CREATE FUNCTION calls."""
561
- if not endpoints:
630
+ """
631
+ Generate CREATE FUNCTION code for all functions.
632
+
633
+ Parameters
634
+ ----------
635
+ replace : bool, optional
636
+ Should existing functions be replaced?
637
+
638
+ Returns
639
+ -------
640
+ List[str]
641
+
642
+ """
643
+ if not self.endpoints:
562
644
  return []
563
645
 
564
646
  out = []
565
647
  link = ''
566
- if app_mode.lower() == 'remote':
567
- link, link_str = _create_link(link_config, link_credentials)
648
+ if self.app_mode.lower() == 'remote':
649
+ link, link_str = self._create_link(self.link_config, self.link_credentials)
568
650
  if link and link_str:
569
651
  out.append(link_str)
570
652
 
571
- for key, endpoint in endpoints.items():
653
+ for key, (endpoint, endpoint_info) in self.endpoints.items():
572
654
  out.append(
573
655
  signature_to_sql(
574
- endpoint._ext_func_signature, # type: ignore
575
- url=url,
576
- data_format=data_format,
577
- app_mode=app_mode,
656
+ endpoint_info['signature'],
657
+ url=self.url,
658
+ data_format=self.data_format,
659
+ app_mode=self.app_mode,
578
660
  replace=replace,
579
661
  link=link or None,
580
662
  ),
@@ -582,49 +664,87 @@ def create_app( # noqa: C901
582
664
 
583
665
  return out
584
666
 
585
- app.show_create_functions = show_create_functions # type: ignore
586
-
587
667
  def register_functions(
668
+ self,
588
669
  *connection_args: Any,
589
670
  replace: bool = False,
590
671
  **connection_kwargs: Any,
591
672
  ) -> None:
592
- """Register functions with the database."""
673
+ """
674
+ Register functions with the database.
675
+
676
+ Parameters
677
+ ----------
678
+ *connection_args : Any
679
+ Database connection parameters
680
+ replace : bool, optional
681
+ Should existing functions be replaced?
682
+ **connection_kwargs : Any
683
+ Database connection parameters
684
+
685
+ """
593
686
  with connection.connect(*connection_args, **connection_kwargs) as conn:
594
687
  with conn.cursor() as cur:
595
688
  if replace:
596
- funcs, links = _locate_app_functions(cur)
689
+ funcs, links = self._locate_app_functions(cur)
597
690
  for fname in funcs:
598
691
  cur.execute(f'DROP FUNCTION IF EXISTS `{fname}`')
599
692
  for link in links:
600
693
  cur.execute(f'DROP LINK {link}')
601
- for func in app.show_create_functions(replace=replace): # type: ignore
694
+ for func in self.show_create_functions(replace=replace):
602
695
  cur.execute(func)
603
696
 
604
- app.register_functions = register_functions # type: ignore
605
-
606
697
  def drop_functions(
698
+ self,
607
699
  *connection_args: Any,
608
700
  **connection_kwargs: Any,
609
701
  ) -> None:
610
- """Drop registered functions from database."""
702
+ """
703
+ Drop registered functions from database.
704
+
705
+ Parameters
706
+ ----------
707
+ *connection_args : Any
708
+ Database connection parameters
709
+ **connection_kwargs : Any
710
+ Database connection parameters
711
+
712
+ """
611
713
  with connection.connect(*connection_args, **connection_kwargs) as conn:
612
714
  with conn.cursor() as cur:
613
- funcs, links = _locate_app_functions(cur)
715
+ funcs, links = self._locate_app_functions(cur)
614
716
  for fname in funcs:
615
717
  cur.execute(f'DROP FUNCTION IF EXISTS `{fname}`')
616
718
  for link in links:
617
719
  cur.execute(f'DROP LINK {link}')
618
720
 
619
- app.drop_functions = drop_functions # type: ignore
620
-
621
721
  async def call(
722
+ self,
622
723
  name: str,
623
724
  data_in: io.BytesIO,
624
725
  data_out: io.BytesIO,
625
- data_format: str = data_format,
626
- data_version: str = data_version,
726
+ data_format: Optional[str] = None,
727
+ data_version: Optional[str] = None,
627
728
  ) -> None:
729
+ """
730
+ Call a function in the application.
731
+
732
+ Parameters
733
+ ----------
734
+ name : str
735
+ Name of the function to call
736
+ data_in : io.BytesIO
737
+ The input data rows
738
+ data_out : io.BytesIO
739
+ The output data rows
740
+ data_format : str, optional
741
+ The format of the input and output data
742
+ data_version : str, optional
743
+ The version of the data format
744
+
745
+ """
746
+ data_format = data_format or self.data_format
747
+ data_version = data_version or self.data_version
628
748
 
629
749
  async def receive() -> Dict[str, Any]:
630
750
  return dict(body=data_in.read())
@@ -654,8 +774,400 @@ def create_app( # noqa: C901
654
774
  },
655
775
  )
656
776
 
657
- await app(scope, receive, send)
777
+ await self(scope, receive, send)
778
+
779
+ def to_environment(
780
+ self,
781
+ name: str,
782
+ destination: str = '.',
783
+ version: Optional[str] = None,
784
+ dependencies: Optional[List[str]] = None,
785
+ authors: Optional[List[Dict[str, str]]] = None,
786
+ maintainers: Optional[List[Dict[str, str]]] = None,
787
+ description: Optional[str] = None,
788
+ container_service: Optional[Dict[str, Any]] = None,
789
+ external_function: Optional[Dict[str, Any]] = None,
790
+ external_function_remote: Optional[Dict[str, Any]] = None,
791
+ external_function_collocated: Optional[Dict[str, Any]] = None,
792
+ overwrite: bool = False,
793
+ ) -> None:
794
+ """
795
+ Convert application to an environment file.
796
+
797
+ Parameters
798
+ ----------
799
+ name : str
800
+ Name of the output environment
801
+ destination : str, optional
802
+ Location of the output file
803
+ version : str, optional
804
+ Version of the package
805
+ dependencies : List[str], optional
806
+ List of dependency specifications like in a requirements.txt file
807
+ authors : List[Dict[str, Any]], optional
808
+ Dictionaries of author information. Keys may include: email, name
809
+ maintainers : List[Dict[str, Any]], optional
810
+ Dictionaries of maintainer information. Keys may include: email, name
811
+ description : str, optional
812
+ Description of package
813
+ container_service : Dict[str, Any], optional
814
+ Container service specifications
815
+ external_function : Dict[str, Any], optional
816
+ External function specifications (applies to both remote and collocated)
817
+ external_function_remote : Dict[str, Any], optional
818
+ Remote external function specifications
819
+ external_function_collocated : Dict[str, Any], optional
820
+ Collocated external function specifications
821
+ overwrite : bool, optional
822
+ Should destination file be overwritten if it exists?
823
+
824
+ """
825
+ if not has_cloudpickle:
826
+ raise RuntimeError('the cloudpicke package is required for this operation')
827
+
828
+ # Write to temporary location if a remote destination is specified
829
+ tmpdir = None
830
+ if destination.startswith('stage://'):
831
+ tmpdir = tempfile.TemporaryDirectory()
832
+ local_path = os.path.join(tmpdir.name, f'{name}.env')
833
+ else:
834
+ local_path = os.path.join(destination, f'{name}.env')
835
+ if not overwrite and os.path.exists(local_path):
836
+ raise OSError(f'path already exists: {local_path}')
837
+
838
+ with zipfile.ZipFile(local_path, mode='w') as z:
839
+ # Write metadata
840
+ z.writestr(
841
+ 'pyproject.toml', utils.to_toml({
842
+ 'project': dict(
843
+ name=name,
844
+ version=version,
845
+ dependencies=dependencies,
846
+ requires_python='== ' +
847
+ '.'.join(str(x) for x in sys.version_info[:3]),
848
+ authors=authors,
849
+ maintainers=maintainers,
850
+ description=description,
851
+ ),
852
+ 'tool.container-service': container_service,
853
+ 'tool.external-function': external_function,
854
+ 'tool.external-function.remote': external_function_remote,
855
+ 'tool.external-function.collocated': external_function_collocated,
856
+ }),
857
+ )
858
+
859
+ # Write Python package
860
+ z.writestr(
861
+ f'{name}/__init__.py',
862
+ textwrap.dedent(f'''
863
+ import pickle as _pkl
864
+ globals().update(
865
+ _pkl.loads({cloudpickle.dumps(self.external_functions)}),
866
+ )
867
+ __all__ = {list(self.external_functions.keys())}''').strip(),
868
+ )
869
+
870
+ # Upload to Stage as needed
871
+ if destination.startswith('stage://'):
872
+ url = urllib.parse.urlparse(re.sub(r'/+$', r'', destination) + '/')
873
+ if not url.path or url.path == '/':
874
+ raise ValueError(f'no stage path was specified: {destination}')
875
+
876
+ mgr = manage_workspaces()
877
+ if url.hostname:
878
+ wsg = mgr.get_workspace_group(url.hostname)
879
+ elif os.environ.get('SINGLESTOREDB_WORKSPACE_GROUP'):
880
+ wsg = mgr.get_workspace_group(
881
+ os.environ['SINGLESTOREDB_WORKSPACE_GROUP'],
882
+ )
883
+ else:
884
+ raise ValueError(f'no workspace group specified: {destination}')
885
+
886
+ # Make intermediate directories
887
+ if url.path.count('/') > 1:
888
+ wsg.stage.mkdirs(os.path.dirname(url.path))
889
+
890
+ wsg.stage.upload_file(
891
+ local_path, url.path + f'{name}.env',
892
+ overwrite=overwrite,
893
+ )
894
+ os.remove(local_path)
895
+
896
+
897
+ def main(argv: Optional[List[str]] = None) -> None:
898
+ """
899
+ Main program for HTTP-based Python UDFs
900
+
901
+ Parameters
902
+ ----------
903
+ argv : List[str], optional
904
+ List of command-line parameters
905
+
906
+ """
907
+ try:
908
+ import uvicorn
909
+ except ImportError:
910
+ raise ImportError('the uvicorn package is required to run this command')
911
+
912
+ # Should we run in embedded mode (typically for Jupyter)
913
+ try:
914
+ asyncio.get_running_loop()
915
+ use_async = True
916
+ except RuntimeError:
917
+ use_async = False
918
+
919
+ # Temporary directory for Stage environment files
920
+ tmpdir = None
921
+
922
+ # Depending on whether we find an environment file specified, we
923
+ # may have to process the command line twice.
924
+ functions = []
925
+ defaults: Dict[str, Any] = {}
926
+ for i in range(2):
927
+
928
+ parser = argparse.ArgumentParser(
929
+ prog='python -m singlestoredb.functions.ext.asgi',
930
+ description='Run an HTTP-based Python UDF server',
931
+ )
932
+ parser.add_argument(
933
+ '--url', metavar='url',
934
+ default=defaults.get(
935
+ 'url',
936
+ get_option('external_function.url'),
937
+ ),
938
+ help='URL of the UDF server endpoint',
939
+ )
940
+ parser.add_argument(
941
+ '--host', metavar='host',
942
+ default=defaults.get(
943
+ 'host',
944
+ get_option('external_function.host'),
945
+ ),
946
+ help='bind socket to this host',
947
+ )
948
+ parser.add_argument(
949
+ '--port', metavar='port', type=int,
950
+ default=defaults.get(
951
+ 'port',
952
+ get_option('external_function.port'),
953
+ ),
954
+ help='bind socket to this port',
955
+ )
956
+ parser.add_argument(
957
+ '--db', metavar='conn-str',
958
+ default=defaults.get(
959
+ 'connection',
960
+ get_option('external_function.connection'),
961
+ ),
962
+ help='connection string to use for registering functions',
963
+ )
964
+ parser.add_argument(
965
+ '--replace-existing', action='store_true',
966
+ help='should existing functions of the same name '
967
+ 'in the database be replaced?',
968
+ )
969
+ parser.add_argument(
970
+ '--data-format', metavar='format',
971
+ default=defaults.get(
972
+ 'data_format',
973
+ get_option('external_function.data_format'),
974
+ ),
975
+ choices=['rowdat_1', 'json'],
976
+ help='format of the data rows',
977
+ )
978
+ parser.add_argument(
979
+ '--data-version', metavar='version',
980
+ default=defaults.get(
981
+ 'data_version',
982
+ get_option('external_function.data_version'),
983
+ ),
984
+ help='version of the data row format',
985
+ )
986
+ parser.add_argument(
987
+ '--link-name', metavar='name',
988
+ default=defaults.get(
989
+ 'link_name',
990
+ get_option('external_function.link_name'),
991
+ ) or '',
992
+ help='name of the link to use for connections',
993
+ )
994
+ parser.add_argument(
995
+ '--link-config', metavar='json',
996
+ default=str(
997
+ defaults.get(
998
+ 'link_config',
999
+ get_option('external_function.link_config'),
1000
+ ) or '{}',
1001
+ ),
1002
+ help='link config in JSON format',
1003
+ )
1004
+ parser.add_argument(
1005
+ '--link-credentials', metavar='json',
1006
+ default=str(
1007
+ defaults.get(
1008
+ 'link_credentials',
1009
+ get_option('external_function.link_credentials'),
1010
+ ) or '{}',
1011
+ ),
1012
+ help='link credentials in JSON format',
1013
+ )
1014
+ parser.add_argument(
1015
+ '--log-level', metavar='[info|debug|warning|error]',
1016
+ default=defaults.get(
1017
+ 'log_level',
1018
+ get_option('external_function.log_level'),
1019
+ ),
1020
+ help='logging level',
1021
+ )
1022
+ parser.add_argument(
1023
+ 'functions', metavar='module.or.func.path', nargs='*',
1024
+ help='functions or modules to export in UDF server',
1025
+ )
1026
+
1027
+ args = parser.parse_args(argv)
1028
+
1029
+ logger.setLevel(getattr(logging, args.log_level.upper()))
1030
+
1031
+ if i > 0:
1032
+ break
658
1033
 
659
- app.call = call # type: ignore
1034
+ # Download Stage files as needed
1035
+ for i, f in enumerate(args.functions):
1036
+ if f.startswith('stage://'):
1037
+ url = urllib.parse.urlparse(f)
1038
+ if not url.path or url.path == '/':
1039
+ raise ValueError(f'no stage path was specified: {f}')
1040
+ if url.path.endswith('/'):
1041
+ raise ValueError(f'an environment file must be specified: {f}')
1042
+
1043
+ mgr = manage_workspaces()
1044
+ if url.hostname:
1045
+ wsg = mgr.get_workspace_group(url.hostname)
1046
+ elif os.environ.get('SINGLESTOREDB_WORKSPACE_GROUP'):
1047
+ wsg = mgr.get_workspace_group(
1048
+ os.environ['SINGLESTOREDB_WORKSPACE_GROUP'],
1049
+ )
1050
+ else:
1051
+ raise ValueError(f'no workspace group specified: {f}')
1052
+
1053
+ if tmpdir is None:
1054
+ tmpdir = tempfile.TemporaryDirectory()
1055
+
1056
+ local_path = os.path.join(tmpdir.name, url.path.split('/')[-1])
1057
+ wsg.stage.download_file(url.path, local_path)
1058
+ args.functions[i] = local_path
1059
+
1060
+ elif f.startswith('http://') or f.startswith('https://'):
1061
+ if tmpdir is None:
1062
+ tmpdir = tempfile.TemporaryDirectory()
1063
+
1064
+ local_path = os.path.join(tmpdir.name, f.split('/')[-1])
1065
+ urllib.request.urlretrieve(f, local_path)
1066
+ args.functions[i] = local_path
1067
+
1068
+ # See if any of the args are zip files (assume they are environment files)
1069
+ modules = [(x, zipfile.is_zipfile(x)) for x in args.functions]
1070
+ envs = [x[0] for x in modules if x[1]]
1071
+ others = [x[0] for x in modules if not x[1]]
1072
+
1073
+ if envs and len(envs) > 1:
1074
+ raise RuntimeError('only one environment file may be specified')
1075
+
1076
+ if envs and others:
1077
+ raise RuntimeError('environment files and other modules can not be mixed.')
1078
+
1079
+ # See if an environment file was specified. If so, use those settings
1080
+ # as the defaults and reprocess command line.
1081
+ if envs:
1082
+ # Add pyproject.toml variables and redo command-line processing
1083
+ defaults = utils.read_config(
1084
+ envs[0],
1085
+ ['tool.external-function', 'tool.external-function.remote'],
1086
+ )
1087
+
1088
+ # Load zip file as a module
1089
+ modname = os.path.splitext(os.path.basename(envs[0]))[0]
1090
+ zi = zipimport.zipimporter(envs[0])
1091
+ mod = zi.load_module(modname)
1092
+ if mod is None:
1093
+ raise RuntimeError(f'environment file could not be imported: {envs[0]}')
1094
+ functions = [mod]
1095
+
1096
+ if defaults:
1097
+ continue
1098
+
1099
+ args.functions = functions or args.functions or None
1100
+ args.replace_existing = args.replace_existing \
1101
+ or defaults.get('replace_existing') \
1102
+ or get_option('external_function.replace_existing')
1103
+
1104
+ # Create application from functions / module
1105
+ app = Application(
1106
+ functions=args.functions,
1107
+ url=args.url,
1108
+ data_format=args.data_format,
1109
+ data_version=args.data_version,
1110
+ link_name=args.link_name or None,
1111
+ link_config=json.loads(args.link_config) or None,
1112
+ link_credentials=json.loads(args.link_credentials) or None,
1113
+ app_mode='remote',
1114
+ )
660
1115
 
661
- return app
1116
+ funcs = app.show_create_functions(replace=args.replace_existing)
1117
+ if not funcs:
1118
+ raise RuntimeError('no functions specified')
1119
+
1120
+ for f in funcs:
1121
+ logger.info(f)
1122
+
1123
+ try:
1124
+ if args.db:
1125
+ logger.info('registering functions with database')
1126
+ app.register_functions(
1127
+ args.db,
1128
+ replace=args.replace_existing,
1129
+ )
1130
+
1131
+ app_args = {
1132
+ k: v for k, v in dict(
1133
+ host=args.host or None,
1134
+ port=args.port or None,
1135
+ log_level=args.log_level,
1136
+ ).items() if v is not None
1137
+ }
1138
+
1139
+ if use_async:
1140
+ asyncio.create_task(_run_uvicorn(uvicorn, app, app_args, db=args.db))
1141
+ else:
1142
+ uvicorn.run(app, **app_args)
1143
+
1144
+ finally:
1145
+ if not use_async and args.db:
1146
+ logger.info('dropping functions from database')
1147
+ app.drop_functions(args.db)
1148
+
1149
+
1150
+ async def _run_uvicorn(
1151
+ uvicorn: Any,
1152
+ app: Any,
1153
+ app_args: Any,
1154
+ db: Optional[str] = None,
1155
+ ) -> None:
1156
+ """Run uvicorn server and clean up functions after shutdown."""
1157
+ await uvicorn.Server(uvicorn.Config(app, **app_args)).serve()
1158
+ if db:
1159
+ logger.info('dropping functions from database')
1160
+ app.drop_functions(db)
1161
+
1162
+
1163
+ create_app = Application
1164
+
1165
+
1166
+ if __name__ == '__main__':
1167
+ try:
1168
+ main()
1169
+ except RuntimeError as exc:
1170
+ logger.error(str(exc))
1171
+ sys.exit(1)
1172
+ except KeyboardInterrupt:
1173
+ pass