singlestoredb 1.14.2__cp38-abi3-win_amd64.whl → 1.15.0__cp38-abi3-win_amd64.whl

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

Potentially problematic release.


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

@@ -0,0 +1,98 @@
1
+ import json
2
+ import time
3
+ from typing import Any
4
+ from typing import Dict
5
+ from typing import Optional
6
+
7
+ from . import utils
8
+
9
+ logger = utils.get_logger('singlestoredb.functions.ext.metrics')
10
+
11
+
12
+ class RoundedFloatEncoder(json.JSONEncoder):
13
+
14
+ def encode(self, obj: Any) -> str:
15
+ if isinstance(obj, dict):
16
+ return '{' + ', '.join(
17
+ f'"{k}": {self._format_value(v)}'
18
+ for k, v in obj.items()
19
+ ) + '}'
20
+ return super().encode(obj)
21
+
22
+ def _format_value(self, value: Any) -> str:
23
+ if isinstance(value, float):
24
+ return f'{value:.2f}'
25
+ return json.dumps(value)
26
+
27
+
28
+ class Timer:
29
+ """
30
+ Timer context manager that supports nested timing using a stack.
31
+
32
+ Example
33
+ -------
34
+ timer = Timer()
35
+
36
+ with timer('total'):
37
+ with timer('receive_data'):
38
+ time.sleep(0.1)
39
+ with timer('parse_input'):
40
+ time.sleep(0.2)
41
+ with timer('call_function'):
42
+ with timer('inner_operation'):
43
+ time.sleep(0.05)
44
+ time.sleep(0.3)
45
+
46
+ print(timer.metrics)
47
+ # {'receive_data': 0.1, 'parse_input': 0.2, 'inner_operation': 0.05,
48
+ # 'call_function': 0.35, 'total': 0.65}
49
+
50
+ """
51
+
52
+ def __init__(self, **kwargs: Any) -> None:
53
+ self.metadata: Dict[str, Any] = kwargs
54
+ self.metrics: Dict[str, float] = dict()
55
+ self.entries: Dict[str, float] = dict()
56
+ self._current_key: Optional[str] = None
57
+ self.start_time = time.perf_counter()
58
+
59
+ def __call__(self, key: str) -> 'Timer':
60
+ self._current_key = key
61
+ return self
62
+
63
+ def __enter__(self) -> 'Timer':
64
+ if self._current_key is None:
65
+ raise ValueError(
66
+ "No key specified. Use timer('key_name') as context manager.",
67
+ )
68
+ self.entries[self._current_key] = time.perf_counter()
69
+ return self
70
+
71
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
72
+ key = self._current_key
73
+ if key and key in self.entries:
74
+ start = self.entries.pop(key)
75
+ elapsed = time.perf_counter() - start
76
+ self.metrics[key] = elapsed
77
+ self._current_key = None
78
+
79
+ async def __aenter__(self) -> 'Timer':
80
+ return self.__enter__()
81
+
82
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
83
+ self.__exit__(exc_type, exc_val, exc_tb)
84
+
85
+ def reset(self) -> None:
86
+ self.metrics.clear()
87
+ self.entries.clear()
88
+ self._current_key = None
89
+
90
+ def finish(self) -> None:
91
+ """Finish the current timing context and store the elapsed time."""
92
+ self.metrics['total'] = time.perf_counter() - self.start_time
93
+ self.log_metrics()
94
+
95
+ def log_metrics(self) -> None:
96
+ if self.metadata.get('function'):
97
+ result = dict(type='function_metrics', **self.metadata, **self.metrics)
98
+ logger.info(json.dumps(result, cls=RoundedFloatEncoder))
@@ -0,0 +1,20 @@
1
+ import numpy as np
2
+ import numpy.typing as npt
3
+
4
+ NDArray = npt.NDArray
5
+
6
+ StringArray = StrArray = npt.NDArray[np.str_]
7
+ BytesArray = npt.NDArray[np.bytes_]
8
+ Float32Array = FloatArray = npt.NDArray[np.float32]
9
+ Float64Array = DoubleArray = npt.NDArray[np.float64]
10
+ IntArray = npt.NDArray[np.int_]
11
+ Int8Array = npt.NDArray[np.int8]
12
+ Int16Array = npt.NDArray[np.int16]
13
+ Int32Array = npt.NDArray[np.int32]
14
+ Int64Array = npt.NDArray[np.int64]
15
+ UInt8Array = npt.NDArray[np.uint8]
16
+ UInt16Array = npt.NDArray[np.uint16]
17
+ UInt32Array = npt.NDArray[np.uint32]
18
+ UInt64Array = npt.NDArray[np.uint64]
19
+ DateTimeArray = npt.NDArray[np.datetime64]
20
+ TimeDeltaArray = npt.NDArray[np.timedelta64]
@@ -0,0 +1,2 @@
1
+ from pandas import DataFrame # noqa: F401
2
+ from pandas import Series # noqa: F401
@@ -0,0 +1,2 @@
1
+ from polars import DataFrame # noqa: F401
2
+ from polars import Series # noqa: F401
@@ -0,0 +1,2 @@
1
+ from pyarrow import Array # noqa: F401
2
+ from pyarrow import Table # noqa: F401
@@ -1,6 +1,8 @@
1
1
  import os
