singlestoredb 0.4.0__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 (120) hide show
  1. singlestoredb/__init__.py +33 -1
  2. singlestoredb/alchemy/__init__.py +90 -0
  3. singlestoredb/auth.py +5 -1
  4. singlestoredb/config.py +116 -14
  5. singlestoredb/connection.py +483 -516
  6. singlestoredb/converters.py +238 -135
  7. singlestoredb/exceptions.py +30 -2
  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/stage.py +257 -0
  22. singlestoredb/fusion/handlers/utils.py +162 -0
  23. singlestoredb/fusion/handlers/workspace.py +412 -0
  24. singlestoredb/fusion/registry.py +164 -0
  25. singlestoredb/fusion/result.py +399 -0
  26. singlestoredb/http/__init__.py +27 -0
  27. singlestoredb/{http.py → http/connection.py} +555 -154
  28. singlestoredb/management/__init__.py +3 -0
  29. singlestoredb/management/billing_usage.py +148 -0
  30. singlestoredb/management/cluster.py +14 -6
  31. singlestoredb/management/manager.py +100 -38
  32. singlestoredb/management/organization.py +188 -0
  33. singlestoredb/management/region.py +5 -5
  34. singlestoredb/management/utils.py +251 -2
  35. singlestoredb/management/workspace.py +1149 -28
  36. singlestoredb/{clients/pymysqlsv → mysql}/__init__.py +16 -21
  37. singlestoredb/{clients/pymysqlsv → mysql}/_auth.py +39 -8
  38. singlestoredb/{clients/pymysqlsv → mysql}/charset.py +26 -23
  39. singlestoredb/{clients/pymysqlsv/connections.py → mysql/connection.py} +532 -165
  40. singlestoredb/{clients/pymysqlsv → mysql}/constants/CLIENT.py +0 -1
  41. singlestoredb/{clients/pymysqlsv → mysql}/constants/COMMAND.py +0 -1
  42. singlestoredb/{clients/pymysqlsv → mysql}/constants/CR.py +0 -2
  43. singlestoredb/{clients/pymysqlsv → mysql}/constants/ER.py +0 -1
  44. singlestoredb/{clients/pymysqlsv → mysql}/constants/FIELD_TYPE.py +1 -1
  45. singlestoredb/{clients/pymysqlsv → mysql}/constants/FLAG.py +0 -1
  46. singlestoredb/{clients/pymysqlsv → mysql}/constants/SERVER_STATUS.py +0 -1
  47. singlestoredb/mysql/converters.py +271 -0
  48. singlestoredb/{clients/pymysqlsv → mysql}/cursors.py +228 -112
  49. singlestoredb/mysql/err.py +92 -0
  50. singlestoredb/{clients/pymysqlsv → mysql}/optionfile.py +5 -4
  51. singlestoredb/{clients/pymysqlsv → mysql}/protocol.py +49 -20
  52. singlestoredb/mysql/tests/__init__.py +19 -0
  53. singlestoredb/{clients/pymysqlsv → mysql}/tests/base.py +32 -12
  54. singlestoredb/mysql/tests/conftest.py +37 -0
  55. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_DictCursor.py +11 -7
  56. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_SSCursor.py +17 -12
  57. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_basic.py +32 -24
  58. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_connection.py +130 -119
  59. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_converters.py +9 -7
  60. singlestoredb/mysql/tests/test_cursor.py +141 -0
  61. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_err.py +3 -2
  62. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_issues.py +35 -27
  63. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_load_local.py +13 -11
  64. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_nextset.py +7 -3
  65. singlestoredb/{clients/pymysqlsv → mysql}/tests/test_optionfile.py +2 -1
  66. singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/__init__.py +1 -1
  67. singlestoredb/mysql/tests/thirdparty/test_MySQLdb/__init__.py +9 -0
  68. singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/capabilities.py +19 -17
  69. singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/dbapi20.py +31 -22
  70. singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/test_MySQLdb_capabilities.py +3 -4
  71. singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/test_MySQLdb_dbapi20.py +24 -20
  72. singlestoredb/{clients/pymysqlsv → mysql}/tests/thirdparty/test_MySQLdb/test_MySQLdb_nonstandard.py +4 -4
  73. singlestoredb/{clients/pymysqlsv → mysql}/times.py +3 -4
  74. singlestoredb/pytest.py +283 -0
  75. singlestoredb/tests/empty.sql +0 -0
  76. singlestoredb/tests/ext_funcs/__init__.py +385 -0
  77. singlestoredb/tests/test.sql +210 -0
  78. singlestoredb/tests/test2.sql +1 -0
  79. singlestoredb/tests/test_basics.py +482 -115
  80. singlestoredb/tests/test_config.py +13 -13
  81. singlestoredb/tests/test_connection.py +241 -305
  82. singlestoredb/tests/test_dbapi.py +27 -0
  83. singlestoredb/tests/test_ext_func.py +1193 -0
  84. singlestoredb/tests/test_ext_func_data.py +1101 -0
  85. singlestoredb/tests/test_fusion.py +465 -0
  86. singlestoredb/tests/test_http.py +32 -26
  87. singlestoredb/tests/test_management.py +588 -8
  88. singlestoredb/tests/test_plugin.py +33 -0
  89. singlestoredb/tests/test_results.py +11 -12
  90. singlestoredb/tests/test_udf.py +687 -0
  91. singlestoredb/tests/utils.py +3 -2
  92. singlestoredb/utils/config.py +58 -0
  93. singlestoredb/utils/debug.py +13 -0
  94. singlestoredb/utils/mogrify.py +151 -0
  95. singlestoredb/utils/results.py +4 -1
  96. singlestoredb-1.0.3.dist-info/METADATA +139 -0
  97. singlestoredb-1.0.3.dist-info/RECORD +112 -0
  98. {singlestoredb-0.4.0.dist-info → singlestoredb-1.0.3.dist-info}/WHEEL +1 -1
  99. singlestoredb-1.0.3.dist-info/entry_points.txt +2 -0
  100. singlestoredb/clients/pymysqlsv/converters.py +0 -365
  101. singlestoredb/clients/pymysqlsv/err.py +0 -144
  102. singlestoredb/clients/pymysqlsv/tests/__init__.py +0 -19
  103. singlestoredb/clients/pymysqlsv/tests/test_cursor.py +0 -133
  104. singlestoredb/clients/pymysqlsv/tests/thirdparty/test_MySQLdb/__init__.py +0 -9
  105. singlestoredb/drivers/__init__.py +0 -45
  106. singlestoredb/drivers/base.py +0 -198
  107. singlestoredb/drivers/cymysql.py +0 -38
  108. singlestoredb/drivers/http.py +0 -47
  109. singlestoredb/drivers/mariadb.py +0 -40
  110. singlestoredb/drivers/mysqlconnector.py +0 -49
  111. singlestoredb/drivers/mysqldb.py +0 -60
  112. singlestoredb/drivers/pymysql.py +0 -37
  113. singlestoredb/drivers/pymysqlsv.py +0 -35
  114. singlestoredb/drivers/pyodbc.py +0 -65
  115. singlestoredb-0.4.0.dist-info/METADATA +0 -111
  116. singlestoredb-0.4.0.dist-info/RECORD +0 -86
  117. /singlestoredb/{clients → fusion/handlers}/__init__.py +0 -0
  118. /singlestoredb/{clients/pymysqlsv → mysql}/constants/__init__.py +0 -0
  119. {singlestoredb-0.4.0.dist-info → singlestoredb-1.0.3.dist-info}/LICENSE +0 -0
  120. {singlestoredb-0.4.0.dist-info → singlestoredb-1.0.3.dist-info}/top_level.txt +0 -0
