singlestoredb 1.0.4__cp38-abi3-win_amd64.whl → 1.2.0__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.

Files changed (41) hide show
  1. _singlestoredb_accel.pyd +0 -0
  2. singlestoredb/__init__.py +1 -1
  3. singlestoredb/config.py +131 -0
  4. singlestoredb/connection.py +3 -0
  5. singlestoredb/converters.py +390 -0
  6. singlestoredb/functions/dtypes.py +5 -198
  7. singlestoredb/functions/ext/__init__.py +0 -1
  8. singlestoredb/functions/ext/asgi.py +671 -153
  9. singlestoredb/functions/ext/json.py +2 -2
  10. singlestoredb/functions/ext/mmap.py +174 -67
  11. singlestoredb/functions/ext/rowdat_1.py +2 -2
  12. singlestoredb/functions/ext/utils.py +169 -0
  13. singlestoredb/fusion/handler.py +115 -9
  14. singlestoredb/fusion/handlers/stage.py +246 -13
  15. singlestoredb/fusion/handlers/workspace.py +417 -14
  16. singlestoredb/fusion/registry.py +86 -1
  17. singlestoredb/http/connection.py +40 -2
  18. singlestoredb/management/__init__.py +1 -0
  19. singlestoredb/management/organization.py +4 -0
  20. singlestoredb/management/utils.py +2 -2
  21. singlestoredb/management/workspace.py +79 -6
  22. singlestoredb/mysql/connection.py +81 -0
  23. singlestoredb/mysql/constants/EXTENDED_TYPE.py +3 -0
  24. singlestoredb/mysql/constants/FIELD_TYPE.py +16 -0
  25. singlestoredb/mysql/constants/VECTOR_TYPE.py +6 -0
  26. singlestoredb/mysql/cursors.py +177 -4
  27. singlestoredb/mysql/protocol.py +50 -1
  28. singlestoredb/notebook/__init__.py +15 -0
  29. singlestoredb/notebook/_objects.py +212 -0
  30. singlestoredb/tests/test.sql +259 -0
  31. singlestoredb/tests/test_connection.py +1715 -133
  32. singlestoredb/tests/test_ext_func.py +2 -2
  33. singlestoredb/tests/test_ext_func_data.py +1 -1
  34. singlestoredb/utils/dtypes.py +205 -0
  35. singlestoredb/utils/results.py +367 -14
  36. {singlestoredb-1.0.4.dist-info → singlestoredb-1.2.0.dist-info}/METADATA +2 -1
  37. {singlestoredb-1.0.4.dist-info → singlestoredb-1.2.0.dist-info}/RECORD +41 -35
  38. {singlestoredb-1.0.4.dist-info → singlestoredb-1.2.0.dist-info}/LICENSE +0 -0
  39. {singlestoredb-1.0.4.dist-info → singlestoredb-1.2.0.dist-info}/WHEEL +0 -0
  40. {singlestoredb-1.0.4.dist-info → singlestoredb-1.2.0.dist-info}/entry_points.txt +0 -0
  41. {singlestoredb-1.0.4.dist-info → singlestoredb-1.2.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,130 @@ 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
+ elif '.' in funcs:
459
+ pkg_path, func_names = funcs.rsplit('.', 1)
460
+ pkg = importlib.import_module(pkg_path)
461
+
462
+ if pkg is None:
463
+ raise RuntimeError(f'Could not locate module: {pkg}')
464
+
465
+ # Add endpoint for each exported function
466
+ for name, alias in get_func_names(func_names):
467
+ item = getattr(pkg, name)
468
+ external_functions[name] = item
469
+ func, info = make_func(alias, item)
470
+ endpoints[alias.encode('utf-8')] = func, info
471
+
472
+ else:
473
+ raise RuntimeError(f'Could not locate module: {funcs}')
474
+
475
+ elif isinstance(funcs, ModuleType):
476
+ for x in vars(funcs).values():
477
+ if not hasattr(x, '_singlestoredb_attrs'):
478
+ continue
479
+ name = x._singlestoredb_attrs.get('name', x.__name__)
480
+ external_functions[x.__name__] = x
481
+ func, info = make_func(name, x)
482
+ endpoints[name.encode('utf-8')] = func, info
483
+
484
+ else:
485
+ alias = funcs.__name__
486
+ external_functions[funcs.__name__] = funcs
487
+ func, info = make_func(alias, funcs)
488
+ endpoints[alias.encode('utf-8')] = func, info
489
+
490
+ self.app_mode = app_mode
491
+ self.url = url
492
+ self.data_format = data_format
493
+ self.data_version = data_version
494
+ self.link_name = link_name
495
+ self.link_config = link_config
496
+ self.link_credentials = link_credentials
497
+ self.endpoints = endpoints
498
+ self.external_functions = external_functions
499
+
500
+ async def __call__(
501
+ self,
436
502
  scope: Dict[str, Any],
437
503
  receive: Callable[..., Awaitable[Any]],
438
504
  send: Callable[..., Awaitable[Any]],
@@ -462,11 +528,16 @@ def create_app( # noqa: C901
462
528
  )
463
529
  accepts = headers.get(b'accepts', content_type)
464
530
  func_name = headers.get(b's2-ef-name', b'')
465
- func = endpoints.get(func_name)
531
+ func_endpoint = self.endpoints.get(func_name)
532
+
533
+ func = None
534
+ func_info: Dict[str, Any] = {}
535
+ if func_endpoint is not None:
536
+ func, func_info = func_endpoint
466
537
 
467
538
  # 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
539
+ if method == 'POST' and func is not None and path == self.invoke_path:
540
+ data_format = func_info['data_format']
470
541
  data = []
471
542
  more_body = True
472
543
  while more_body:
@@ -475,57 +546,61 @@ def create_app( # noqa: C901
475
546
  more_body = request.get('more_body', False)
476
547
 
477
548
  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)]
549
+ input_handler = self.handlers[(content_type, data_version, data_format)]
550
+ output_handler = self.handlers[(accepts, data_version, data_format)]
480
551
 
481
552
  out = await func(
482
- *input_handler['load'](
483
- func._ext_func_colspec, b''.join(data), # type: ignore
553
+ *input_handler['load']( # type: ignore
554
+ func_info['colspec'], b''.join(data),
484
555
  ),
485
556
  )
486
- body = output_handler['dump'](func._ext_func_returns, *out) # type: ignore
557
+ body = output_handler['dump'](func_info['returns'], *out) # type: ignore
487
558
 
488
559
  await send(output_handler['response'])
489
560
 
490
561
  # Handle api reflection
491
- elif method == 'GET' and path == show_create_function_path:
562
+ elif method == 'GET' and path == self.show_create_function_path:
492
563
  host = headers.get(b'host', b'localhost:80')
493
564
  reflected_url = f'{scope["scheme"]}://{host.decode("utf-8")}/invoke'
494
565
  data_format = 'json' if b'json' in content_type else 'rowdat_1'
495
566
 
496
567
  syntax = []
497
- for key, endpoint in endpoints.items():
568
+ for key, (endpoint, endpoint_info) in self.endpoints.items():
498
569
  if not func_name or key == func_name:
499
570
  syntax.append(
500
571
  signature_to_sql(
501
- endpoint._ext_func_signature, # type: ignore
502
- url=url or reflected_url,
572
+ endpoint_info['signature'],
573
+ url=self.url or reflected_url,
503
574
  data_format=data_format,
504
575
  ),
505
576
  )
506
577
  body = '\n'.join(syntax).encode('utf-8')
507
578
 
508
- await send(text_response_dict)
579
+ await send(self.text_response_dict)
509
580
 
510
581
  # Path not found
511
582
  else:
512
583
  body = b''
513
- await send(path_not_found_response_dict)
584
+ await send(self.path_not_found_response_dict)
514
585
 
515
586
  # Send body
516
- out = body_response_dict.copy()
587
+ out = self.body_response_dict.copy()
517
588
  out['body'] = body
518
589
  await send(out)
519
590
 
520
591
  def _create_link(
592
+ self,
521
593
  config: Optional[Dict[str, Any]],
522
594
  credentials: Optional[Dict[str, Any]],
523
595
  ) -> Tuple[str, str]:
524
596
  """Generate CREATE LINK command."""
597
+ if self.link_name:
598
+ return self.link_name, ''
599
+
525
600
  if not config and not credentials:
526
601
  return '', ''
527
602
 
528
- link_name = f'link_{secrets.token_hex(16)}'
603
+ link_name = f'py_ext_func_link_{secrets.token_hex(14)}'
529
604
  out = [f'CREATE LINK {link_name} AS HTTP']
530
605
 
531
606
  if config:
@@ -536,7 +611,7 @@ def create_app( # noqa: C901
536
611
 
537
612
  return link_name, ' '.join(out) + ';'
538
613
 
539
- def _locate_app_functions(cur: Any) -> Tuple[Set[str], Set[str]]:
614
+ def _locate_app_functions(self, cur: Any) -> Tuple[Set[str], Set[str]]:
540
615
  """Locate all current functions and links belonging to this app."""
541
616
  funcs, links = set(), set()
542
617
  cur.execute('SHOW FUNCTIONS')
@@ -548,33 +623,46 @@ def create_app( # noqa: C901
548
623
  cur.execute(f'SHOW CREATE FUNCTION `{name}`')
549
624
  for fname, _, code, *_ in list(cur):
550
625
  m = re.search(r" (?:\w+) SERVICE '([^']+)'", code)
551
- if m and m.group(1) == url:
626
+ if m and m.group(1) == self.url:
552
627
  funcs.add(fname)
553
- if link:
628
+ if link and re.match(r'^py_ext_func_link_\S{14}$', link):
554
629
  links.add(link)
555
630
  return funcs, links
556
631
 
557
632
  def show_create_functions(
633
+ self,
558
634
  replace: bool = False,
559
635
  ) -> List[str]:
560
- """Generate CREATE FUNCTION calls."""
561
- if not endpoints:
636
+ """
637
+ Generate CREATE FUNCTION code for all functions.
638
+
639
+ Parameters
640
+ ----------
641
+ replace : bool, optional
642
+ Should existing functions be replaced?
643
+
644
+ Returns
645
+ -------
646
+ List[str]
647
+
648
+ """
649
+ if not self.endpoints:
562
650
  return []
563
651
 
564
652
  out = []
565
653
  link = ''
566
- if app_mode.lower() == 'remote':
567
- link, link_str = _create_link(link_config, link_credentials)
654
+ if self.app_mode.lower() == 'remote':
655
+ link, link_str = self._create_link(self.link_config, self.link_credentials)
568
656
  if link and link_str:
569
657
  out.append(link_str)
570
658
 
571
- for key, endpoint in endpoints.items():
659
+ for key, (endpoint, endpoint_info) in self.endpoints.items():
572
660
  out.append(
573
661
  signature_to_sql(
574
- endpoint._ext_func_signature, # type: ignore
575
- url=url,
576
- data_format=data_format,
577
- app_mode=app_mode,
662
+ endpoint_info['signature'],
663
+ url=self.url,
664
+ data_format=self.data_format,
665
+ app_mode=self.app_mode,
578
666
  replace=replace,
579
667
  link=link or None,
580
668
  ),
@@ -582,49 +670,87 @@ def create_app( # noqa: C901
582
670
 
583
671
  return out
584
672
 
585
- app.show_create_functions = show_create_functions # type: ignore
586
-
587
673
  def register_functions(
674
+ self,
588
675
  *connection_args: Any,
589
676
  replace: bool = False,
590
677
  **connection_kwargs: Any,
591
678
  ) -> None:
592
- """Register functions with the database."""
679
+ """
680
+ Register functions with the database.
681
+
682
+ Parameters
683
+ ----------
684
+ *connection_args : Any
685
+ Database connection parameters
686
+ replace : bool, optional
687
+ Should existing functions be replaced?
688
+ **connection_kwargs : Any
689
+ Database connection parameters
690
+
691
+ """
593
692
  with connection.connect(*connection_args, **connection_kwargs) as conn:
594
693
  with conn.cursor() as cur:
595
694
  if replace:
596
- funcs, links = _locate_app_functions(cur)
695
+ funcs, links = self._locate_app_functions(cur)
597
696
  for fname in funcs:
598
697
  cur.execute(f'DROP FUNCTION IF EXISTS `{fname}`')
599
698
  for link in links:
600
699
  cur.execute(f'DROP LINK {link}')
601
- for func in app.show_create_functions(replace=replace): # type: ignore
700
+ for func in self.show_create_functions(replace=replace):
602
701
  cur.execute(func)
603
702
 
604
- app.register_functions = register_functions # type: ignore
605
-
606
703
  def drop_functions(
704
+ self,
607
705
  *connection_args: Any,
608
706
  **connection_kwargs: Any,
609
707
  ) -> None:
610
- """Drop registered functions from database."""
708
+ """
709
+ Drop registered functions from database.
710
+
711
+ Parameters
712
+ ----------
713
+ *connection_args : Any
714
+ Database connection parameters
715
+ **connection_kwargs : Any
716
+ Database connection parameters
717
+
718
+ """
611
719
  with connection.connect(*connection_args, **connection_kwargs) as conn:
612
720
  with conn.cursor() as cur:
613
- funcs, links = _locate_app_functions(cur)
721
+ funcs, links = self._locate_app_functions(cur)
614
722
  for fname in funcs:
615
723
  cur.execute(f'DROP FUNCTION IF EXISTS `{fname}`')
616
724
  for link in links:
617
725
  cur.execute(f'DROP LINK {link}')
618
726
 
619
- app.drop_functions = drop_functions # type: ignore
620
-
621
727
  async def call(
728
+ self,
622
729
  name: str,
623
730
  data_in: io.BytesIO,
624
731
  data_out: io.BytesIO,
625
- data_format: str = data_format,
626
- data_version: str = data_version,
732
+ data_format: Optional[str] = None,
733
+ data_version: Optional[str] = None,
627
734
  ) -> None:
735
+ """
736
+ Call a function in the application.
737
+
738
+ Parameters
739
+ ----------
740
+ name : str
741
+ Name of the function to call
742
+ data_in : io.BytesIO
743
+ The input data rows
744
+ data_out : io.BytesIO
745
+ The output data rows
746
+ data_format : str, optional
747
+ The format of the input and output data
748
+ data_version : str, optional
749
+ The version of the data format
750
+
751
+ """
752
+ data_format = data_format or self.data_format
753
+ data_version = data_version or self.data_version
628
754
 
629
755
  async def receive() -> Dict[str, Any]:
630
756
  return dict(body=data_in.read())
@@ -654,8 +780,400 @@ def create_app( # noqa: C901
654
780
  },
655
781
  )
656
782
 
657
- await app(scope, receive, send)
783
+ await self(scope, receive, send)
784
+
785
+ def to_environment(
786
+ self,
787
+ name: str,
788
+ destination: str = '.',
789
+ version: Optional[str] = None,
790
+ dependencies: Optional[List[str]] = None,
791
+ authors: Optional[List[Dict[str, str]]] = None,
792
+ maintainers: Optional[List[Dict[str, str]]] = None,
793
+ description: Optional[str] = None,
794
+ container_service: Optional[Dict[str, Any]] = None,
795
+ external_function: Optional[Dict[str, Any]] = None,
796
+ external_function_remote: Optional[Dict[str, Any]] = None,
797
+ external_function_collocated: Optional[Dict[str, Any]] = None,
798
+ overwrite: bool = False,
799
+ ) -> None:
800
+ """
801
+ Convert application to an environment file.
802
+
803
+ Parameters
804
+ ----------
805
+ name : str
806
+ Name of the output environment
807
+ destination : str, optional
808
+ Location of the output file
809
+ version : str, optional
810
+ Version of the package
811
+ dependencies : List[str], optional
812
+ List of dependency specifications like in a requirements.txt file
813
+ authors : List[Dict[str, Any]], optional
814
+ Dictionaries of author information. Keys may include: email, name
815
+ maintainers : List[Dict[str, Any]], optional
816
+ Dictionaries of maintainer information. Keys may include: email, name
817
+ description : str, optional
818
+ Description of package
819
+ container_service : Dict[str, Any], optional
820
+ Container service specifications
821
+ external_function : Dict[str, Any], optional
822
+ External function specifications (applies to both remote and collocated)
823
+ external_function_remote : Dict[str, Any], optional
824
+ Remote external function specifications
825
+ external_function_collocated : Dict[str, Any], optional
826
+ Collocated external function specifications
827
+ overwrite : bool, optional
828
+ Should destination file be overwritten if it exists?
829
+
830
+ """
831
+ if not has_cloudpickle:
832
+ raise RuntimeError('the cloudpicke package is required for this operation')
833
+
834
+ # Write to temporary location if a remote destination is specified
835
+ tmpdir = None
836
+ if destination.startswith('stage://'):
837
+ tmpdir = tempfile.TemporaryDirectory()
838
+ local_path = os.path.join(tmpdir.name, f'{name}.env')
839
+ else:
840
+ local_path = os.path.join(destination, f'{name}.env')
841
+ if not overwrite and os.path.exists(local_path):
842
+ raise OSError(f'path already exists: {local_path}')
843
+
844
+ with zipfile.ZipFile(local_path, mode='w') as z:
845
+ # Write metadata
846
+ z.writestr(
847
+ 'pyproject.toml', utils.to_toml({
848
+ 'project': dict(
849
+ name=name,
850
+ version=version,
851
+ dependencies=dependencies,
852
+ requires_python='== ' +
853
+ '.'.join(str(x) for x in sys.version_info[:3]),
854
+ authors=authors,
855
+ maintainers=maintainers,
856
+ description=description,
857
+ ),
858
+ 'tool.container-service': container_service,
859
+ 'tool.external-function': external_function,
860
+ 'tool.external-function.remote': external_function_remote,
861
+ 'tool.external-function.collocated': external_function_collocated,
862
+ }),
863
+ )
864
+
865
+ # Write Python package
866
+ z.writestr(
867
+ f'{name}/__init__.py',
868
+ textwrap.dedent(f'''
869
+ import pickle as _pkl
870
+ globals().update(
871
+ _pkl.loads({cloudpickle.dumps(self.external_functions)}),
872
+ )
873
+ __all__ = {list(self.external_functions.keys())}''').strip(),
874
+ )
875
+
876
+ # Upload to Stage as needed
877
+ if destination.startswith('stage://'):
878
+ url = urllib.parse.urlparse(re.sub(r'/+$', r'', destination) + '/')
879
+ if not url.path or url.path == '/':
880
+ raise ValueError(f'no stage path was specified: {destination}')
881
+
882
+ mgr = manage_workspaces()
883
+ if url.hostname:
884
+ wsg = mgr.get_workspace_group(url.hostname)
885
+ elif os.environ.get('SINGLESTOREDB_WORKSPACE_GROUP'):
886
+ wsg = mgr.get_workspace_group(
887
+ os.environ['SINGLESTOREDB_WORKSPACE_GROUP'],
888
+ )
889
+ else:
890
+ raise ValueError(f'no workspace group specified: {destination}')
891
+
892
+ # Make intermediate directories
893
+ if url.path.count('/') > 1:
894
+ wsg.stage.mkdirs(os.path.dirname(url.path))
895
+
896
+ wsg.stage.upload_file(
897
+ local_path, url.path + f'{name}.env',
898
+ overwrite=overwrite,
899
+ )
900
+ os.remove(local_path)
901
+
902
+
903
+ def main(argv: Optional[List[str]] = None) -> None:
904
+ """
905
+ Main program for HTTP-based Python UDFs
906
+
907
+ Parameters
908
+ ----------
909
+ argv : List[str], optional
910
+ List of command-line parameters
911
+
912
+ """
913
+ try:
914
+ import uvicorn
915
+ except ImportError:
916
+ raise ImportError('the uvicorn package is required to run this command')
917
+
918
+ # Should we run in embedded mode (typically for Jupyter)
919
+ try:
920
+ asyncio.get_running_loop()
921
+ use_async = True
922
+ except RuntimeError:
923
+ use_async = False
924
+
925
+ # Temporary directory for Stage environment files
926
+ tmpdir = None
927
+
928
+ # Depending on whether we find an environment file specified, we
929
+ # may have to process the command line twice.
930
+ functions = []
931
+ defaults: Dict[str, Any] = {}
932
+ for i in range(2):
933
+
934
+ parser = argparse.ArgumentParser(
935
+ prog='python -m singlestoredb.functions.ext.asgi',
936
+ description='Run an HTTP-based Python UDF server',
937
+ )
938
+ parser.add_argument(
939
+ '--url', metavar='url',
940
+ default=defaults.get(
941
+ 'url',
942
+ get_option('external_function.url'),
943
+ ),
944
+ help='URL of the UDF server endpoint',
945
+ )
946
+ parser.add_argument(
947
+ '--host', metavar='host',
948
+ default=defaults.get(
949
+ 'host',
950
+ get_option('external_function.host'),
951
+ ),
952
+ help='bind socket to this host',
953
+ )
954
+ parser.add_argument(
955
+ '--port', metavar='port', type=int,
956
+ default=defaults.get(
957
+ 'port',
958
+ get_option('external_function.port'),
959
+ ),
960
+ help='bind socket to this port',
961
+ )
962
+ parser.add_argument(
963
+ '--db', metavar='conn-str',
964
+ default=defaults.get(
965
+ 'connection',
966
+ get_option('external_function.connection'),
967
+ ),
968
+ help='connection string to use for registering functions',
969
+ )
970
+ parser.add_argument(
971
+ '--replace-existing', action='store_true',
972
+ help='should existing functions of the same name '
973
+ 'in the database be replaced?',
974
+ )
975
+ parser.add_argument(
976
+ '--data-format', metavar='format',
977
+ default=defaults.get(
978
+ 'data_format',
979
+ get_option('external_function.data_format'),
980
+ ),
981
+ choices=['rowdat_1', 'json'],
982
+ help='format of the data rows',
983
+ )
984
+ parser.add_argument(
985
+ '--data-version', metavar='version',
986
+ default=defaults.get(
987
+ 'data_version',
988
+ get_option('external_function.data_version'),
989
+ ),
990
+ help='version of the data row format',
991
+ )
992
+ parser.add_argument(
993
+ '--link-name', metavar='name',
994
+ default=defaults.get(
995
+ 'link_name',
996
+ get_option('external_function.link_name'),
997
+ ) or '',
998
+ help='name of the link to use for connections',
999
+ )
1000
+ parser.add_argument(
1001
+ '--link-config', metavar='json',
1002
+ default=str(
1003
+ defaults.get(
1004
+ 'link_config',
1005
+ get_option('external_function.link_config'),
1006
+ ) or '{}',
1007
+ ),
1008
+ help='link config in JSON format',
1009
+ )
1010
+ parser.add_argument(
1011
+ '--link-credentials', metavar='json',
1012
+ default=str(
1013
+ defaults.get(
1014
+ 'link_credentials',
1015
+ get_option('external_function.link_credentials'),
1016
+ ) or '{}',
1017
+ ),
1018
+ help='link credentials in JSON format',
1019
+ )
1020
+ parser.add_argument(
1021
+ '--log-level', metavar='[info|debug|warning|error]',
1022
+ default=defaults.get(
1023
+ 'log_level',
1024
+ get_option('external_function.log_level'),
1025
+ ),
1026
+ help='logging level',
1027
+ )
1028
+ parser.add_argument(
1029
+ 'functions', metavar='module.or.func.path', nargs='*',
1030
+ help='functions or modules to export in UDF server',
1031
+ )
1032
+
1033
+ args = parser.parse_args(argv)
1034
+
1035
+ logger.setLevel(getattr(logging, args.log_level.upper()))
1036
+
1037
+ if i > 0:
1038
+ break
658
1039
 
659
- app.call = call # type: ignore
1040
+ # Download Stage files as needed
1041
+ for i, f in enumerate(args.functions):
1042
+ if f.startswith('stage://'):
1043
+ url = urllib.parse.urlparse(f)
1044
+ if not url.path or url.path == '/':
1045
+ raise ValueError(f'no stage path was specified: {f}')
1046
+ if url.path.endswith('/'):
1047
+ raise ValueError(f'an environment file must be specified: {f}')
1048
+
1049
+ mgr = manage_workspaces()
1050
+ if url.hostname:
1051
+ wsg = mgr.get_workspace_group(url.hostname)
1052
+ elif os.environ.get('SINGLESTOREDB_WORKSPACE_GROUP'):
1053
+ wsg = mgr.get_workspace_group(
1054
+ os.environ['SINGLESTOREDB_WORKSPACE_GROUP'],
1055
+ )
1056
+ else:
1057
+ raise ValueError(f'no workspace group specified: {f}')
1058
+
1059
+ if tmpdir is None:
1060
+ tmpdir = tempfile.TemporaryDirectory()
1061
+
1062
+ local_path = os.path.join(tmpdir.name, url.path.split('/')[-1])
1063
+ wsg.stage.download_file(url.path, local_path)
1064
+ args.functions[i] = local_path
1065
+
1066
+ elif f.startswith('http://') or f.startswith('https://'):
1067
+ if tmpdir is None:
1068
+ tmpdir = tempfile.TemporaryDirectory()
1069
+
1070
+ local_path = os.path.join(tmpdir.name, f.split('/')[-1])
1071
+ urllib.request.urlretrieve(f, local_path)
1072
+ args.functions[i] = local_path
1073
+
1074
+ # See if any of the args are zip files (assume they are environment files)
1075
+ modules = [(x, zipfile.is_zipfile(x)) for x in args.functions]
1076
+ envs = [x[0] for x in modules if x[1]]
1077
+ others = [x[0] for x in modules if not x[1]]
1078
+
1079
+ if envs and len(envs) > 1:
1080
+ raise RuntimeError('only one environment file may be specified')
1081
+
1082
+ if envs and others:
1083
+ raise RuntimeError('environment files and other modules can not be mixed.')
1084
+
1085
+ # See if an environment file was specified. If so, use those settings
1086
+ # as the defaults and reprocess command line.
1087
+ if envs:
1088
+ # Add pyproject.toml variables and redo command-line processing
1089
+ defaults = utils.read_config(
1090
+ envs[0],
1091
+ ['tool.external-function', 'tool.external-function.remote'],
1092
+ )
1093
+
1094
+ # Load zip file as a module
1095
+ modname = os.path.splitext(os.path.basename(envs[0]))[0]
1096
+ zi = zipimport.zipimporter(envs[0])
1097
+ mod = zi.load_module(modname)
1098
+ if mod is None:
1099
+ raise RuntimeError(f'environment file could not be imported: {envs[0]}')
1100
+ functions = [mod]
1101
+
1102
+ if defaults:
1103
+ continue
1104
+
1105
+ args.functions = functions or args.functions or None
1106
+ args.replace_existing = args.replace_existing \
1107
+ or defaults.get('replace_existing') \
1108
+ or get_option('external_function.replace_existing')
1109
+
1110
+ # Create application from functions / module
1111
+ app = Application(
1112
+ functions=args.functions,
1113
+ url=args.url,
1114
+ data_format=args.data_format,
1115
+ data_version=args.data_version,
1116
+ link_name=args.link_name or None,
1117
+ link_config=json.loads(args.link_config) or None,
1118
+ link_credentials=json.loads(args.link_credentials) or None,
1119
+ app_mode='remote',
1120
+ )
660
1121
 
661
- return app
1122
+ funcs = app.show_create_functions(replace=args.replace_existing)
1123
+ if not funcs:
1124
+ raise RuntimeError('no functions specified')
1125
+
1126
+ for f in funcs:
1127
+ logger.info(f)
1128
+
1129
+ try:
1130
+ if args.db:
1131
+ logger.info('registering functions with database')
1132
+ app.register_functions(
1133
+ args.db,
1134
+ replace=args.replace_existing,
1135
+ )
1136
+
1137
+ app_args = {
1138
+ k: v for k, v in dict(
1139
+ host=args.host or None,
1140
+ port=args.port or None,
1141
+ log_level=args.log_level,
1142
+ ).items() if v is not None
1143
+ }
1144
+
1145
+ if use_async:
1146
+ asyncio.create_task(_run_uvicorn(uvicorn, app, app_args, db=args.db))
1147
+ else:
1148
+ uvicorn.run(app, **app_args)
1149
+
1150
+ finally:
1151
+ if not use_async and args.db:
1152
+ logger.info('dropping functions from database')
1153
+ app.drop_functions(args.db)
1154
+
1155
+
1156
+ async def _run_uvicorn(
1157
+ uvicorn: Any,
1158
+ app: Any,
1159
+ app_args: Any,
1160
+ db: Optional[str] = None,
1161
+ ) -> None:
1162
+ """Run uvicorn server and clean up functions after shutdown."""
1163
+ await uvicorn.Server(uvicorn.Config(app, **app_args)).serve()
1164
+ if db:
1165
+ logger.info('dropping functions from database')
1166
+ app.drop_functions(db)
1167
+
1168
+
1169
+ create_app = Application
1170
+
1171
+
1172
+ if __name__ == '__main__':
1173
+ try:
1174
+ main()
1175
+ except RuntimeError as exc:
1176
+ logger.error(str(exc))
1177
+ sys.exit(1)
1178
+ except KeyboardInterrupt:
1179
+ pass