2
2
  import tempfile
3
+ from pathlib import Path
3
4
  from typing import Any
5
+ from warnings import warn
4
6
 
5
7
  from IPython.core.interactiveshell import InteractiveShell
6
8
  from IPython.core.magic import line_magic
@@ -8,6 +10,8 @@ from IPython.core.magic import Magics
8
10
  from IPython.core.magic import magics_class
9
11
  from IPython.core.magic import needs_local_scope
10
12
  from IPython.core.magic import no_var_expand
13
+ from IPython.utils.contexts import preserve_keys
14
+ from IPython.utils.syspathcontext import prepended_to_syspath
11
15
  from jinja2 import Template
12
16
 
13
17
 
@@ -53,4 +57,81 @@ class RunPersonalMagic(Magics):
53
57
  # Execute the SQL command
54
58
  self.shell.run_line_magic('sql', sql_command)
55
59
  # Run the downloaded file
56
- self.shell.run_line_magic('run', f'"{temp_file_path}"')
60
+ with preserve_keys(self.shell.user_ns, '__file__'):
61
+ self.shell.user_ns['__file__'] = temp_file_path
62
+ self.safe_execfile_ipy(temp_file_path, raise_exceptions=True)
63
+
64
+ def safe_execfile_ipy(
65
+ self,
66
+ fname: str,
67
+ shell_futures: bool = False,
68
+ raise_exceptions: bool = False,
69
+ ) -> None:
70
+ """Like safe_execfile, but for .ipy or .ipynb files with IPython syntax.
71
+
72
+ Parameters
73
+ ----------
74
+ fname : str
75
+ The name of the file to execute. The filename must have a
76
+ .ipy or .ipynb extension.
77
+ shell_futures : bool (False)
78
+ If True, the code will share future statements with the interactive
79
+ shell. It will both be affected by previous __future__ imports, and
80
+ any __future__ imports in the code will affect the shell. If False,
81
+ __future__ imports are not shared in either direction.
82
+ raise_exceptions : bool (False)
83
+ If True raise exceptions everywhere. Meant for testing.
84
+ """
85
+ fpath = Path(fname).expanduser().resolve()
86
+
87
+ # Make sure we can open the file
88
+ try:
89
+ with fpath.open('rb'):
90
+ pass
91
+ except Exception:
92
+ warn('Could not open file <%s> for safe execution.' % fpath)
93
+ return
94
+
95
+ # Find things also in current directory. This is needed to mimic the
96
+ # behavior of running a script from the system command line, where
97
+ # Python inserts the script's directory into sys.path
98
+ dname = str(fpath.parent)
99
+
100
+ def get_cells() -> Any:
101
+ """generator for sequence of code blocks to run"""
102
+ if fpath.suffix == '.ipynb':
103
+ from nbformat import read
104
+ nb = read(fpath, as_version=4)
105
+ if not nb.cells:
106
+ return
107
+ for cell in nb.cells:
108
+ if cell.cell_type == 'code':
109
+ if not cell.source.strip():
110
+ continue
111
+ if getattr(cell, 'metadata', {}).get('language', '') == 'sql':
112
+ output_redirect = getattr(
113
+ cell, 'metadata', {},
114
+ ).get('output_variable', '') or ''
115
+ if output_redirect:
116
+ output_redirect = f' {output_redirect} <<'
117
+ yield f'%%sql{output_redirect}\n{cell.source}'
118
+ else:
119
+ yield cell.source
120
+ else:
121
+ yield fpath.read_text(encoding='utf-8')
122
+
123
+ with prepended_to_syspath(dname):
124
+ try:
125
+ for cell in get_cells():
126
+ result = self.shell.run_cell(
127
+ cell, silent=True, shell_futures=shell_futures,
128
+ )
129
+ if raise_exceptions:
130
+ result.raise_error()
131
+ elif not result.success:
132
+ break
133
+ except Exception:
134
+ if raise_exceptions:
135
+ raise
136
+ self.shell.showtraceback()
137
+ warn('Unknown failure executing file: <%s>' % fpath)
@@ -1,6 +1,8 @@
1
1
  import os