@@ -1,20 +1,911 @@
1
1
  #!/usr/bin/env python
2
2
  """SingleStoreDB Workspace Management."""
3
+ from __future__ import annotations
4
+
3
5
  import datetime
6
+ import glob
7
+ import io
8
+ import os
9
+ import re
10
+ import socket
11
+ import time
4
12
  from typing import Any
13
+ from typing import BinaryIO
5
14
  from typing import Dict
6
15
  from typing import List
7
16
  from typing import Optional
17
+ from typing import TextIO
8
18
  from typing import Union
9
19
 
10
20
  from .. import connection
11
21
  from ..exceptions import ManagementError
22
+ from .billing_usage import BillingUsageItem
12
23
  from .manager import Manager
24
+ from .organization import Organization
13
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
14
30
  from .utils import to_datetime
31
+ from .utils import ttl_property
15
32
  from .utils import vars_to_str
16
33
 
17
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
+
18
909
  class Workspace(object):
19
910
  """
20
911
  SingleStoreDB workspace definition.
@@ -34,9 +925,12 @@ class Workspace(object):
34
925
  """
35
926
 
36
927
  def __init__(
37
- self, name: str, workspace_id: str,
928
+ self,
929
+ name: str,
930
+ workspace_id: str,
38
931
  workspace_group: Union[str, 'WorkspaceGroup'],
39
- size: str, state: str,
932
+ size: str,
933
+ state: str,
40
934
  created_at: Union[str, datetime.datetime],
41
935
  terminated_at: Optional[Union[str, datetime.datetime]] = None,
42
936
  endpoint: Optional[str] = None,
@@ -106,7 +1000,7 @@ class Workspace(object):
106
1000
  out._manager = manager
107
1001
  return out
108
1002
 
109
- def refresh(self) -> 'Workspace':
1003
+ def refresh(self) -> Workspace:
110
1004
  """Update the object to the current state."""
111
1005
  if self._manager is None:
112
1006
  raise ManagementError(
@@ -122,6 +1016,7 @@ class Workspace(object):
122
1016
  wait_on_terminated: bool = False,
123
1017
  wait_interval: int = 10,
124
1018
  wait_timeout: int = 600,
1019
+ force: bool = False,
125
1020
  ) -> None:
126
1021
  """
127
1022
  Terminate the workspace.
@@ -134,6 +1029,8 @@ class Workspace(object):
134
1029
  Number of seconds between each server check
135
1030
  wait_timeout : int, optional
136
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?
137
1034
 
138
1035
  Raises
139
1036
  ------
@@ -145,7 +1042,8 @@ class Workspace(object):
145
1042
  raise ManagementError(
146
1043
  msg='No workspace manager is associated with this object.',
147
1044
  )
148
- 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}')
149
1047
  if wait_on_terminated:
150
1048
  self._manager._wait_on_state(
151
1049
  self._manager.get_workspace(self.id),
@@ -175,6 +1073,78 @@ class Workspace(object):
175
1073
  kwargs['host'] = self.endpoint
176
1074
  return connection.connect(**kwargs)
177
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
+
178
1148
 
179
1149
  class WorkspaceGroup(object):
180
1150
  """
@@ -197,7 +1167,7 @@ class WorkspaceGroup(object):
197
1167
  def __init__(
198
1168
  self, name: str, id: str,
199
1169
  created_at: Union[str, datetime.datetime],
200
- region: Region,
1170
+ region: Optional[Region],
201
1171
  firewall_ranges: List[str],
202
1172
  terminated_at: Optional[Union[str, datetime.datetime]],
203
1173
  ):
@@ -210,7 +1180,7 @@ class WorkspaceGroup(object):
210
1180
  #: Timestamp of when the workspace group was created
211
1181
  self.created_at = to_datetime(created_at)
212
1182
 
213
- #: Region of the cluster (see :class:`Region`)
1183
+ #: Region of the workspace group (see :class:`Region`)
214
1184
  self.region = region
215
1185
 
216
1186
  #: List of allowed incoming IP addresses / ranges
@@ -248,18 +1218,42 @@ class WorkspaceGroup(object):
248
1218
  :class:`WorkspaceGroup`
249
1219
 
250
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>')
251
1225
  out = cls(
252
- name=obj['name'], id=obj['workspaceGroupID'],
1226
+ name=obj['name'],
1227
+ id=obj['workspaceGroupID'],
253
1228
  created_at=obj['createdAt'],
254
- region=[x for x in manager.regions if x.id == obj['regionID']][0],
1229
+ region=region,
255
1230
  firewall_ranges=obj.get('firewallRanges', []),
256
1231
  terminated_at=obj.get('terminatedAt'),
257
1232
  )
258
1233
  out._manager = manager
259
1234
  return out
260
1235
 
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
+
261
1255
  def refresh(self) -> 'WorkspaceGroup':
262
- """Update teh object to the current state."""
1256
+ """Update the object to the current state."""
263
1257
  if self._manager is None:
264
1258
  raise ManagementError(
265
1259
  msg='No workspace manager is associated with this object.',
@@ -275,14 +1269,14 @@ class WorkspaceGroup(object):
275
1269
  firewall_ranges: Optional[List[str]] = None,
276
1270
  ) -> None:
277
1271
  """
278
- Update the cluster definition.
1272
+ Update the workspace group definition.
279
1273
 
280
1274
  Parameters
281
1275
  ----------
282
1276
  name : str, optional
283
- Cluster name
1277
+ Workspace group name
284
1278
  admim_password : str, optional
285
- Admin password for the cluster
1279
+ Admin password for the workspace group
286
1280
  firewall_ranges : Sequence[str], optional
287
1281
  List of allowed incoming IP addresses
288
1282
 
@@ -314,7 +1308,7 @@ class WorkspaceGroup(object):
314
1308
  force : bool, optional
315
1309
  Terminate a workspace group even if it has active workspaces
316
1310
  wait_on_terminated : bool, optional
317
- Wait for the cluster to go into 'Terminated' mode before returning
1311
+ Wait for the workspace group to go into 'Terminated' mode before returning
318
1312
  wait_interval : int, optional
319
1313
  Number of seconds between each server check
320
1314
  wait_timeout : int, optional
@@ -332,16 +1326,21 @@ class WorkspaceGroup(object):
332
1326
  )
333
1327
  self._manager._delete(f'workspaceGroups/{self.id}', params=dict(force=force))
334
1328
  if wait_on_terminated:
335
- self._manager._wait_on_state(
336
- self._manager.get_workspace_group(self.id),
337
- 'Terminated', interval=wait_interval, timeout=wait_timeout,
338
- )
339
- 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
340
1339
 
341
1340
  def create_workspace(
342
1341
  self, name: str, size: Optional[str] = None,
343
1342
  wait_on_active: bool = False, wait_interval: int = 10,
344
- wait_timeout: int = 600,
1343
+ wait_timeout: int = 600, add_endpoint_to_firewall_ranges: bool = True,
345
1344
  ) -> Workspace:
346
1345
  """
347
1346
  Create a new workspace.
@@ -359,6 +1358,9 @@ class WorkspaceGroup(object):
359
1358
  if wait=True
360
1359
  wait_interval : int, optional
361
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?
362
1364
 
363
1365
  Returns
364
1366
  -------
@@ -369,20 +1371,100 @@ class WorkspaceGroup(object):
369
1371
  raise ManagementError(
370
1372
  msg='No workspace manager is associated with this object.',
371
1373
  )
372
- return self._manager.create_workspace(
1374
+
1375
+ out = self._manager.create_workspace(
373
1376
  name=name, workspace_group=self, size=size, wait_on_active=wait_on_active,
374
1377
  wait_interval=wait_interval, wait_timeout=wait_timeout,
375
1378
  )
376
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
+
377
1386
  @property
378
- def workspaces(self) -> List[Workspace]:
1387
+ def workspaces(self) -> NamedList[Workspace]:
379
1388
  """Return a list of available workspaces."""
380
1389
  if self._manager is None:
381
1390
  raise ManagementError(
382
1391
  msg='No workspace manager is associated with this object.',
383
1392
  )
384
1393
  res = self._manager._get('workspaces', params=dict(workspaceGroupID=self.id))
385
- 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)
386
1468
 
387
1469
 
388
1470
  class WorkspaceManager(Manager):
@@ -416,20 +1498,38 @@ class WorkspaceManager(Manager):
416
1498
  obj_type = 'workspace'
417
1499
 
418
1500
  @property
419
- def workspace_groups(self) -> List[WorkspaceGroup]:
1501
+ def workspace_groups(self) -> NamedList[WorkspaceGroup]:
420
1502
  """Return a list of available workspace groups."""
421
1503
  res = self._get('workspaceGroups')
422
- return [WorkspaceGroup.from_dict(item, self) for item in res.json()]
1504
+ return NamedList([WorkspaceGroup.from_dict(item, self) for item in res.json()])
423
1505
 
424
1506
  @property
425
- 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]:
426
1523
  """Return a list of available regions."""
