singlestoredb 0.8.8__cp36-abi3-win32.whl → 0.9.0__cp36-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 (41) hide show
  1. _singlestoredb_accel.pyd +0 -0
  2. singlestoredb/__init__.py +1 -1
  3. singlestoredb/config.py +6 -0
  4. singlestoredb/exceptions.py +24 -0
  5. singlestoredb/functions/__init__.py +1 -0
  6. singlestoredb/functions/decorator.py +165 -0
  7. singlestoredb/functions/dtypes.py +1396 -0
  8. singlestoredb/functions/ext/__init__.py +2 -0
  9. singlestoredb/functions/ext/asgi.py +357 -0
  10. singlestoredb/functions/ext/json.py +49 -0
  11. singlestoredb/functions/ext/rowdat_1.py +111 -0
  12. singlestoredb/functions/signature.py +607 -0
  13. singlestoredb/management/billing_usage.py +148 -0
  14. singlestoredb/management/manager.py +42 -1
  15. singlestoredb/management/organization.py +85 -0
  16. singlestoredb/management/utils.py +118 -1
  17. singlestoredb/management/workspace.py +881 -5
  18. singlestoredb/mysql/__init__.py +12 -10
  19. singlestoredb/mysql/_auth.py +3 -1
  20. singlestoredb/mysql/charset.py +12 -11
  21. singlestoredb/mysql/connection.py +4 -3
  22. singlestoredb/mysql/constants/CLIENT.py +0 -1
  23. singlestoredb/mysql/constants/COMMAND.py +0 -1
  24. singlestoredb/mysql/constants/CR.py +0 -2
  25. singlestoredb/mysql/constants/ER.py +0 -1
  26. singlestoredb/mysql/constants/FIELD_TYPE.py +0 -1
  27. singlestoredb/mysql/constants/FLAG.py +0 -1
  28. singlestoredb/mysql/constants/SERVER_STATUS.py +0 -1
  29. singlestoredb/mysql/converters.py +49 -28
  30. singlestoredb/mysql/err.py +3 -3
  31. singlestoredb/mysql/optionfile.py +4 -4
  32. singlestoredb/mysql/protocol.py +2 -1
  33. singlestoredb/mysql/times.py +3 -4
  34. singlestoredb/tests/test2.sql +1 -0
  35. singlestoredb/tests/test_management.py +393 -3
  36. singlestoredb/tests/test_udf.py +698 -0
  37. {singlestoredb-0.8.8.dist-info → singlestoredb-0.9.0.dist-info}/METADATA +1 -1
  38. {singlestoredb-0.8.8.dist-info → singlestoredb-0.9.0.dist-info}/RECORD +41 -29
  39. {singlestoredb-0.8.8.dist-info → singlestoredb-0.9.0.dist-info}/LICENSE +0 -0
  40. {singlestoredb-0.8.8.dist-info → singlestoredb-0.9.0.dist-info}/WHEEL +0 -0
  41. {singlestoredb-0.8.8.dist-info → singlestoredb-0.9.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,607 @@
1
+ #!/usr/bin/env python3
2
+ import datetime
3
+ import inspect
4
+ import numbers
5
+ import os
6
+ import re
7
+ import string
8
+ import textwrap
9
+ import typing
10
+ from typing import Any
11
+ from typing import Callable
12
+ from typing import Dict
13
+ from typing import List
14
+ from typing import Optional
15
+ from typing import Sequence
16
+ from typing import Tuple
17
+ from typing import TypeVar
18
+ from typing import Union
19
+ from urllib.parse import urljoin
20
+
21
+ try:
22
+ import numpy as np
23
+ has_numpy = True
24
+ except ImportError:
25
+ has_numpy = False
26
+
27
+ from . import dtypes as dt
28
+ from ..mysql.converters import escape_item # type: ignore
29
+
30
+
31
+ array_types: Tuple[Any, ...]
32
+
33
+ if has_numpy:
34
+ array_types = (Sequence, np.ndarray)
35
+ numpy_type_map = {
36
+ np.integer: 'int64',
37
+ np.int_: 'int64',
38
+ np.int64: 'int64',
39
+ np.int32: 'int32',
40
+ np.int16: 'int16',
41
+ np.int8: 'int8',
42
+ np.uint: 'uint64',
43
+ np.unsignedinteger: 'uint64',
44
+ np.uint64: 'uint64',
45
+ np.uint32: 'uint32',
46
+ np.uint16: 'uint16',
47
+ np.uint8: 'uint8',
48
+ np.longlong: 'uint64',
49
+ np.ulonglong: 'uint64',
50
+ np.unicode_: 'str',
51
+ np.str_: 'str',
52
+ np.bytes_: 'bytes',
53
+ np.float_: 'float64',
54
+ np.float64: 'float64',
55
+ np.float32: 'float32',
56
+ np.float16: 'float16',
57
+ np.double: 'float64',
58
+ }
59
+ else:
60
+ array_types = (Sequence,)
61
+ numpy_type_map = {}
62
+
63
+ float_type_map = {
64
+ 'float': 'float64',
65
+ 'float_': 'float64',
66
+ 'float64': 'float64',
67
+ 'f8': 'float64',
68
+ 'double': 'float64',
69
+ 'float32': 'float32',
70
+ 'f4': 'float32',
71
+ 'float16': 'float16',
72
+ 'f2': 'float16',
73
+ 'float8': 'float8',
74
+ 'f1': 'float8',
75
+ }
76
+
77
+ int_type_map = {
78
+ 'int': 'int64',
79
+ 'integer': 'int64',
80
+ 'int_': 'int64',
81
+ 'int64': 'int64',
82
+ 'i8': 'int64',
83
+ 'int32': 'int32',
84
+ 'i4': 'int32',
85
+ 'int16': 'int16',
86
+ 'i2': 'int16',
87
+ 'int8': 'int8',
88
+ 'i1': 'int8',
89
+ 'uint': 'uint64',
90
+ 'uinteger': 'uint64',
91
+ 'uint_': 'uint64',
92
+ 'uint64': 'uint64',
93
+ 'u8': 'uint64',
94
+ 'uint32': 'uint32',
95
+ 'u4': 'uint32',
96
+ 'uint16': 'uint16',
97
+ 'u2': 'uint16',
98
+ 'uint8': 'uint8',
99
+ 'u1': 'uint8',
100
+ }
101
+
102
+ sql_type_map = {
103
+ 'bool': 'BOOL',
104
+ 'int8': 'TINYINT',
105
+ 'int16': 'SMALLINT',
106
+ 'int32': 'INT',
107
+ 'int64': 'BIGINT',
108
+ 'uint8': 'TINYINT UNSIGNED',
109
+ 'uint16': 'SMALLINT UNSIGNED',
110
+ 'uint32': 'INT UNSIGNED',
111
+ 'uint64': 'BIGINT UNSIGNED',
112
+ 'float32': 'FLOAT',
113
+ 'float64': 'DOUBLE',
114
+ 'str': 'TEXT',
115
+ 'bytes': 'BLOB',
116
+ 'null': 'NULL',
117
+ 'datetime': 'DATETIME',
118
+ 'datetime6': 'DATETIME(6)',
119
+ 'date': 'DATE',
120
+ 'time': 'TIME',
121
+ 'time6': 'TIME(6)',
122
+ }
123
+
124
+
125
+ class Collection:
126
+ """Base class for collection data types."""
127
+
128
+ def __init__(self, *item_dtypes: Union[List[type], type]):
129
+ self.item_dtypes = item_dtypes
130
+
131
+
132
+ class TupleCollection(Collection):
133
+ pass
134
+
135
+
136
+ class ArrayCollection(Collection):
137
+ pass
138
+
139
+
140
+ def escape_name(name: str) -> str:
141
+ """Escape a function parameter name."""
142
+ if '`' in name:
143
+ name = name.replace('`', '``')
144
+ return f'`{name}`'
145
+
146
+
147
+ def simplify_dtype(dtype: Any) -> List[Any]:
148
+ """
149
+ Expand a type annotation to a flattened list of atomic types.
150
+
151
+ Parameters
152
+ ----------
153
+ dtype : Any
154
+ Python type annotation
155
+
156
+ Returns
157
+ -------
158
+ List[Any] -- list of dtype strings, TupleCollections, and ArrayCollections
159
+
160
+ """
161
+ origin = typing.get_origin(dtype)
162
+ atype = type(dtype)
163
+ args = []
164
+
165
+ # Flatten Unions
166
+ if origin is Union:
167
+ for x in typing.get_args(dtype):
168
+ args.extend(simplify_dtype(x))
169
+
170
+ # Expand custom types to individual types (does not support bounds)
171
+ elif atype is TypeVar:
172
+ for x in dtype.__constraints__:
173
+ args.extend(simplify_dtype(x))
174
+ if dtype.__bound__:
175
+ args.extend(simplify_dtype(dtype.__bound__))
176
+
177
+ # Sequence types
178
+ elif origin is not None and issubclass(origin, Sequence):
179
+ item_args: List[Union[List[type], type]] = []
180
+ for x in typing.get_args(dtype):
181
+ item_dtype = simplify_dtype(x)
182
+ if len(item_dtype) == 1:
183
+ item_args.append(item_dtype[0])
184
+ else:
185
+ item_args.append(item_dtype)
186
+ if origin is tuple or origin is Tuple:
187
+ args.append(TupleCollection(*item_args))
188
+ elif len(item_args) > 1:
189
+ raise TypeError('sequence types may only contain one item data type')
190
+ else:
191
+ args.append(ArrayCollection(*item_args))
192
+
193
+ # Not a Union or TypeVar
194
+ else:
195
+ args.append(dtype)
196
+
197
+ return args
198
+
199
+
200
+ def classify_dtype(dtype: Any) -> str:
201
+ """Classify the type annotation into a type name."""
202
+ if isinstance(dtype, list):
203
+ return '|'.join(classify_dtype(x) for x in dtype)
204
+
205
+ # Specific types
206
+ if dtype is None or dtype is type(None): # noqa: E721
207
+ return 'null'
208
+ if dtype is int:
209
+ return 'int64'
210
+ if dtype is float:
211
+ return 'float64'
212
+ if dtype is bool:
213
+ return 'bool'
214
+
215
+ if not inspect.isclass(dtype):
216
+ # Check for compound types
217
+ origin = typing.get_origin(dtype)
218
+ if origin is not None:
219
+ # Tuple type
220
+ if origin is Tuple:
221
+ args = typing.get_args(dtype)
222
+ item_dtypes = ','.join(classify_dtype(x) for x in args)
223
+ return f'tuple:{item_dtypes}'
224
+
225
+ # Array types
226
+ elif issubclass(origin, array_types):
227
+ args = typing.get_args(dtype)
228
+ item_dtype = classify_dtype(args[0])
229
+ return f'array[{item_dtype}]'
230
+
231
+ raise TypeError(f'unsupported type annotation: {dtype}')
232
+
233
+ if isinstance(dtype, ArrayCollection):
234
+ item_dtypes = ','.join(classify_dtype(x) for x in dtype.item_dtypes)
235
+ return f'array[{item_dtypes}]'
236
+
237
+ if isinstance(dtype, TupleCollection):
238
+ item_dtypes = ','.join(classify_dtype(x) for x in dtype.item_dtypes)
239
+ return f'tuple[{item_dtypes}]'
240
+
241
+ # Check numpy types if it's available
242
+ if dtype in numpy_type_map:
243
+ return numpy_type_map[dtype]
244
+
245
+ # Broad numeric types
246
+ if issubclass(dtype, int):
247
+ return 'int64'
248
+ if issubclass(dtype, float):
249
+ return 'float64'
250
+
251
+ # Strings / Bytes
252
+ if issubclass(dtype, str):
253
+ return 'str'
254
+ if issubclass(dtype, (bytes, bytearray)):
255
+ return 'bytes'
256
+
257
+ # Date / Times
258
+ if issubclass(dtype, datetime.datetime):
259
+ return 'datetime'
260
+ if issubclass(dtype, datetime.date):
261
+ return 'date'
262
+ if issubclass(dtype, datetime.timedelta):
263
+ return 'time'
264
+
265
+ # Last resort, guess it by the name...
266
+ name = dtype.__name__.lower()
267
+ is_float = issubclass(dtype, numbers.Real)
268
+ is_int = issubclass(dtype, numbers.Integral)
269
+ if is_float:
270
+ return float_type_map.get(name, 'float64')
271
+ if is_int:
272
+ return int_type_map.get(name, 'int64')
273
+
274
+ raise TypeError(f'unsupported type annotation: {dtype}')
275
+
276
+
277
+ def collapse_dtypes(dtypes: Union[str, List[str]]) -> str:
278
+ """
279
+ Collapse a dtype possibly containing multiple data types to one type.
280
+
281
+ Parameters
282
+ ----------
283
+ dtypes : str or list[str]
284
+ The data types to collapse
285
+
286
+ Returns
287
+ -------
288
+ str
289
+
290
+ """
291
+ if not isinstance(dtypes, list):
292
+ return dtypes
293
+
294
+ orig_dtypes = dtypes
295
+ dtypes = list(set(dtypes))
296
+
297
+ is_nullable = 'null' in dtypes
298
+
299
+ dtypes = [x for x in dtypes if x != 'null']
300
+
301
+ if 'uint64' in dtypes:
302
+ dtypes = [x for x in dtypes if x not in ('uint8', 'uint16', 'uint32')]
303
+ if 'uint32' in dtypes:
304
+ dtypes = [x for x in dtypes if x not in ('uint8', 'uint16')]
305
+ if 'uint16' in dtypes:
306
+ dtypes = [x for x in dtypes if x not in ('uint8',)]
307
+
308
+ if 'int64' in dtypes:
309
+ dtypes = [
310
+ x for x in dtypes if x not in (
311
+ 'int8', 'int16', 'int32',
312
+ 'uint8', 'uint16', 'uint32',
313
+ )
314
+ ]
315
+ if 'int32' in dtypes:
316
+ dtypes = [
317
+ x for x in dtypes if x not in (
318
+ 'int8', 'int16',
319
+ 'uint8', 'uint16',
320
+ )
321
+ ]
322
+ if 'int16' in dtypes:
323
+ dtypes = [x for x in dtypes if x not in ('int8', 'uint8')]
324
+
325
+ if 'float64' in dtypes:
326
+ dtypes = [
327
+ x for x in dtypes if x not in (
328
+ 'float32',
329
+ 'int8', 'int16', 'int32', 'int64',
330
+ 'uint8', 'uint16', 'uint32', 'uint64',
331
+ )
332
+ ]
333
+ if 'float32' in dtypes:
334
+ dtypes = [
335
+ x for x in dtypes if x not in (
336
+ 'int8', 'int16', 'int32',
337
+ 'uint8', 'uint16', 'uint32',
338
+ )
339
+ ]
340
+
341
+ for i, item in enumerate(dtypes):
342
+
343
+ if item.startswith('array[') and '|' in item:
344
+ _, item_spec = item.split('[', 1)
345
+ item_spec = item_spec[:-1]
346
+ item = collapse_dtypes(item_spec.split('|'))
347
+ dtypes[i] = f'array[{item}]'
348
+
349
+ elif item.startswith('tuple[') and '|' in item:
350
+ _, item_spec = item.split('[', 1)
351
+ item_spec = item_spec[:-1]
352
+ sub_dtypes = []
353
+ for subitem_spec in item_spec.split(','):
354
+ item = collapse_dtypes(subitem_spec.split('|'))
355
+ sub_dtypes.append(item)
356
+ dtypes[i] = f'tuple[{",".join(sub_dtypes)}]'
357
+
358
+ if len(dtypes) > 1:
359
+ raise TypeError(
360
+ 'types can not be collapsed to a single type: '
361
+ f'{", ".join(orig_dtypes)}',
362
+ )
363
+
364
+ if not dtypes:
365
+ return 'null'
366
+
367
+ return dtypes[0] + ('?' if is_nullable else '')
368
+
369
+
370
+ def get_signature(func: Callable[..., Any], name: Optional[str] = None) -> Dict[str, Any]:
371
+ '''
372
+ Print the UDF signature of the Python callable.
373
+
374
+ Parameters
375
+ ----------
376
+ func : Callable
377
+ The function to extract the signature of
378
+ name : str, optional
379
+ Name override for function
380
+
381
+ Returns
382
+ -------
383
+ Dict[str, Any]
384
+
385
+ '''
386
+ signature = inspect.signature(func)
387
+ args: List[Dict[str, Any]] = []
388
+ attrs = getattr(func, '_singlestoredb_attrs', {})
389
+ name = attrs.get('name', name if name else func.__name__)
390
+ out: Dict[str, Any] = dict(name=name, args=args)
391
+
392
+ arg_names = [x for x in signature.parameters]
393
+ defaults = [
394
+ x.default if x.default is not inspect.Parameter.empty else None
395
+ for x in signature.parameters.values()
396
+ ]
397
+ annotations = {
398
+ k: x.annotation for k, x in signature.parameters.items()
399
+ if x.annotation is not inspect.Parameter.empty
400
+ }
401
+
402
+ for p in signature.parameters.values():
403
+ if p.kind == inspect.Parameter.VAR_POSITIONAL:
404
+ raise TypeError('variable positional arguments are not supported')
405
+ elif p.kind == inspect.Parameter.VAR_KEYWORD:
406
+ raise TypeError('variable keyword arguments are not supported')
407
+
408
+ args_overrides = attrs.get('args', None)
409
+ returns_overrides = attrs.get('returns', None)
410
+
411
+ spec_diff = set(arg_names).difference(set(annotations.keys()))
412
+
413
+ # Make sure all arguments are annotated
414
+ if spec_diff and args_overrides is None:
415
+ raise TypeError(
416
+ 'missing annotations for {} in {}'
417
+ .format(', '.join(spec_diff), name),
418
+ )
419
+ elif isinstance(args_overrides, dict):
420
+ for s in spec_diff:
421
+ if s not in args_overrides:
422
+ raise TypeError(
423
+ 'missing annotations for {} in {}'
424
+ .format(', '.join(spec_diff), name),
425
+ )
426
+ elif isinstance(args_overrides, list):
427
+ if len(arg_names) != len(args_overrides):
428
+ raise TypeError(
429
+ 'number of annotations does not match in {}: {}'
430
+ .format(name, ', '.join(spec_diff)),
431
+ )
432
+
433
+ for i, arg in enumerate(arg_names):
434
+ arg_type = '#sql'
435
+ if isinstance(args_overrides, list):
436
+ sql = args_overrides[i]
437
+ elif isinstance(args_overrides, dict) and arg in args_overrides:
438
+ sql = args_overrides[arg]
439
+ elif isinstance(args_overrides, str):
440
+ sql = args_overrides
441
+ elif args_overrides is not None \
442
+ and not isinstance(args_overrides, (list, dict, str)):
443
+ raise TypeError(f'unrecognized type for arguments: {args_overrides}')
444
+ else:
445
+ arg_type = collapse_dtypes([
446
+ classify_dtype(x) for x in simplify_dtype(annotations[arg])
447
+ ])
448
+ sql = None
449
+ args.append(dict(name=arg, dtype=arg_type, sql=sql, default=defaults[i]))
450
+
451
+ if returns_overrides is None \
452
+ and signature.return_annotation is inspect.Signature.empty:
453
+ raise TypeError(f'no return value annotation in function {name}')
454
+
455
+ out_type = '#sql'
456
+ if isinstance(returns_overrides, str):
457
+ sql = returns_overrides
458
+ elif returns_overrides is not None and not isinstance(returns_overrides, str):
459
+ raise TypeError(f'unrecognized type for return value: {returns_overrides}')
460
+ else:
461
+ sql = None
462
+ out_type = collapse_dtypes([
463
+ classify_dtype(x) for x in simplify_dtype(signature.return_annotation)
464
+ ])
465
+ out['returns'] = dict(dtype=out_type, sql=sql, default=None)
466
+
467
+ copied_keys = ['database', 'environment', 'packages', 'resources', 'replace']
468
+ for key in copied_keys:
469
+ if attrs.get(key):
470
+ out[key] = attrs[key]
471
+
472
+ out['endpoint'] = '/invoke'
473
+ out['doc'] = func.__doc__
474
+
475
+ return out
476
+
477
+
478
+ def dtype_to_sql(dtype: str, default: Any = None) -> str:
479
+ """
480
+ Convert a collapsed dtype string to a SQL type.
481
+
482
+ Parameters
483
+ ----------
484
+ dtype : str
485
+ Simplified data type string
486
+ default : Any, optional
487
+ Default value
488
+
489
+ Returns
490
+ -------
491
+ str
492
+
493
+ """
494
+ nullable = ' NOT NULL'
495
+ if dtype.endswith('?'):
496
+ nullable = ' NULL'
497
+ dtype = dtype[:-1]
498
+
499
+ if dtype == 'null':
500
+ nullable = ''
501
+
502
+ default_clause = ''
503
+ if default is not None:
504
+ if default is dt.NULL:
505
+ default = None
506
+ default_clause = f' DEFAULT {escape_item(default, "utf8")}'
507
+
508
+ if dtype.startswith('array['):
509
+ _, dtypes = dtype.split('[', 1)
510
+ dtypes = dtypes[:-1]
511
+ item_dtype = dtype_to_sql(dtypes)
512
+ return f'ARRAY({item_dtype}){nullable}{default_clause}'
513
+
514
+ if dtype.startswith('tuple['):
515
+ _, dtypes = dtype.split('[', 1)
516
+ dtypes = dtypes[:-1]
517
+ item_dtypes = []
518
+ for i, item in enumerate(dtypes.split(',')):
519
+ name = string.ascii_letters[i]
520
+ if '=' in item:
521
+ name, item = item.split('=', 1)
522
+ item_dtypes.append(name + ' ' + dtype_to_sql(item))
523
+ return f'RECORD({", ".join(item_dtypes)}){nullable}{default_clause}'
524
+
525
+ return f'{sql_type_map[dtype]}{nullable}{default_clause}'
526
+
527
+
528
+ def signature_to_sql(
529
+ signature: Dict[str, Any],
530
+ base_url: Optional[str] = None,
531
+ data_format: str = 'rowdat_1',
532
+ ) -> str:
533
+ '''
534
+ Convert a dictionary function signature into SQL.
535
+
536
+ Parameters
537
+ ----------
538
+ signature : Dict[str, Any]
539
+ Function signature in the form of a dictionary as returned by
540
+ the `get_signature` function
541
+
542
+ Returns
543
+ -------
544
+ str : SQL formatted function signature
545
+
546
+ '''
547
+ args = []
548
+ for arg in signature['args']:
549
+ if arg.get('sql'):
550
+ # Use default value from Python function if SQL doesn't set one
551
+ default = ''
552
+ if not re.search(r'\s+default\s+\S', arg['sql'], flags=re.I):
553
+ default = ''
554
+ if arg.get('default', None) is not None:
555
+ default = f' DEFAULT {escape_item(arg["default"], "utf8")}'
556
+ args.append(escape_name(arg['name']) + ' ' + arg['sql'] + default)
557
+ else:
558
+ args.append(
559
+ escape_name(arg['name']) + ' ' +
560
+ dtype_to_sql(arg['dtype'], arg.get('default', None)),
561
+ )
562
+
563
+ returns = ''
564
+ if signature.get('returns'):
565
+ if signature['returns'].get('sql'):
566
+ res = signature['returns']['sql']
567
+ else:
568
+ res = dtype_to_sql(signature['returns']['dtype'])
569
+ returns = f' RETURNS {res}'
570
+
571
+ host = os.environ.get('SINGLESTOREDB_EXT_HOST', '127.0.0.1')
572
+ port = os.environ.get('SINGLESTOREDB_EXT_PORT', '8000')
573
+
574
+ url = urljoin(base_url or f'https://{host}:{port}', signature['endpoint'])
575
+
576
+ database = ''
577
+ if signature.get('database'):
578
+ database = escape_name(signature['database']) + '.'
579
+
580
+ replace = 'OR REPLACE ' if signature.get('replace') else ''
581
+
582
+ return (
583
+ f'CREATE {replace}EXTERNAL FUNCTION {database}{escape_name(signature["name"])}' +
584
+ '(' + ', '.join(args) + ')' + returns +
585
+ f' AS REMOTE SERVICE "{url}" FORMAT {data_format.upper()};'
586
+ )
587
+
588
+
589
+ def func_to_env(func: Callable[..., Any]) -> str:
590
+ # TODO: multiple functions
591
+ signature = get_signature(func)
592
+ env_name = signature['environment']
593
+ replace = 'OR REPLACE ' if signature.get('replace') else ''
594
+ packages = ', '.join(escape_item(x, 'utf8') for x in signature.get('packages', []))
595
+ resources = ', '.join(escape_item(x, 'utf8') for x in signature.get('resources', []))
596
+ code = inspect.getsource(func)
597
+
598
+ return (
599
+ f'CREATE {replace}ENVIRONMENT {env_name} LANGUAGE PYTHON ' +
600
+ 'USING EXPORTS ' + escape_name(func.__name__) + ' ' +
601
+ (f'\n PACKAGES ({packages}) ' if packages else '') +
602
+ (f'\n RESOURCES ({resources}) ' if resources else '') +
603
+ '\n AS CLOUD SERVICE' +
604
+ '\n BEGIN\n' +
605
+ textwrap.indent(code, ' ') +
606
+ ' END;'
607
+ )