singlestoredb 0.3.3__py3-none-any.whl → 1.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of singlestoredb might be problematic. Click here for more details.

Files changed (121) hide show
  1. singlestoredb/__init__.py +33 -2
  2. singlestoredb/alchemy/__init__.py +90 -0
  3. singlestoredb/auth.py +6 -4
  4. singlestoredb/config.py +116 -16
  5. singlestoredb/connection.py +489 -523
  6. singlestoredb/converters.py +275 -26
  7. singlestoredb/exceptions.py +30 -4
  8. singlestoredb/functions/__init__.py +1 -0
  9. singlestoredb/functions/decorator.py +142 -0
  10. singlestoredb/functions/dtypes.py +1639 -0
  11. singlestoredb/functions/ext/__init__.py +2 -0
  12. singlestoredb/functions/ext/arrow.py +375 -0
  13. singlestoredb/functions/ext/asgi.py +661 -0
  14. singlestoredb/functions/ext/json.py +427 -0
  15. singlestoredb/functions/ext/mmap.py +306 -0
  16. singlestoredb/functions/ext/rowdat_1.py +744 -0
  17. singlestoredb/functions/signature.py +673 -0
  18. singlestoredb/fusion/__init__.py +11 -0
  19. singlestoredb/fusion/graphql.py +213 -0
  20. singlestoredb/fusion/handler.py +621 -0
  21. singlestoredb/fusion/handlers/__init__.py +0 -0
  22. singlestoredb/fusion/handlers/stage.py +257 -0
  23. singlestoredb/fusion/handlers/utils.py +162 -0
  24. singlestoredb/fusion/handlers/workspace.py +412 -0
  25. singlestoredb/fusion/registry.py +164 -0
  26. singlestoredb/fusion/result.py +399 -0
  27. singlestoredb/http/__init__.py +27 -0
  28. singlestoredb/http/connection.py +1192 -0
  29. singlestoredb/management/__init__.py +3 -2
  30. singlestoredb/management/billing_usage.py +148 -0
  31. singlestoredb/management/cluster.py +19 -14
  32. singlestoredb/management/manager.py +100 -40
  33. singlestoredb/management/organization.py +188 -0
  34. singlestoredb/management/region.py +6 -8
  35. singlestoredb/management/utils.py +253 -4
  36. singlestoredb/management/workspace.py +1153 -35
  37. singlestoredb/mysql/__init__.py +177 -0
  38. singlestoredb/mysql/_auth.py +298 -0
  39. singlestoredb/mysql/charset.py +214 -0
  40. singlestoredb/mysql/connection.py +1814 -0
  41. singlestoredb/mysql/constants/CLIENT.py +38 -0
  42. singlestoredb/mysql/constants/COMMAND.py +32 -0
  43. singlestoredb/mysql/constants/CR.py +78 -0
  44. singlestoredb/mysql/constants/ER.py +474 -0
  45. singlestoredb/mysql/constants/FIELD_TYPE.py +32 -0
  46. singlestoredb/mysql/constants/FLAG.py +15 -0
  47. singlestoredb/mysql/constants/SERVER_STATUS.py +10 -0
  48. singlestoredb/mysql/constants/__init__.py +0 -0
  49. singlestoredb/mysql/converters.py +271 -0
  50. singlestoredb/mysql/cursors.py +713 -0
  51. singlestoredb/mysql/err.py +92 -0
  52. singlestoredb/mysql/optionfile.py +20 -0
  53. singlestoredb/mysql/protocol.py +388 -0
  54. singlestoredb/mysql/tests/__init__.py +19 -0
  55. singlestoredb/mysql/tests/base.py +126 -0
  56. singlestoredb/mysql/tests/conftest.py +37 -0
  57. singlestoredb/mysql/tests/test_DictCursor.py +132 -0
  58. singlestoredb/mysql/tests/test_SSCursor.py +141 -0
  59. singlestoredb/mysql/tests/test_basic.py +452 -0
  60. singlestoredb/mysql/tests/test_connection.py +851 -0
  61. singlestoredb/mysql/tests/test_converters.py +58 -0
  62. singlestoredb/mysql/tests/test_cursor.py +141 -0
  63. singlestoredb/mysql/tests/test_err.py +16 -0
  64. singlestoredb/mysql/tests/test_issues.py +514 -0
  65. singlestoredb/mysql/tests/test_load_local.py +75 -0
  66. singlestoredb/mysql/tests/test_nextset.py +88 -0
  67. singlestoredb/mysql/tests/test_optionfile.py +27 -0
  68. singlestoredb/mysql/tests/thirdparty/__init__.py +6 -0
  69. singlestoredb/mysql/tests/thirdparty/test_MySQLdb/__init__.py +9 -0
  70. singlestoredb/mysql/tests/thirdparty/test_MySQLdb/capabilities.py +323 -0
  71. singlestoredb/mysql/tests/thirdparty/test_MySQLdb/dbapi20.py +865 -0
  72. singlestoredb/mysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_capabilities.py +110 -0
  73. singlestoredb/mysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_dbapi20.py +224 -0
  74. singlestoredb/mysql/tests/thirdparty/test_MySQLdb/test_MySQLdb_nonstandard.py +101 -0
  75. singlestoredb/mysql/times.py +23 -0
  76. singlestoredb/pytest.py +283 -0
  77. singlestoredb/tests/empty.sql +0 -0
  78. singlestoredb/tests/ext_funcs/__init__.py +385 -0
  79. singlestoredb/tests/test.sql +210 -0
  80. singlestoredb/tests/test2.sql +1 -0
  81. singlestoredb/tests/test_basics.py +482 -117
  82. singlestoredb/tests/test_config.py +13 -15
  83. singlestoredb/tests/test_connection.py +241 -289
  84. singlestoredb/tests/test_dbapi.py +27 -0
  85. singlestoredb/tests/test_exceptions.py +0 -2
  86. singlestoredb/tests/test_ext_func.py +1193 -0
  87. singlestoredb/tests/test_ext_func_data.py +1101 -0
  88. singlestoredb/tests/test_fusion.py +465 -0
  89. singlestoredb/tests/test_http.py +32 -28
  90. singlestoredb/tests/test_management.py +588 -10
  91. singlestoredb/tests/test_plugin.py +33 -0
  92. singlestoredb/tests/test_results.py +11 -14
  93. singlestoredb/tests/test_types.py +0 -2
  94. singlestoredb/tests/test_udf.py +687 -0
  95. singlestoredb/tests/test_xdict.py +0 -2
  96. singlestoredb/tests/utils.py +3 -4
  97. singlestoredb/types.py +4 -5
  98. singlestoredb/utils/config.py +71 -12
  99. singlestoredb/utils/convert_rows.py +0 -2
  100. singlestoredb/utils/debug.py +13 -0
  101. singlestoredb/utils/mogrify.py +151 -0
  102. singlestoredb/utils/results.py +4 -3
  103. singlestoredb/utils/xdict.py +12 -12
  104. singlestoredb-1.0.3.dist-info/METADATA +139 -0
  105. singlestoredb-1.0.3.dist-info/RECORD +112 -0
  106. {singlestoredb-0.3.3.dist-info → singlestoredb-1.0.3.dist-info}/WHEEL +1 -1
  107. singlestoredb-1.0.3.dist-info/entry_points.txt +2 -0
  108. singlestoredb/drivers/__init__.py +0 -46
  109. singlestoredb/drivers/base.py +0 -200
  110. singlestoredb/drivers/cymysql.py +0 -40
  111. singlestoredb/drivers/http.py +0 -49
  112. singlestoredb/drivers/mariadb.py +0 -42
  113. singlestoredb/drivers/mysqlconnector.py +0 -51
  114. singlestoredb/drivers/mysqldb.py +0 -62
  115. singlestoredb/drivers/pymysql.py +0 -39
  116. singlestoredb/drivers/pyodbc.py +0 -67
  117. singlestoredb/http.py +0 -794
  118. singlestoredb-0.3.3.dist-info/METADATA +0 -105
  119. singlestoredb-0.3.3.dist-info/RECORD +0 -46
  120. {singlestoredb-0.3.3.dist-info → singlestoredb-1.0.3.dist-info}/LICENSE +0 -0
  121. {singlestoredb-0.3.3.dist-info → singlestoredb-1.0.3.dist-info}/top_level.txt +0 -0