2
2
  import tempfile
3
+ from pathlib import Path
3
4
  from typing import Any
5
+ from warnings import warn
4
6
 
5
7
  from IPython.core.interactiveshell import InteractiveShell
6
8
  from IPython.core.magic import line_magic
@@ -8,6 +10,8 @@ from IPython.core.magic import Magics
8
10
  from IPython.core.magic import magics_class
9
11
  from IPython.core.magic import needs_local_scope
10
12
  from IPython.core.magic import no_var_expand
13
+ from IPython.utils.contexts import preserve_keys
14
+ from IPython.utils.syspathcontext import prepended_to_syspath
11
15
  from jinja2 import Template
12
16
 
13
17
 
@@ -50,4 +54,81 @@ class RunSharedMagic(Magics):
50
54
  # Execute the SQL command
51
55
  self.shell.run_line_magic('sql', sql_command)
52
56
  # Run the downloaded file
53
- self.shell.run_line_magic('run', f'"{temp_file_path}"')
57
+ with preserve_keys(self.shell.user_ns, '__file__'):
58
+ self.shell.user_ns['__file__'] = temp_file_path
59
+ self.safe_execfile_ipy(temp_file_path, raise_exceptions=True)
60
+
61
+ def safe_execfile_ipy(
62
+ self,
63
+ fname: str,
64
+ shell_futures: bool = False,
65
+ raise_exceptions: bool = False,
66
+ ) -> None:
67
+ """Like safe_execfile, but for .ipy or .ipynb files with IPython syntax.
68
+
69
+ Parameters
70
+ ----------
71
+ fname : str
72
+ The name of the file to execute. The filename must have a
73
+ .ipy or .ipynb extension.
74
+ shell_futures : bool (False)
75
+ If True, the code will share future statements with the interactive
76
+ shell. It will both be affected by previous __future__ imports, and
77
+ any __future__ imports in the code will affect the shell. If False,
78
+ __future__ imports are not shared in either direction.
79
+ raise_exceptions : bool (False)
80
+ If True raise exceptions everywhere. Meant for testing.
81
+ """
82
+ fpath = Path(fname).expanduser().resolve()
83
+
84
+ # Make sure we can open the file
85
+ try:
86
+ with fpath.open('rb'):
87
+ pass
88
+ except Exception:
89
+ warn('Could not open file <%s> for safe execution.' % fpath)
90
+ return
91
+
92
+ # Find things also in current directory. This is needed to mimic the
93
+ # behavior of running a script from the system command line, where
94
+ # Python inserts the script's directory into sys.path
95
+ dname = str(fpath.parent)
96
+
97
+ def get_cells() -> Any:
98
+ """generator for sequence of code blocks to run"""
99
+ if fpath.suffix == '.ipynb':
100
+ from nbformat import read
101
+ nb = read(fpath, as_version=4)
102
+ if not nb.cells:
103
+ return
104
+ for cell in nb.cells:
105
+ if cell.cell_type == 'code':
106
+ if not cell.source.strip():
107
+ continue
108
+ if getattr(cell, 'metadata', {}).get('language', '') == 'sql':
109
+ output_redirect = getattr(
110
+ cell, 'metadata', {},
111
+ ).get('output_variable', '') or ''
112
+ if output_redirect:
113
+ output_redirect = f' {output_redirect} <<'
114
+ yield f'%%sql{output_redirect}\n{cell.source}'
115
+ else:
116
+ yield cell.source
117
+ else:
118
+ yield fpath.read_text(encoding='utf-8')
119
+
120
+ with prepended_to_syspath(dname):
121
+ try:
122
+ for cell in get_cells():
123
+ result = self.shell.run_cell(
124
+ cell, silent=True, shell_futures=shell_futures,
125
+ )
126
+ if raise_exceptions:
127
+ result.raise_error()
128
+ elif not result.success:
129
+ break
130
+ except Exception:
131
+ if raise_exceptions:
132
+ raise
133
+ self.shell.showtraceback()
134
+ warn('Unknown failure executing file: <%s>' % fpath)
@@ -2,6 +2,7 @@
2
2
  from .cluster import manage_cluster