427
1524
  res = self._get('regions')
428
- return [Region.from_dict(item, self) for item in res.json()]
1525
+ return NamedList([Region.from_dict(item, self) for item in res.json()])
429
1526
 
430
1527
  def create_workspace_group(
431
1528
  self, name: str, region: Union[str, Region],
432
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,
433
1533
  ) -> WorkspaceGroup:
434
1534
  """
435
1535
  Create a new workspace group.
@@ -446,6 +1546,17 @@ class WorkspaceManager(Manager):
446
1546
  admin_password : str, optional
447
1547
  Admin password for the workspace group. If no password is supplied,
448
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)
449
1560
 
450
1561
  Returns
451
1562
  -------
@@ -458,7 +1569,10 @@ class WorkspaceManager(Manager):
458
1569
  'workspaceGroups', json=dict(
459
1570
  name=name, regionID=region,
460
1571
  adminPassword=admin_password,
461
- firewallRanges=firewall_ranges,
1572
+ firewallRanges=firewall_ranges or [],
1573
+ expiresAt=expires_at,
1574
+ allowAllTraffic=allow_all_traffic,
1575
+ updateWindow=update_window,
462
1576
  ),
463
1577
  )
464
1578
  return self.get_workspace_group(res.json()['workspaceGroupID'])
@@ -547,6 +1661,8 @@ def manage_workspaces(
547
1661
  access_token: Optional[str] = None,
548
1662
  version: str = WorkspaceManager.default_version,
549
1663
  base_url: str = WorkspaceManager.default_base_url,
1664
+ *,
1665
+ organization_id: Optional[str] = None,
550
1666
  ) -> WorkspaceManager:
551
1667
  """
552
1668
  Retrieve a SingleStoreDB workspace manager.
@@ -559,10 +1675,15 @@ def manage_workspaces(
559
1675
  Version of the API to use
560
1676
  base_url : str, optional
561
1677
  Base URL of the workspace management API
1678
+ organization_id : str, optional
1679
+ ID of organization, if using a JWT for authentication
562
1680
 
563
1681
  Returns
564
1682
  -------
565
1683
  :class:`WorkspaceManager`
566
1684
 
567
1685
  """
568
- 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
+ )