@@ -3,21 +3,909 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import datetime
6
- from collections.abc import Sequence
6
+ import glob
7
+ import io
8
+ import os
9
+ import re
10
+ import socket
11
+ import time
7
12
  from typing import Any
13
+ from typing import BinaryIO
8
14
  from typing import Dict
9
15
  from typing import List
10
16
  from typing import Optional
17
+ from typing import TextIO
11
18
  from typing import Union
12
19
 
13
20
  from .. import connection
14
21
  from ..exceptions import ManagementError
22
+ from .billing_usage import BillingUsageItem
15
23
  from .manager import Manager
24
+ from .organization import Organization
16
25
  from .region import Region
26
+ from .utils import from_datetime
27
+ from .utils import NamedList
28
+ from .utils import PathLike
29
+ from .utils import snake_to_camel
17
30
  from .utils import to_datetime
31
+ from .utils import ttl_property
18
32
  from .utils import vars_to_str
19
33
 
20
34
 
35
+ def get_secret(name: str) -> str:
36
+ """Get a secret from the organization."""
37
+ return manage_workspaces().organization.get_secret(name).value
38
+
39
+
40
+ class StageObject(object):
41
+ """
42
+ Stage file / folder object.
43
+
44
+ This object is not instantiated directly. It is used in the results
45
+ of various operations in ``WorkspaceGroup.stage`` methods.
46
+
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ name: str,
52
+ path: str,
53
+ size: int,
54
+ type: str,
55
+ format: str,
56
+ mimetype: str,
57
+ created: Optional[datetime.datetime],
58
+ last_modified: Optional[datetime.datetime],
59
+ writable: bool,
60
+ content: Optional[List[str]] = None,
61
+ ):
62
+ #: Name of file / folder
63
+ self.name = name
64
+
65
+ if type == 'directory':
66
+ path = re.sub(r'/*$', r'', str(path)) + '/'
67
+
68
+ #: Path of file / folder
69
+ self.path = path
70
+
71
+ #: Size of the object (in bytes)
72
+ self.size = size
73
+
74
+ #: Data type: file or directory
75
+ self.type = type
76
+
77
+ #: Data format
78
+ self.format = format
79
+
80
+ #: Mime type
81
+ self.mimetype = mimetype
82
+
83
+ #: Datetime the object was created
84
+ self.created_at = created
85
+
86
+ #: Datetime the object was modified last
87
+ self.last_modified_at = last_modified
88
+
89
+ #: Is the object writable?
90
+ self.writable = writable
91
+
92
+ #: Contents of a directory
93
+ self.content: List[str] = content or []
94
+
95
+ self._stage: Optional[Stage] = None
96
+
97
+ @classmethod
98
+ def from_dict(
99
+ cls,
100
+ obj: Dict[str, Any],
101
+ stage: Stage,
102
+ ) -> StageObject:
103
+ """
104
+ Construct a StageObject from a dictionary of values.
105
+
106
+ Parameters
107
+ ----------
108
+ obj : dict
109
+ Dictionary of values
110
+ stage : Stage
111
+ Stage object to use as the parent
112
+
113
+ Returns
114
+ -------
115
+ :class:`StageObject`
116
+
117
+ """
118
+ out = cls(
119
+ name=obj['name'],
120
+ path=obj['path'],
121
+ size=obj['size'],
122
+ type=obj['type'],
123
+ format=obj['format'],
124
+ mimetype=obj['mimetype'],
125
+ created=to_datetime(obj.get('created')),
126
+ last_modified=to_datetime(obj.get('last_modified')),
127
+ writable=bool(obj['writable']),
128
+ )
129
+ out._stage = stage
130
+ return out
131
+
132
+ def __str__(self) -> str:
133
+ """Return string representation."""
134
+ return vars_to_str(self)
135
+
136
+ def __repr__(self) -> str:
137
+ """Return string representation."""
138
+ return str(self)
139
+
140
+ def open(
141
+ self,
142
+ mode: str = 'r',
143
+ encoding: Optional[str] = None,
144
+ ) -> Union[io.StringIO, io.BytesIO]:
145
+ """
146
+ Open a Stage path for reading or writing.
147
+
148
+ Parameters
149
+ ----------
150
+ mode : str, optional
151
+ The read / write mode. The following modes are supported:
152
+ * 'r' open for reading (default)
153
+ * 'w' open for writing, truncating the file first
154
+ * 'x' create a new file and open it for writing
155
+ The data type can be specified by adding one of the following:
156
+ * 'b' binary mode
157
+ * 't' text mode (default)
158
+ encoding : str, optional
159
+ The string encoding to use for text
160
+
161
+ Returns
162
+ -------
163
+ StageObjectBytesReader - 'rb' or 'b' mode
164
+ StageObjectBytesWriter - 'wb' or 'xb' mode
165
+ StageObjectTextReader - 'r' or 'rt' mode
166
+ StageObjectTextWriter - 'w', 'x', 'wt' or 'xt' mode
167
+
168
+ """
169
+ if self._stage is None:
170
+ raise ManagementError(
171
+ msg='No Stage object is associated with this object.',
172
+ )
173
+
174
+ if self.is_dir():
175
+ raise IsADirectoryError(
176
+ f'directories can not be read or written: {self.path}',
177
+ )
178
+
179
+ return self._stage.open(self.path, mode=mode, encoding=encoding)
180
+
181
+ def download(
182
+ self,
183
+ local_path: Optional[PathLike] = None,
184
+ *,
185
+ overwrite: bool = False,
186
+ encoding: Optional[str] = None,
187
+ ) -> Optional[Union[bytes, str]]:
188
+ """
189
+ Download the content of a stage path.
190
+
191
+ Parameters
192
+ ----------
193
+ local_path : Path or str
194
+ Path to local file target location
195
+ overwrite : bool, optional
196
+ Should an existing file be overwritten if it exists?
197
+ encoding : str, optional
198
+ Encoding used to convert the resulting data
199
+
200
+ Returns
201
+ -------
202
+ bytes or str or None
203
+
204
+ """
205
+ if self._stage is None:
206
+ raise ManagementError(
207
+ msg='No Stage object is associated with this object.',
208
+ )
209
+
210
+ return self._stage.download_file(
211
+ self.path, local_path=local_path,
212
+ overwrite=overwrite, encoding=encoding,
213
+ )
214
+
215
+ download_file = download
216
+
217
+ def remove(self) -> None:
218
+ """Delete the stage file."""
219
+ if self._stage is None:
220
+ raise ManagementError(
221
+ msg='No Stage object is associated with this object.',
222
+ )
223
+
224
+ if self.type == 'directory':
225
+ raise IsADirectoryError(
226
+ f'path is a directory; use rmdir or removedirs {self.path}',
227
+ )
228
+
229
+ self._stage.remove(self.path)
230
+
231
+ def rmdir(self) -> None:
232
+ """Delete the empty stage directory."""
233
+ if self._stage is None:
234
+ raise ManagementError(
235
+ msg='No Stage object is associated with this object.',
236
+ )
237
+
238
+ if self.type != 'directory':
239
+ raise NotADirectoryError(
240
+ f'path is not a directory: {self.path}',
241
+ )
242
+
243
+ self._stage.rmdir(self.path)
244
+
245
+ def removedirs(self) -> None:
246
+ """Delete the stage directory recursively."""
247
+ if self._stage is None:
248
+ raise ManagementError(
249
+ msg='No Stage object is associated with this object.',
250
+ )
251
+
252
+ if self.type != 'directory':
253
+ raise NotADirectoryError(
254
+ f'path is not a directory: {self.path}',
255
+ )
256
+
257
+ self._stage.removedirs(self.path)
258
+
259
+ def rename(self, new_path: PathLike, *, overwrite: bool = False) -> None:
260
+ """
261
+ Move the stage file to a new location.
262
+
263
+ Parameters
264
+ ----------
265
+ new_path : Path or str
266
+ The new location of the file
267
+ overwrite : bool, optional
268
+ Should path be overwritten if it already exists?
269
+
270
+ """
271
+ if self._stage is None:
272
+ raise ManagementError(
273
+ msg='No Stage object is associated with this object.',
274
+ )
275
+ out = self._stage.rename(self.path, new_path, overwrite=overwrite)
276
+ self.name = out.name
277
+ self.path = out.path
278
+ return None
279
+
280
+ def exists(self) -> bool:
281
+ """Does the file / folder exist?"""
282
+ if self._stage is None:
283
+ raise ManagementError(
284
+ msg='No Stage object is associated with this object.',
285
+ )
286
+ return self._stage.exists(self.path)
287
+
288
+ def is_dir(self) -> bool:
289
+ """Is the stage object a directory?"""
290
+ return self.type == 'directory'
291
+
292
+ def is_file(self) -> bool:
293
+ """Is the stage object a file?"""
294
+ return self.type != 'directory'
295
+
296
+ def abspath(self) -> str:
297
+ """Return the full path of the object."""
298
+ return str(self.path)
299
+
300
+ def basename(self) -> str:
301
+ """Return the basename of the object."""
302
+ return self.name
303
+
304
+ def dirname(self) -> str:
305
+ """Return the directory name of the object."""
306
+ return re.sub(r'/*$', r'', os.path.dirname(re.sub(r'/*$', r'', self.path))) + '/'
307
+
308
+ def getmtime(self) -> float:
309
+ """Return the last modified datetime as a UNIX timestamp."""
310
+ if self.last_modified_at is None:
311
+ return 0.0
312
+ return self.last_modified_at.timestamp()
313
+
314
+ def getctime(self) -> float:
315
+ """Return the creation datetime as a UNIX timestamp."""
316
+ if self.created_at is None:
317
+ return 0.0
318
+ return self.created_at.timestamp()
319
+
320
+
321
+ class StageObjectTextWriter(io.StringIO):
322
+ """StringIO wrapper for writing to Stage."""
323
+
324
+ def __init__(self, buffer: Optional[str], stage: Stage, stage_path: PathLike):
325
+ self._stage = stage
326
+ self._stage_path = stage_path
327
+ super().__init__(buffer)
328
+
329
+ def close(self) -> None:
330
+ """Write the content to the stage path."""
331
+ self._stage._upload(self.getvalue(), self._stage_path)
332
+ super().close()
333
+
334
+
335
+ class StageObjectTextReader(io.StringIO):
336
+ """StringIO wrapper for reading from Stage."""
337
+
338
+
339
+ class StageObjectBytesWriter(io.BytesIO):
340
+ """BytesIO wrapper for writing to Stage."""
341
+
342
+ def __init__(self, buffer: bytes, stage: Stage, stage_path: PathLike):
343
+ self._stage = stage
344
+ self._stage_path = stage_path
345
+ super().__init__(buffer)
346
+
347
+ def close(self) -> None:
348
+ """Write the content to the stage path."""
349
+ self._stage._upload(self.getvalue(), self._stage_path)
350
+ super().close()
351
+
352
+
353
+ class StageObjectBytesReader(io.BytesIO):
354
+ """BytesIO wrapper for reading from Stage."""
355
+
356
+
357
+ class Stage(object):
358
+ """
359
+ Stage manager.
360
+
361
+ This object is not instantiated directly.
362
+ It is returned by ``WorkspaceGroup.stage``.
363
+
364
+ """
365
+
366
+ def __init__(self, workspace_group: WorkspaceGroup, manager: WorkspaceManager):
367
+ self._workspace_group = workspace_group
368
+ self._manager = manager
369
+
370
+ def open(
371
+ self,
372
+ stage_path: PathLike,
373
+ mode: str = 'r',
374
+ encoding: Optional[str] = None,
375
+ ) -> Union[io.StringIO, io.BytesIO]:
376
+ """
377
+ Open a Stage path for reading or writing.
378
+
379
+ Parameters
380
+ ----------
381
+ stage_path : Path or str
382
+ The stage path to read / write
383
+ mode : str, optional
384
+ The read / write mode. The following modes are supported:
385
+ * 'r' open for reading (default)
386
+ * 'w' open for writing, truncating the file first
387
+ * 'x' create a new file and open it for writing
388
+ The data type can be specified by adding one of the following:
389
+ * 'b' binary mode
390
+ * 't' text mode (default)
391
+ encoding : str, optional
392
+ The string encoding to use for text
393
+
394
+ Returns
395
+ -------
396
+ StageObjectBytesReader - 'rb' or 'b' mode
397
+ StageObjectBytesWriter - 'wb' or 'xb' mode
398
+ StageObjectTextReader - 'r' or 'rt' mode
399
+ StageObjectTextWriter - 'w', 'x', 'wt' or 'xt' mode
400
+
401
+ """
402
+ if '+' in mode or 'a' in mode:
403
+ raise ValueError('modifying an existing stage file is not supported')
404
+
405
+ if 'w' in mode or 'x' in mode:
406
+ exists = self.exists(stage_path)
407
+ if exists:
408
+ if 'x' in mode:
409
+ raise FileExistsError(f'stage path already exists: {stage_path}')
410
+ self.remove(stage_path)
411
+ if 'b' in mode:
412
+ return StageObjectBytesWriter(b'', self, stage_path)
413
+ return StageObjectTextWriter('', self, stage_path)
414
+
415
+ if 'r' in mode:
416
+ content = self.download_file(stage_path)
417
+ if isinstance(content, bytes):
418
+ if 'b' in mode:
419
+ return StageObjectBytesReader(content)
420
+ encoding = 'utf-8' if encoding is None else encoding
421
+ return StageObjectTextReader(content.decode(encoding))
422
+
423
+ if isinstance(content, str):
424
+ return StageObjectTextReader(content)
425
+
426
+ raise ValueError(f'unrecognized file content type: {type(content)}')
427
+
428
+ raise ValueError(f'must have one of create/read/write mode specified: {mode}')
429
+
430
+ def upload_file(
431
+ self,
432
+ local_path: Union[PathLike, TextIO, BinaryIO],
433
+ stage_path: PathLike,
434
+ *,
435
+ overwrite: bool = False,
436
+ ) -> StageObject:
437
+ """
438
+ Upload a local file.
439
+
440
+ Parameters
441
+ ----------
442
+ local_path : Path or str or file-like
443
+ Path to the local file or an open file object
444
+ stage_path : Path or str
445
+ Path to the stage file
446
+ overwrite : bool, optional
447
+ Should the ``stage_path`` be overwritten if it exists already?
448
+
449
+ """
450
+ if isinstance(local_path, (TextIO, BinaryIO)):
451
+ pass
452
+ elif not os.path.isfile(local_path):
453
+ raise IsADirectoryError(f'local path is not a file: {local_path}')
454
+
455
+ if self.exists(stage_path):
456
+ if not overwrite:
457
+ raise OSError(f'stage path already exists: {stage_path}')
458
+
459
+ self.remove(stage_path)
460
+
461
+ if isinstance(local_path, (TextIO, BinaryIO)):
462
+ return self._upload(local_path, stage_path, overwrite=overwrite)
463
+ return self._upload(open(local_path, 'rb'), stage_path, overwrite=overwrite)
464
+
465
+ def upload_folder(
466
+ self,
467
+ local_path: PathLike,
468
+ stage_path: PathLike,
469
+ *,
470
+ overwrite: bool = False,
471
+ recursive: bool = True,
472
+ include_root: bool = False,
473
+ ignore: Optional[Union[PathLike, List[PathLike]]] = None,
474
+ ) -> StageObject:
475
+ """
476
+ Upload a folder recursively.
477
+
478
+ Only the contents of the folder are uploaded. To include the
479
+ folder name itself in the target path use ``include_root=True``.
480
+
481
+ Parameters
482
+ ----------
483
+ local_path : Path or str
484
+ Local directory to upload
485
+ stage_path : Path or str
486
+ Path of stage folder to upload to
487
+ overwrite : bool, optional
488
+ If a file already exists, should it be overwritten?
489
+ recursive : bool, optional
490
+ Should nested folders be uploaded?
491
+ include_root : bool, optional
492
+ Should the local root folder itself be uploaded as the top folder?
493
+ ignore : Path or str or List[Path] or List[str], optional
494
+ Glob patterns of files to ignore, for example, '**/*.pyc` will
495
+ ignore all '*.pyc' files in the directory tree
496
+
497
+ """
498
+ if not os.path.isdir(local_path):
499
+ raise NotADirectoryError(f'local path is not a directory: {local_path}')
500
+ if self.exists(stage_path) and not self.is_dir(stage_path):
501
+ raise NotADirectoryError(f'stage path is not a directory: {stage_path}')
502
+
503
+ ignore_files = set()
504
+ if ignore:
505
+ if isinstance(ignore, list):
506
+ for item in ignore:
507
+ ignore_files.update(glob.glob(str(item), recursive=recursive))
508
+ else:
509
+ ignore_files.update(glob.glob(str(ignore), recursive=recursive))
510
+
511
+ parent_dir = os.path.basename(os.getcwd())
512
+
513
+ files = glob.glob(os.path.join(local_path, '**'), recursive=recursive)
514
+
515
+ for src in files:
516
+ if ignore_files and src in ignore_files:
517
+ continue
518
+ target = os.path.join(parent_dir, src) if include_root else src
519
+ self.upload_file(src, target, overwrite=overwrite)
520
+
521
+ return self.info(stage_path)
522
+
523
+ def _upload(
524
+ self,
525
+ content: Union[str, bytes, TextIO, BinaryIO],
526
+ stage_path: PathLike,
527
+ *,
528
+ overwrite: bool = False,
529
+ ) -> StageObject:
530
+ """
531
+ Upload content to a stage file.
532
+
533
+ Parameters
534
+ ----------
535
+ content : str or bytes or file-like
536
+ Content to upload to stage
537
+ stage_path : Path or str
538
+ Path to the stage file
539
+ overwrite : bool, optional
540
+ Should the ``stage_path`` be overwritten if it exists already?
541
+
542
+ """
543
+ if self.exists(stage_path):
544
+ if not overwrite:
545
+ raise OSError(f'stage path already exists: {stage_path}')
546
+ self.remove(stage_path)
547
+
548
+ self._manager._put(
549
+ f'stage/{self._workspace_group.id}/fs/{stage_path}',
550
+ files={'file': content},
551
+ headers={'Content-Type': None},
552
+ )
553
+
554
+ return self.info(stage_path)
555
+
556
+ def mkdir(self, stage_path: PathLike, overwrite: bool = False) -> StageObject:
557
+ """
558
+ Make a directory in the stage.
559
+
560
+ Parameters
561
+ ----------
562
+ stage_path : Path or str
563
+ Path of the folder to create
564
+ overwrite : bool, optional
565
+ Should the stage path be overwritten if it exists already?
566
+
567
+ Returns
568
+ -------
569
+ StageObject
570
+
571
+ """
572
+ stage_path = re.sub(r'/*$', r'', str(stage_path)) + '/'
573
+
574
+ if self.exists(stage_path):
575
+ if not overwrite:
576
+ return self.info(stage_path)
577
+
578
+ self.remove(stage_path)
579
+
580
+ self._manager._put(
581
+ f'stage/{self._workspace_group.id}/fs/{stage_path}?isFile=false',
582
+ )
583
+
584
+ return self.info(stage_path)
585
+
586
+ mkdirs = mkdir
587
+
588
+ def rename(
589
+ self,
590
+ old_path: PathLike,
591
+ new_path: PathLike,
592
+ *,
593
+ overwrite: bool = False,
594
+ ) -> StageObject:
595
+ """
596
+ Move the stage file to a new location.
597
+
598
+ Paraemeters
599
+ -----------
600
+ old_path : Path or str
601
+ Original location of the path
602
+ new_path : Path or str
603
+ New location of the path
604
+ overwrite : bool, optional
605
+ Should the ``new_path`` be overwritten if it exists already?
606
+
607
+ """
608
+ if not self.exists(old_path):
609
+ raise OSError(f'stage path does not exist: {old_path}')
610
+
611
+ if self.exists(new_path):
612
+ if not overwrite:
613
+ raise OSError(f'stage path already exists: {new_path}')
614
+
615
+ if str(old_path).endswith('/') and not str(new_path).endswith('/'):
616
+ raise OSError('original and new paths are not the same type')
617
+
618
+ if str(new_path).endswith('/'):
619
+ self.removedirs(new_path)
620
+ else:
621
+ self.remove(new_path)
622
+
623
+ self._manager._patch(
624
+ f'stage/{self._workspace_group.id}/fs/{old_path}',
625
+ json=dict(newPath=new_path),
626
+ )
627
+
628
+ return self.info(new_path)
629
+
630
+ def info(self, stage_path: PathLike) -> StageObject:
631
+ """
632
+ Return information about a stage location.
633
+
634
+ Parameters
635
+ ----------
636
+ stage_path : Path or str
637
+ Path to the stage location
638
+
639
+ Returns
640
+ -------
641
+ StageObject
642
+
643
+ """
644
+ res = self._manager._get(
645
+ re.sub(r'/+$', r'/', f'stage/{self._workspace_group.id}/fs/{stage_path}'),
646
+ params=dict(metadata=1),
647
+ ).json()
648
+
649
+ return StageObject.from_dict(res, self)
650
+
651
+ def exists(self, stage_path: PathLike) -> bool:
652
+ """
653
+ Does the given stage path exist?
654
+
655
+ Parameters
656
+ ----------
657
+ stage_path : Path or str
658
+ Path to stage object
659
+
660
+ Returns
661
+ -------
662
+ bool
663
+
664
+ """
665
+ try:
666
+ self.info(stage_path)
667
+ return True
668
+ except ManagementError as exc:
669
+ if exc.errno == 404:
670
+ return False
671
+ raise
672
+
673
+ def is_dir(self, stage_path: PathLike) -> bool:
674
+ """
675
+ Is the given stage path a directory?
676
+
677
+ Parameters
678
+ ----------
679
+ stage_path : Path or str
680
+ Path to stage object
681
+
682
+ Returns
683
+ -------
684
+ bool
685
+
686
+ """
687
+ try:
688
+ return self.info(stage_path).type == 'directory'
689
+ except ManagementError as exc:
690
+ if exc.errno == 404:
691
+ return False
692
+ raise
693
+
694
+ def is_file(self, stage_path: PathLike) -> bool:
695
+ """
696
+ Is the given stage path a file?
697
+
698
+ Parameters
699
+ ----------
700
+ stage_path : Path or str
701
+ Path to stage object
702
+
703
+ Returns
704
+ -------
705
+ bool
706
+
707
+ """
708
+ try:
709
+ return self.info(stage_path).type != 'directory'
710
+ except ManagementError as exc:
711
+ if exc.errno == 404:
712
+ return False
713
+ raise
714
+
715
+ def _listdir(self, stage_path: PathLike, *, recursive: bool = False) -> List[str]:
716
+ """
717
+ Return the names of files in a directory.
718
+
719
+ Parameters
720
+ ----------
721
+ stage_path : Path or str
722
+ Path to the folder in Stage
723
+ recursive : bool, optional
724
+ Should folders be listed recursively?
725
+
726
+ """
727
+ res = self._manager._get(
728
+ f'stage/{self._workspace_group.id}/fs/{stage_path}',
729
+ ).json()
730
+ if recursive:
731
+ out = []
732
+ for item in res['content'] or []:
733
+ out.append(item['path'])
734
+ if item['type'] == 'directory':
735
+ out.extend(self._listdir(item['path'], recursive=recursive))
736
+ return out
737
+ return [x['path'] for x in res['content'] or []]
738
+
739
+ def listdir(
740
+ self,
741
+ stage_path: PathLike = '/',
742
+ *,
743
+ recursive: bool = False,
744
+ ) -> List[str]:
745
+ """
746
+ List the files / folders at the given path.
747
+
748
+ Parameters
749
+ ----------
750
+ stage_path : Path or str, optional
751
+ Path to the stage location
752
+
753
+ Returns
754
+ -------
755
+ List[str]
756
+
757
+ """
758
+ stage_path = re.sub(r'^(\./|/)+', r'', str(stage_path))
759
+ stage_path = re.sub(r'/+$', r'', stage_path) + '/'
760
+
761
+ if self.is_dir(stage_path):
762
+ out = self._listdir(stage_path, recursive=recursive)
763
+ if stage_path != '/':
764
+ stage_path_n = len(stage_path.split('/')) - 1
765
+ out = ['/'.join(x.split('/')[stage_path_n:]) for x in out]
766
+ return out
767
+
768
+ raise NotADirectoryError(f'stage path is not a directory: {stage_path}')
769
+
770
+ def download_file(
771
+ self,
772
+ stage_path: PathLike,
773
+ local_path: Optional[PathLike] = None,
774
+ *,
775
+ overwrite: bool = False,
776
+ encoding: Optional[str] = None,
777
+ ) -> Optional[Union[bytes, str]]:
778
+ """
779
+ Download the content of a stage path.
780
+
781
+ Parameters
782
+ ----------
783
+ stage_path : Path or str
784
+ Path to the stage file
785
+ local_path : Path or str
786
+ Path to local file target location
787
+ overwrite : bool, optional
788
+ Should an existing file be overwritten if it exists?
789
+ encoding : str, optional
790
+ Encoding used to convert the resulting data
791
+
792
+ Returns
793
+ -------
794
+ bytes or str - ``local_path`` is None
795
+ None - ``local_path`` is a Path or str
796
+
797
+ """
798
+ if local_path is not None and not overwrite and os.path.exists(local_path):
799
+ raise OSError('target file already exists; use overwrite=True to replace')
800
+ if self.is_dir(stage_path):
801
+ raise IsADirectoryError(f'stage path is a directory: {stage_path}')
802
+
803
+ out = self._manager._get(
804
+ f'stage/{self._workspace_group.id}/fs/{stage_path}',
805
+ ).content
806
+
807
+ if local_path is not None:
808
+ with open(local_path, 'wb') as outfile:
809
+ outfile.write(out)
810
+ return None
811
+
812
+ if encoding:
813
+ return out.decode(encoding)
814
+
815
+ return out
816
+
817
+ def download_folder(
818
+ self,
819
+ stage_path: PathLike,
820
+ local_path: PathLike = '.',
821
+ *,
822
+ overwrite: bool = False,
823
+ ) -> None:
824
+ """
825
+ Download a Stage folder to a local directory.
826
+
827
+ Parameters
828
+ ----------
829
+ stage_path : Path or str
830
+ Path to the stage file
831
+ local_path : Path or str
832
+ Path to local directory target location
833
+ overwrite : bool, optional
834
+ Should an existing directory / files be overwritten if they exist?
835
+
836
+ """
837
+ if local_path is not None and not overwrite and os.path.exists(local_path):
838
+ raise OSError(
839
+ 'target directory already exists; '
840
+ 'use overwrite=True to replace',
841
+ )
842
+ if not self.is_dir(stage_path):
843
+ raise NotADirectoryError(f'stage path is not a directory: {stage_path}')
844
+
845
+ for f in self.listdir(stage_path, recursive=True):
846
+ if self.is_dir(f):
847
+ continue
848
+ target = os.path.normpath(os.path.join(local_path, f))
849
+ os.makedirs(os.path.dirname(target), exist_ok=True)
850
+ self.download_file(f, target, overwrite=overwrite)
851
+
852
+ def remove(self, stage_path: PathLike) -> None:
853
+ """
854
+ Delete a stage location.
855
+
856
+ Parameters
857
+ ----------
858
+ stage_path : Path or str
859
+ Path to the stage location
860
+
861
+ """
862
+ if self.is_dir(stage_path):
863
+ raise IsADirectoryError(
864
+ 'stage path is a directory, '
865
+ f'use rmdir or removedirs: {stage_path}',
866
+ )
867
+
868
+ self._manager._delete(f'stage/{self._workspace_group.id}/fs/{stage_path}')
869
+
870
+ def removedirs(self, stage_path: PathLike) -> None:
871
+ """
872
+ Delete a stage folder recursively.
873
+
874
+ Parameters
875
+ ----------
876
+ stage_path : Path or str
877
+ Path to the stage location
878
+
879
+ """
880
+ stage_path = re.sub(r'/*$', r'', str(stage_path)) + '/'
881
+ self._manager._delete(f'stage/{self._workspace_group.id}/fs/{stage_path}')
882
+
883
+ def rmdir(self, stage_path: PathLike) -> None:
884
+ """
885
+ Delete a stage folder.
886
+
887
+ Parameters
888
+ ----------
889
+ stage_path : Path or str
890
+ Path to the stage location
891
+
892
+ """
893
+ stage_path = re.sub(r'/*$', r'', str(stage_path)) + '/'
894
+
895
+ if self.listdir(stage_path):
896
+ raise OSError(f'stage folder is not empty, use removedirs: {stage_path}')
897
+
898
+ self._manager._delete(f'stage/{self._workspace_group.id}/fs/{stage_path}')
899
+
900
+ def __str__(self) -> str:
901
+ """Return string representation."""
902
+ return vars_to_str(self)
903
+
904
+ def __repr__(self) -> str:
905
+ """Return string representation."""
906
+ return str(self)
907
+
908
+
21
909
  class Workspace(object):
22
910
  """