3
3
  from .files import manage_files
4
4
  from .manager import get_token
5
+ from .region import manage_regions
5
6
  from .workspace import get_organization
6
7
  from .workspace import get_secret
7
8
  from .workspace import get_stage
@@ -4,6 +4,7 @@ from typing import Dict
4
4
  from typing import Optional
5
5
 
6
6
  from .manager import Manager
7
+ from .utils import NamedList
7
8
  from .utils import vars_to_str
8
9
 
9
10
 
@@ -65,3 +66,94 @@ class Region(object):
65
66
  )
66
67
  out._manager = manager
67
68
  return out
69
+
70
+
71
+ class RegionManager(Manager):
72
+ """
73
+ SingleStoreDB region manager.
74
+
75
+ This class should be instantiated using :func:`singlestoredb.manage_regions`.
76
+
77
+ Parameters
78
+ ----------
79
+ access_token : str, optional
80
+ The API key or other access token for the workspace management API
81
+ version : str, optional
82
+ Version of the API to use
83
+ base_url : str, optional
84
+ Base URL of the workspace management API
85
+
86
+ See Also
87
+ --------
88
+ :func:`singlestoredb.manage_regions`
89
+ """
90
+
91
+ #: Object type
92
+ obj_type = 'region'
93
+
94
+ def list_regions(self) -> NamedList[Region]:
95
+ """
96
+ List all available regions.
97
+
98
+ Returns
99
+ -------
100
+ NamedList[Region]
101
+ List of available regions
102
+
103
+ Raises
104
+ ------
105
+ ManagementError
106
+ If there is an error getting the regions
107
+ """
108
+ res = self._get('regions')
109
+ return NamedList(
110
+ [Region.from_dict(item, self) for item in res.json()],
111
+ )
112
+
113
+ def list_shared_tier_regions(self) -> NamedList[Region]:
114
+ """
115
+ List regions that support shared tier workspaces.
116
+
117
+ Returns
118
+ -------
119
+ NamedList[Region]
120
+ List of regions that support shared tier workspaces
121
+
122
+ Raises
123
+ ------
124
+ ManagementError
125
+ If there is an error getting the regions
126
+ """
127
+ res = self._get('regions/sharedtier')
128
+ return NamedList(
129
+ [Region.from_dict(item, self) for item in res.json()],
130
+ )
131
+
132
+
133
+ def manage_regions(
134
+ access_token: Optional[str] = None,
135
+ version: Optional[str] = None,
136
+ base_url: Optional[str] = None,
137
+ ) -> RegionManager:
138
+ """
139
+ Retrieve a SingleStoreDB region manager.
140
+
141
+ Parameters
142
+ ----------
143
+ access_token : str, optional
144
+ The API key or other access token for the workspace management API
145
+ version : str, optional
146
+ Version of the API to use
147
+ base_url : str, optional
148
+ Base URL of the workspace management API
149
+
150
+ Returns
151
+ -------
152
+ :class:`RegionManager`
153
+
154
+ """
155
+ return RegionManager(
156
+ access_token=access_token,
157
+ version=version,
158
+ base_url=base_url,
159
+ )
@@ -1301,17 +1301,24 @@ class StarterWorkspace(object):
1301
1301
  See Also
1302
1302
  --------
1303
1303
  :meth:`WorkspaceManager.get_starter_workspace`
1304
+ :meth:`WorkspaceManager.create_starter_workspace`
1305
+ :meth:`WorkspaceManager.terminate_starter_workspace`
1306
+ :meth:`WorkspaceManager.create_starter_workspace_user`
1304
1307
  :attr:`WorkspaceManager.starter_workspaces`
1305
1308
 