23
911
  SingleStoreDB workspace definition.
@@ -37,9 +925,12 @@ class Workspace(object):
37
925
  """
38
926
 
39
927
  def __init__(
40
- self, name: str, workspace_id: str,
41
- workspace_group: Union[str, WorkspaceGroup],
42
- size: str, state: str,
928
+ self,
929
+ name: str,
930
+ workspace_id: str,
931
+ workspace_group: Union[str, 'WorkspaceGroup'],
932
+ size: str,
933
+ state: str,
43
934
  created_at: Union[str, datetime.datetime],
44
935
  terminated_at: Optional[Union[str, datetime.datetime]] = None,
45
936
  endpoint: Optional[str] = None,
@@ -83,7 +974,7 @@ class Workspace(object):
83
974
  return str(self)
84
975
 
85
976
  @classmethod
86
- def from_dict(cls, obj: Dict[str, Any], manager: 'WorkspaceManager') -> Workspace:
977
+ def from_dict(cls, obj: Dict[str, Any], manager: 'WorkspaceManager') -> 'Workspace':
87
978
  """
88
979
  Construct a Workspace from a dictionary of values.
89
980
 
@@ -125,6 +1016,7 @@ class Workspace(object):
125
1016
  wait_on_terminated: bool = False,
126
1017
  wait_interval: int = 10,