1306
1309
  """
1307
1310
 
1308
1311
  name: str
1309
1312
  id: str
1313
+ database_name: str
1314
+ endpoint: Optional[str]
1310
1315
 
1311
1316
  def __init__(
1312
1317
  self,
1313
1318
  name: str,
1314
1319
  id: str,
1320
+ database_name: str,
1321
+ endpoint: Optional[str] = None,
1315
1322
  ):
1316
1323
  #: Name of the starter workspace
1317
1324
  self.name = name
@@ -1319,6 +1326,13 @@ class StarterWorkspace(object):
1319
1326
  #: Unique ID of the starter workspace
1320
1327
  self.id = id
1321
1328
 
1329
+ #: Name of the database associated with the starter workspace
1330
+ self.database_name = database_name
1331
+
1332
+ #: Endpoint to connect to the starter workspace. The endpoint is in the form
1333
+ #: of ``hostname:port``
1334
+ self.endpoint = endpoint
1335
+
1322
1336
  self._manager: Optional[WorkspaceManager] = None
1323
1337
 
1324
1338
  def __str__(self) -> str:
@@ -1351,10 +1365,63 @@ class StarterWorkspace(object):
1351
1365
  out = cls(
1352
1366
  name=obj['name'],
1353
1367
  id=obj['virtualWorkspaceID'],
1368
+ database_name=obj['databaseName'],
1369
+ endpoint=obj.get('endpoint'),
1354
1370
  )
1355
1371
  out._manager = manager
1356
1372
  return out
1357
1373
 
1374
+ def connect(self, **kwargs: Any) -> connection.Connection:
1375
+ """
1376
+ Create a connection to the database server for this starter workspace.
1377
+
1378
+ Parameters
1379
+ ----------
1380
+ **kwargs : keyword-arguments, optional
1381
+ Parameters to the SingleStoreDB `connect` function except host
1382
+ and port which are supplied by the starter workspace object
1383
+
1384
+ Returns
1385
+ -------
1386
+ :class:`Connection`
1387
+
1388
+ """
1389
+ if not self.endpoint:
1390
+ raise ManagementError(
1391
+ msg='An endpoint has not been set in this '
1392
+ 'starter workspace configuration',
1393
+ )
1394
+ # Parse endpoint as host:port
1395
+ if ':' in self.endpoint:
1396
+ host, port = self.endpoint.split(':', 1)
1397
+ kwargs['host'] = host
1398
+ kwargs['port'] = int(port)
1399
+ else:
1400
+ kwargs['host'] = self.endpoint
1401
+ return connection.connect(**kwargs)
1402
+
1403
+ def terminate(self) -> None:
1404
+ """Terminate the starter workspace."""
1405
+ if self._manager is None:
1406
+ raise ManagementError(
1407
+ msg='No workspace manager is associated with this object.',
1408
+ )
1409
+ self._manager._delete(f'sharedtier/virtualWorkspaces/{self.id}')
1410
+
1411
+ def refresh(self) -> StarterWorkspace:
1412
+ """Update the object to the current state."""
1413
+ if self._manager is None:
1414
+ raise ManagementError(
1415
+ msg='No workspace manager is associated with this object.',
1416
+ )
1417
+ new_obj = self._manager.get_starter_workspace(self.id)
1418
+ for name, value in vars(new_obj).items():
1419
+ if isinstance(value, Mapping):
1420
+ setattr(self, name, snake_to_camel_dict(value))
1421
+ else:
1422
+ setattr(self, name, value)
1423
+ return self
1424
+
1358
1425
  @property
1359
1426
  def organization(self) -> Organization:
1360
1427
  if self._manager is None:
@@ -1375,7 +1442,7 @@ class StarterWorkspace(object):
1375
1442
  stages = stage
1376
1443
 
1377
1444
  @property
1378
- def starter_workspaces(self) -> NamedList[StarterWorkspace]:
1445
+ def starter_workspaces(self) -> NamedList['StarterWorkspace']:
1379
1446
  """Return a list of available starter workspaces."""
1380
1447
  if self._manager is None:
1381
1448
  raise ManagementError(
@@ -1386,6 +1453,64 @@ class StarterWorkspace(object):
1386
1453
  [StarterWorkspace.from_dict(item, self._manager) for item in res.json()],
1387
1454
  )
1388
1455
 
1456
+ def create_user(
1457
+ self,
1458
+ user_name: str,
1459
+ password: Optional[str] = None,
1460
+ ) -> Dict[str, str]:
1461
+ """
1462
+ Create a new user for this starter workspace.
1463
+
1464
+ Parameters
1465
+ ----------
1466
+ user_name : str
1467
+ The starter workspace user name to connect the new user to the database
1468
+ password : str, optional
1469
+ Password for the new user. If not provided, a password will be
1470
+ auto-generated by the system.
1471
+
1472
+ Returns
1473
+ -------
1474
+ Dict[str, str]
1475
+ Dictionary containing 'userID' and 'password' of the created user
1476
+
1477
+ Raises
1478
+ ------
1479
+ ManagementError
1480
+ If no workspace manager is associated with this object.
1481
+ """
1482
+ if self._manager is None:
1483
+ raise ManagementError(
1484
+ msg='No workspace manager is associated with this object.',
1485
+ )
1486
+
1487
+ payload = {
1488
+ 'userName': user_name,
1489
+ }
1490
+ if password is not None:
1491
+ payload['password'] = password
1492
+
1493
+ res = self._manager._post(
1494
+ f'sharedtier/virtualWorkspaces/{self.id}/users',
1495
+ json=payload,
1496
+ )
1497
+
1498
+ response_data = res.json()
1499
+ user_id = response_data.get('userID')
1500
+ if not user_id:
1501
+ raise ManagementError(msg='No userID returned from API')
1502
+
1503
+ # Return the password provided by user or generated by API
1504
+ returned_password = password if password is not None \
1505
+ else response_data.get('password')
1506
+ if not returned_password:
1507
+ raise ManagementError(msg='No password available from API response')
1508
+
1509
+ return {
1510
+ 'user_id': user_id,
1511
+ 'password': returned_password,
1512
+ }
1513
+
1389
1514
 
1390
1515
  class Billing(object):
1391
1516
  """Billing information."""
@@ -1522,6 +1647,14 @@ class WorkspaceManager(Manager):
1522
1647
  res = self._get('regions')
1523
1648
  return NamedList([Region.from_dict(item, self) for item in res.json()])
1524
1649
 
1650
+ @ttl_property(datetime.timedelta(hours=1))
1651
+ def shared_tier_regions(self) -> NamedList[Region]:
1652
+ """Return a list of regions that support shared tier workspaces."""
1653
+ res = self._get('regions/sharedtier')
1654
+ return NamedList(
1655
+ [Region.from_dict(item, self) for item in res.json()],
1656
+ )
1657
+
1525
1658
  def create_workspace_group(
1526
1659
  self,
1527
1660
  name: str,
@@ -1717,6 +1850,52 @@ class WorkspaceManager(Manager):
1717
1850
  res = self._get(f'sharedtier/virtualWorkspaces/{id}')
1718
1851
  return StarterWorkspace.from_dict(res.json(), manager=self)
1719
1852
 
1853
+ def create_starter_workspace(
1854
+ self,
1855
+ name: str,
1856
+ database_name: str,
1857
+ workspace_group: dict[str, str],
1858
+ ) -> 'StarterWorkspace':
1859
+ """
1860
+ Create a new starter (shared tier) workspace.
1861
+
1862
+ Parameters
1863
+ ----------
1864
+ name : str
1865
+ Name of the starter workspace
1866
+ database_name : str
1867
+ Name of the database for the starter workspace
1868
+ workspace_group : dict[str, str]
1869
+ Workspace group input (dict with keys: 'cell_id')
1870
+
1871
+ Returns
1872
+ -------
1873
+ :class:`StarterWorkspace`
1874
+ """
1875
+ if not workspace_group or not isinstance(workspace_group, dict):
1876
+ raise ValueError(
1877
+ 'workspace_group must be a dict with keys: '
1878
+ "'cell_id'",
1879
+ )
1880
+ if set(workspace_group.keys()) != {'cell_id'}:
1881
+ raise ValueError("workspace_group must contain only 'cell_id'")
1882
+
1883
+ payload = {
1884
+ 'name': name,
1885
+ 'databaseName': database_name,
1886
+ 'workspaceGroup': {
1887
+ 'cellID': workspace_group['cell_id'],
1888
+ },
1889
+ }
1890
+
1891
+ res = self._post('sharedtier/virtualWorkspaces', json=payload)
1892
+ virtual_workspace_id = res.json().get('virtualWorkspaceID')
1893
+ if not virtual_workspace_id:
1894
+ raise ManagementError(msg='No virtualWorkspaceID returned from API')
1895
+
1896
+ res = self._get(f'sharedtier/virtualWorkspaces/{virtual_workspace_id}')
1897
+ return StarterWorkspace.from_dict(res.json(), self)
1898
+
1720
1899
 
1721
1900
  def manage_workspaces(
1722
1901
  access_token: Optional[str] = None,