127
1018
  wait_timeout: int = 600,
1019
+ force: bool = False,
128
1020
  ) -> None:
129
1021
  """
130
1022
  Terminate the workspace.
@@ -137,6 +1029,8 @@ class Workspace(object):
137
1029
  Number of seconds between each server check
138
1030
  wait_timeout : int, optional
139
1031
  Total number of seconds to check server before giving up
1032
+ force : bool, optional
1033
+ Should the workspace group be terminated even if it has workspaces?
140
1034
 
141
1035
  Raises
142
1036
  ------
@@ -148,7 +1042,8 @@ class Workspace(object):
148
1042
  raise ManagementError(
149
1043
  msg='No workspace manager is associated with this object.',
150
1044
  )
151
- self._manager._delete(f'workspaces/{self.id}')
1045
+ force_str = 'true' if force else 'false'
1046
+ self._manager._delete(f'workspaces/{self.id}?force={force_str}')
152
1047
  if wait_on_terminated:
153
1048
  self._manager._wait_on_state(
154
1049
  self._manager.get_workspace(self.id),
@@ -178,6 +1073,78 @@ class Workspace(object):
178
1073
  kwargs['host'] = self.endpoint
179
1074
  return connection.connect(**kwargs)
180
1075
 
1076
+ def suspend(
1077
+ self,
1078
+ wait_on_suspended: bool = False,
1079
+ wait_interval: int = 20,
1080
+ wait_timeout: int = 600,
1081
+ ) -> None:
1082
+ """
1083
+ Suspend the workspace.
1084
+
1085
+ Parameters
1086
+ ----------
1087
+ wait_on_suspended : bool, optional
1088
+ Wait for the workspace to go into 'Suspended' mode before returning
1089
+ wait_interval : int, optional
1090
+ Number of seconds between each server check
1091
+ wait_timeout : int, optional
1092
+ Total number of seconds to check server before giving up
1093
+
1094
+ Raises
1095
+ ------
1096
+ ManagementError
1097
+ If timeout is reached
1098
+
1099
+ """
1100
+ if self._manager is None:
1101
+ raise ManagementError(
1102
+ msg='No workspace manager is associated with this object.',
1103
+ )
1104
+ self._manager._post(f'workspaces/{self.id}/suspend')
1105
+ if wait_on_suspended:
1106
+ self._manager._wait_on_state(
1107
+ self._manager.get_workspace(self.id),
1108
+ 'Suspended', interval=wait_interval, timeout=wait_timeout,
1109
+ )
1110
+ self.refresh()
1111
+
1112
+ def resume(
1113
+ self,
1114
+ wait_on_resumed: bool = False,
1115
+ wait_interval: int = 20,
1116
+ wait_timeout: int = 600,
1117
+ ) -> None:
1118
+ """
1119
+ Resume the workspace.
1120
+
1121
+ Parameters
1122
+ ----------
1123
+ wait_on_resumed : bool, optional
1124
+ Wait for the workspace to go into 'Resumed' or 'Active' mode before returning
1125
+ wait_interval : int, optional
1126
+ Number of seconds between each server check
1127
+ wait_timeout : int, optional
1128
+ Total number of seconds to check server before giving up
1129
+
1130
+ Raises
1131
+ ------
1132
+ ManagementError
1133
+ If timeout is reached
1134
+
1135
+ """
1136
+ if self._manager is None:
1137
+ raise ManagementError(
1138
+ msg='No workspace manager is associated with this object.',
1139
+ )
1140
+ self._manager._post(f'workspaces/{self.id}/resume')
1141
+ if wait_on_resumed:
1142
+ self._manager._wait_on_state(
1143
+ self._manager.get_workspace(self.id),
1144
+ ['Resumed', 'Active'], interval=wait_interval, timeout=wait_timeout,
1145
+ )
1146
+ self.refresh()
1147
+
181
1148
 
182
1149
  class WorkspaceGroup(object):
183
1150
  """
@@ -200,8 +1167,8 @@ class WorkspaceGroup(object):
200
1167
  def __init__(
201
1168
  self, name: str, id: str,
202
1169
  created_at: Union[str, datetime.datetime],
203
- region: Region,
204
- firewall_ranges: Sequence[str],
1170
+ region: Optional[Region],
1171
+ firewall_ranges: List[str],
205
1172
  terminated_at: Optional[Union[str, datetime.datetime]],
206
1173
  ):
207
1174
  #: Name of the workspace group
@@ -213,7 +1180,7 @@ class WorkspaceGroup(object):
213
1180
  #: Timestamp of when the workspace group was created
214
1181
  self.created_at = to_datetime(created_at)
215
1182
 
216
- #: Region of the cluster (see :class:`Region`)
1183
+ #: Region of the workspace group (see :class:`Region`)
217
1184
  self.region = region
218
1185
 
219
1186
  #: List of allowed incoming IP addresses / ranges
@@ -235,7 +1202,7 @@ class WorkspaceGroup(object):
235
1202
  @classmethod
236
1203
  def from_dict(
237
1204
  cls, obj: Dict[str, Any], manager: 'WorkspaceManager',
238
- ) -> WorkspaceGroup:
1205
+ ) -> 'WorkspaceGroup':
239
1206
  """
240
1207
  Construct a WorkspaceGroup from a dictionary of values.
241
1208
 
@@ -251,18 +1218,42 @@ class WorkspaceGroup(object):
251
1218
  :class:`WorkspaceGroup`
252
1219
 
253
1220
  """
1221
+ try:
1222
+ region = [x for x in manager.regions if x.id == obj['regionID']][0]
1223
+ except IndexError:
1224
+ region = Region(obj.get('regionID', '<unknown>'), '<unknown>', '<unknown>')
254
1225
  out = cls(
255
- name=obj['name'], id=obj['workspaceGroupID'],
1226
+ name=obj['name'],
1227
+ id=obj['workspaceGroupID'],
256
1228
  created_at=obj['createdAt'],
257
- region=[x for x in manager.regions if x.id == obj['regionID']][0],
1229
+ region=region,
258
1230
  firewall_ranges=obj.get('firewallRanges', []),
259
1231
  terminated_at=obj.get('terminatedAt'),
260
1232
  )
261
1233
  out._manager = manager
262
1234
  return out
263
1235
 
264
- def refresh(self) -> WorkspaceGroup:
265
- """Update teh object to the current state."""
1236
+ @property
1237
+ def organization(self) -> Organization:
1238
+ if self._manager is None:
1239
+ raise ManagementError(
1240
+ msg='No workspace manager is associated with this object.',
1241
+ )
1242
+ return self._manager.organization
1243
+
1244
+ @property
1245
+ def stage(self) -> Stage:
1246
+ """Stage manager."""
1247
+ if self._manager is None:
1248
+ raise ManagementError(
1249
+ msg='No workspace manager is associated with this object.',
1250
+ )
1251
+ return Stage(self, self._manager)
1252
+
1253
+ stages = stage
1254
+
1255
+ def refresh(self) -> 'WorkspaceGroup':
1256
+ """Update the object to the current state."""
266
1257
  if self._manager is None:
267
1258
  raise ManagementError(
268
1259
  msg='No workspace manager is associated with this object.',
@@ -275,17 +1266,17 @@ class WorkspaceGroup(object):
275
1266
  def update(
276
1267
  self, name: Optional[str] = None,
277
1268
  admin_password: Optional[str] = None,
278
- firewall_ranges: Optional[Sequence[str]] = None,
1269
+ firewall_ranges: Optional[List[str]] = None,
279
1270
  ) -> None:
280
1271
  """
281
- Update the cluster definition.
1272
+ Update the workspace group definition.
282
1273
 
283
1274
  Parameters
284
1275
  ----------
285
1276
  name : str, optional
286
- Cluster name
1277
+ Workspace group name
287
1278
  admim_password : str, optional
288
- Admin password for the cluster
1279
+ Admin password for the workspace group
289
1280
  firewall_ranges : Sequence[str], optional
290
1281
  List of allowed incoming IP addresses
291
1282
 
@@ -317,7 +1308,7 @@ class WorkspaceGroup(object):
317
1308
  force : bool, optional
318
1309
  Terminate a workspace group even if it has active workspaces
319
1310
  wait_on_terminated : bool, optional
320
- Wait for the cluster to go into 'Terminated' mode before returning
1311
+ Wait for the workspace group to go into 'Terminated' mode before returning
321
1312
  wait_interval : int, optional
322
1313
  Number of seconds between each server check
323
1314
  wait_timeout : int, optional
@@ -335,16 +1326,21 @@ class WorkspaceGroup(object):
335
1326
  )
336
1327
  self._manager._delete(f'workspaceGroups/{self.id}', params=dict(force=force))
337
1328
  if wait_on_terminated:
338
- self._manager._wait_on_state(
339
- self._manager.get_workspace_group(self.id),
340
- 'Terminated', interval=wait_interval, timeout=wait_timeout,
341
- )
342
- self.refresh()
1329
+ while True:
1330
+ self.refresh()
1331
+ if self.terminated_at is not None:
1332
+ break
1333
+ if wait_timeout <= 0:
1334
+ raise ManagementError(
1335
+ msg='Exceeded waiting time for WorkspaceGroup to terminate',
1336
+ )
1337
+ time.sleep(wait_interval)
1338
+ wait_timeout -= wait_interval
343
1339
 
344
1340
  def create_workspace(
345
1341
  self, name: str, size: Optional[str] = None,
346
1342
  wait_on_active: bool = False, wait_interval: int = 10,
347
- wait_timeout: int = 600,
1343
+ wait_timeout: int = 600, add_endpoint_to_firewall_ranges: bool = True,
348
1344
  ) -> Workspace:
349
1345
  """
350
1346
  Create a new workspace.
@@ -362,6 +1358,9 @@ class WorkspaceGroup(object):
362
1358
  if wait=True
363
1359
  wait_interval : int, optional
364
1360
  Number of seconds between each polling interval
1361
+ add_endpoint_to_firewall_ranges : bool, optional
1362
+ Should the workspace endpoint be added to the workspace group
1363
+ firewall ranges?
365
1364
 
366
1365
  Returns
367
1366
  -------
@@ -372,20 +1371,100 @@ class WorkspaceGroup(object):
372
1371
  raise ManagementError(
373
1372
  msg='No workspace manager is associated with this object.',
374
1373
  )
375
- return self._manager.create_workspace(
1374
+
1375
+ out = self._manager.create_workspace(
376
1376
  name=name, workspace_group=self, size=size, wait_on_active=wait_on_active,
377
1377
  wait_interval=wait_interval, wait_timeout=wait_timeout,
378
1378
  )
379
1379
 
1380
+ if add_endpoint_to_firewall_ranges and out.endpoint is not None:
1381
+ ip_address = '{}/32'.format(socket.gethostbyname(out.endpoint))
1382
+ self.update(firewall_ranges=self.firewall_ranges+[ip_address])
1383
+
1384
+ return out
1385
+
380
1386
  @property
381
- def workspaces(self) -> List[Workspace]:
1387
+ def workspaces(self) -> NamedList[Workspace]:
382
1388
  """Return a list of available workspaces."""
383
1389
  if self._manager is None:
384
1390
  raise ManagementError(
385
1391
  msg='No workspace manager is associated with this object.',
386
1392
  )
387
1393
  res = self._manager._get('workspaces', params=dict(workspaceGroupID=self.id))
388
- return [Workspace.from_dict(item, self._manager) for item in res.json()]
1394
+ return NamedList(
1395
+ [Workspace.from_dict(item, self._manager) for item in res.json()],
1396
+ )
1397
+
1398
+
1399
+ class Billing(object):
1400
+ """Billing information."""
1401
+
1402
+ COMPUTE_CREDIT = 'compute_credit'
1403
+ STORAGE_AVG_BYTE = 'storage_avg_byte'
1404
+
1405
+ HOUR = 'hour'
1406
+ DAY = 'day'
1407
+ MONTH = 'month'
1408
+
1409
+ def __init__(self, manager: Manager):
1410
+ self._manager = manager
1411
+
1412
+ def usage(
1413
+ self,
1414
+ start_time: datetime.datetime,
1415
+ end_time: datetime.datetime,
1416
+ metric: Optional[str] = None,
1417
+ aggregate_by: Optional[str] = None,
1418
+ ) -> List[BillingUsageItem]:
1419
+ """
1420
+ Get usage information.
1421
+
1422
+ Parameters
1423
+ ----------
1424
+ start_time : datetime.datetime
1425
+ Start time for usage interval
1426
+ end_time : datetime.datetime
1427
+ End time for usage interval
1428
+ metric : str, optional
1429
+ Possible metrics are ``mgr.billing.COMPUTE_CREDIT`` and
1430
+ ``mgr.billing.STORAGE_AVG_BYTE`` (default is all)
1431
+ aggregate_by : str, optional
1432
+ Aggregate type used to group usage: ``mgr.billing.HOUR``,
1433
+ ``mgr.billing.DAY``, or ``mgr.billing.MONTH``
1434
+
1435
+ Returns
1436
+ -------
1437
+ List[BillingUsage]
1438
+
1439
+ """
1440
+ res = self._manager._get(
1441
+ 'billing/usage',
1442
+ params={
1443
+ k: v for k, v in dict(
1444
+ metric=snake_to_camel(metric),
1445
+ startTime=from_datetime(start_time),
1446
+ endTime=from_datetime(end_time),
1447
+ aggregate_by=aggregate_by.lower() if aggregate_by else None,
1448
+ ).items() if v is not None
1449
+ },
1450
+ )
1451
+ return [
1452
+ BillingUsageItem.from_dict(x, self._manager)
1453
+ for x in res.json()['billingUsage']
1454
+ ]
1455
+
1456
+
1457
+ class Organizations(object):
1458
+ """Organizations."""
1459
+
1460
+ def __init__(self, manager: Manager):
1461
+ self._manager = manager
1462
+
1463
+ @property
1464
+ def current(self) -> Organization:
1465
+ """Get current organization."""
1466
+ res = self._manager._get('organizations/current').json()
1467
+ return Organization.from_dict(res, self._manager)
389
1468
 
390
1469
 
391
1470
  class WorkspaceManager(Manager):
@@ -419,20 +1498,38 @@ class WorkspaceManager(Manager):
419
1498
  obj_type = 'workspace'
420
1499
 
421
1500
  @property
422
- def workspace_groups(self) -> List[WorkspaceGroup]:
1501
+ def workspace_groups(self) -> NamedList[WorkspaceGroup]:
423
1502
  """Return a list of available workspace groups."""
424
1503
  res = self._get('workspaceGroups')
425
- return [WorkspaceGroup.from_dict(item, self) for item in res.json()]
1504
+ return NamedList([WorkspaceGroup.from_dict(item, self) for item in res.json()])
426
1505
 
427
1506
  @property
428
- def regions(self) -> List[Region]:
1507
+ def organizations(self) -> Organizations:
1508
+ """Return the organizations."""
1509
+ return Organizations(self)
1510
+
1511
+ @property
1512
+ def organization(self) -> Organization:
1513
+ """ Return the current organization."""
1514
+ return self.organizations.current
1515
+
1516
+ @property
1517
+ def billing(self) -> Billing:
1518
+ """Return the current billing information."""
1519
+ return Billing(self)
1520
+
1521
+ @ttl_property(datetime.timedelta(hours=1))
1522
+ def regions(self) -> NamedList[Region]:
429
1523
  """Return a list of available regions."""
430
1524
  res = self._get('regions')
431
- return [Region.from_dict(item, self) for item in res.json()]
1525
+ return NamedList([Region.from_dict(item, self) for item in res.json()])
432
1526
 
433
1527
  def create_workspace_group(
434
1528
  self, name: str, region: Union[str, Region],
435
- firewall_ranges: Sequence[str], admin_password: Optional[str] = None,
1529
+ firewall_ranges: List[str], admin_password: Optional[str] = None,
1530
+ expires_at: Optional[str] = None,
1531
+ allow_all_traffic: Optional[bool] = None,
1532
+ update_window: Optional[Dict[str, int]] = None,
436
1533
  ) -> WorkspaceGroup:
437
1534
  """
438
1535
  Create a new workspace group.
@@ -449,6 +1546,17 @@ class WorkspaceManager(Manager):
449
1546
  admin_password : str, optional
450
1547
  Admin password for the workspace group. If no password is supplied,
451
1548
  a password will be generated and retured in the response.
1549
+ expires_at : str, optional
1550
+ The timestamp of when the workspace group will expire.
1551
+ If the expiration time is not specified,
1552
+ the workspace group will have no expiration time.
1553
+ At expiration, the workspace group is terminated and all the data is lost.
1554
+ Expiration time can be specified as a timestamp or duration.
1555
+ Example: "2021-01-02T15:04:05Z07:00", "2021-01-02", "3h30m"
1556
+ allow_all_traffic : bool, optional
1557
+ Allow all traffic to the workspace group
1558
+ update_window : Dict[str, int], optional
1559
+ Specify the day and hour of an update window: dict(day=0-6, hour=0-23)
452
1560
 
453
1561
  Returns
454
1562
  -------
@@ -461,7 +1569,10 @@ class WorkspaceManager(Manager):
461
1569
  'workspaceGroups', json=dict(
462
1570
  name=name, regionID=region,
463
1571
  adminPassword=admin_password,
464
- firewallRanges=firewall_ranges,
1572
+ firewallRanges=firewall_ranges or [],
1573
+ expiresAt=expires_at,
1574
+ allowAllTraffic=allow_all_traffic,
1575
+ updateWindow=update_window,
465
1576
  ),
466
1577
  )
467
1578
  return self.get_workspace_group(res.json()['workspaceGroupID'])
@@ -550,6 +1661,8 @@ def manage_workspaces(
550
1661
  access_token: Optional[str] = None,
551
1662
  version: str = WorkspaceManager.default_version,
552
1663
  base_url: str = WorkspaceManager.default_base_url,
1664
+ *,
1665
+ organization_id: Optional[str] = None,
553
1666
  ) -> WorkspaceManager:
554
1667
  """
555
1668
  Retrieve a SingleStoreDB workspace manager.
@@ -562,10 +1675,15 @@ def manage_workspaces(
562
1675
  Version of the API to use
563
1676
  base_url : str, optional
564
1677
  Base URL of the workspace management API
1678
+ organization_id : str, optional
1679
+ ID of organization, if using a JWT for authentication
565
1680
 
566
1681
  Returns
567
1682
  -------
568
1683
  :class:`WorkspaceManager`
569
1684
 
570
1685
  """
571
- return WorkspaceManager(access_token=access_token, base_url=base_url, version=version)
1686
+ return WorkspaceManager(
1687
+ access_token=access_token, base_url=base_url,
1688
+ version=version, organization_id=organization_id,
1689
+ )