diff --git a/singlestoredb/fusion/handlers/stage.py b/singlestoredb/fusion/handlers/stage.py index 154e7112..54a8d028 100644 --- a/singlestoredb/fusion/handlers/stage.py +++ b/singlestoredb/fusion/handlers/stage.py @@ -7,24 +7,26 @@ from ..handler import SQLHandler from ..result import FusionSQLResult from .utils import dt_isoformat -from .utils import get_workspace_group +from .utils import get_deployment class ShowStageFilesHandler(SQLHandler): """ - SHOW STAGE FILES [ in_group ] + SHOW STAGE FILES [ in ] [ at_path ] [ ] [ ] [ ] [ recursive ] [ extended ]; - # Workspace group - in_group = IN GROUP { group_id | group_name } + # Deployment + in = { in_group | in_deployment } + in_group = IN GROUP { deployment_id | deployment_name } + in_deployment = IN { deployment_id | deployment_name } - # ID of group - group_id = ID '' + # ID of deployment + deployment_id = ID '' - # Name of group - group_name = '' + # Name of deployment + deployment_name = '' # Stage path to list at_path = AT '' @@ -44,10 +46,10 @@ class ShowStageFilesHandler(SQLHandler): Arguments --------- - * ````: The ID of the workspace group in which - the Stage is attached. - * ````: The name of the workspace group in which + * ````: The ID of the deployment in which the Stage is attached. + * ````: The name of the deployment in which + which the Stage is attached. * ````: A path in the Stage. * ````: A pattern similar to SQL LIKE clause. Uses ``%`` as the wildcard character. @@ -62,8 +64,8 @@ class ShowStageFilesHandler(SQLHandler): key. By default, the results are sorted in the ascending order. * The ``AT PATH`` clause specifies the path in the Stage to list the files from. - * The ``IN GROUP`` clause specifies the ID or the name of the - workspace group in which the Stage is attached. + * The ``IN`` clause specifies the ID or the name of the + deployment in which the Stage is attached. * Use the ``RECURSIVE`` clause to list the files recursively. * To return more information about the files, use the ``EXTENDED`` clause. @@ -72,12 +74,12 @@ class ShowStageFilesHandler(SQLHandler): -------- The following command lists the files at a specific path:: - SHOW STAGE FILES IN GROUP 'wsg1' AT PATH "/data/"; + SHOW STAGE FILES IN 'wsg1' AT PATH "/data/"; The following command lists the files recursively with additional information:: - SHOW STAGE FILES IN GROUP 'wsg1' RECURSIVE EXTENDED; + SHOW STAGE FILES IN 'wsg1' RECURSIVE EXTENDED; See Also -------- @@ -87,7 +89,7 @@ class ShowStageFilesHandler(SQLHandler): """ # noqa: E501 def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]: - wg = get_workspace_group(params) + wg = get_deployment(params) res = FusionSQLResult() res.add_field('Name', result.STRING) @@ -132,20 +134,22 @@ def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]: class UploadStageFileHandler(SQLHandler): """ UPLOAD FILE TO STAGE stage_path - [ in_group ] + [ in ] FROM local_path [ overwrite ]; # Path to stage file stage_path = '' - # Workspace group - in_group = IN GROUP { group_id | group_name } + # Deployment + in = { in_group | in_deployment } + in_group = IN GROUP { deployment_id | deployment_name } + in_deployment = IN { deployment_id | deployment_name } - # ID of group - group_id = ID '' + # ID of deployment + deployment_id = ID '' - # Name of group - group_name = '' + # Name of deployment + deployment_name = '' # Path to local file local_path = '' @@ -162,17 +166,17 @@ class UploadStageFileHandler(SQLHandler): Arguments --------- - * ````: The path in the Stage where the file is uploaded. - * ````: The ID of the workspace group in which the Stage + * ````: The path in the Stage where the file is uploaded. + * ````: The ID of the deployment in which the Stage is attached. - * ````: The name of the workspace group in which the - Stage is attached. + * ````: The name of the deployment in which + which the Stage is attached. * ````: The path to the file to upload in the local directory. Remarks ------- - * The ``IN GROUP`` clause specifies the ID or the name of the workspace + * The ``IN`` clause specifies the ID or the name of the workspace group in which the Stage is attached. * If the ``OVERWRITE`` clause is specified, any existing file at the specified path in the Stage is overwritten. @@ -182,7 +186,7 @@ class UploadStageFileHandler(SQLHandler): The following command uploads a file to a Stage and overwrites any existing files at the specified path:: - UPLOAD FILE TO STAGE '/data/stats.csv' IN GROUP 'wsg1' + UPLOAD FILE TO STAGE '/data/stats.csv' IN 'wsg1' FROM '/tmp/user/stats.csv' OVERWRITE; See Also @@ -192,7 +196,7 @@ class UploadStageFileHandler(SQLHandler): """ # noqa: E501 def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]: - wg = get_workspace_group(params) + wg = get_deployment(params) wg.stage.upload_file( params['local_path'], params['stage_path'], overwrite=params['overwrite'], @@ -206,7 +210,7 @@ def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]: class DownloadStageFileHandler(SQLHandler): """ DOWNLOAD STAGE FILE stage_path - [ in_group ] + [ in ] [ local_path ] [ overwrite ] [ encoding ]; @@ -214,14 +218,16 @@ class DownloadStageFileHandler(SQLHandler): # Path to stage file stage_path = '' - # Workspace group - in_group = IN GROUP { group_id | group_name } + # Deployment + in = { in_group | in_deployment } + in_group = IN GROUP { deployment_id | deployment_name } + in_deployment = IN { deployment_id | deployment_name } - # ID of group - group_id = ID '' + # ID of deployment + deployment_id = ID '' - # Name of group - group_name = '' + # Name of deployment + deployment_name = '' # Path to local file local_path = TO '' @@ -241,11 +247,11 @@ class DownloadStageFileHandler(SQLHandler): Arguments --------- - * ````: The path to the file to download in a Stage. - * ````: The ID of the workspace group in which the - Stage is attached. - * ````: The name of the workspace group in which the + * ````: The path to the file to download in a Stage. + * ````: The ID of the deployment in which the Stage is attached. + * ````: The name of the deployment in which + which the Stage is attached. * ````: The encoding to apply to the downloaded file. * ````: Specifies the path in the local directory where the file is downloaded. @@ -254,8 +260,8 @@ class DownloadStageFileHandler(SQLHandler): ------- * If the ``OVERWRITE`` clause is specified, any existing file at the download location is overwritten. - * The ``IN GROUP`` clause specifies the ID or the name of the - workspace group in which the Stage is attached. + * The ``IN`` clause specifies the ID or the name of the + deployment in which the Stage is attached. * By default, files are downloaded in binary encoding. To view the contents of the file on the standard output, use the ``ENCODING`` clause and specify an encoding. @@ -267,12 +273,12 @@ class DownloadStageFileHandler(SQLHandler): The following command displays the contents of the file on the standard output:: - DOWNLOAD STAGE FILE '/data/stats.csv' IN GROUP 'wsgroup1' ENCODING 'utf8'; + DOWNLOAD STAGE FILE '/data/stats.csv' IN 'wsgroup1' ENCODING 'utf8'; The following command downloads a file to a specific location and overwrites any existing file with the name ``stats.csv`` on the local storage:: - DOWNLOAD STAGE FILE '/data/stats.csv' IN GROUP 'wsgroup1' + DOWNLOAD STAGE FILE '/data/stats.csv' IN 'wsgroup1' TO '/tmp/data.csv' OVERWRITE; See Also @@ -282,7 +288,7 @@ class DownloadStageFileHandler(SQLHandler): """ # noqa: E501 def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]: - wg = get_workspace_group(params) + wg = get_deployment(params) out = wg.stage.download_file( params['stage_path'], @@ -309,19 +315,21 @@ def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]: class DropStageFileHandler(SQLHandler): """ DROP STAGE FILE stage_path - [ in_group ]; + [ in ]; # Path to stage file stage_path = '' - # Workspace group - in_group = IN GROUP { group_id | group_name } + # Deployment + in = { in_group | in_deployment } + in_group = IN GROUP { deployment_id | deployment_name } + in_deployment = IN { deployment_id | deployment_name } - # ID of group - group_id = ID '' + # ID of deployment + deployment_id = ID '' - # Name of group - group_name = '' + # Name of deployment + deployment_name = '' Description ----------- @@ -332,23 +340,23 @@ class DropStageFileHandler(SQLHandler): Arguments --------- - * ````: The path to the file to delete in a Stage. - * ````: The ID of the workspace group in which the + * ````: The path to the file to delete in a Stage. + * ````: The ID of the deployment in which the Stage is attached. - * ````: The name of the workspace group in which - the Stage is attached. + * ````: The name of the deployment in which + which the Stage is attached. Remarks ------- - * The ``IN GROUP`` clause specifies the ID or the name of the - workspace group in which the Stage is attached. + * The ``IN`` clause specifies the ID or the name of the + deployment in which the Stage is attached. Example -------- The following command deletes a file from a Stage attached to - a workspace group named **wsg1**:: + a deployment named **wsg1**:: - DROP STAGE FILE '/data/stats.csv' IN GROUP 'wsg1'; + DROP STAGE FILE '/data/stats.csv' IN 'wsg1'; See Also -------- @@ -357,7 +365,7 @@ class DropStageFileHandler(SQLHandler): """ # noqa: E501 def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]: - wg = get_workspace_group(params) + wg = get_deployment(params) wg.stage.remove(params['stage_path']) return None @@ -368,20 +376,22 @@ def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]: class DropStageFolderHandler(SQLHandler): """ DROP STAGE FOLDER stage_path - [ in_group ] + [ in ] [ recursive ]; # Path to stage folder stage_path = '' - # Workspace group - in_group = IN GROUP { group_id | group_name } + # Deployment + in = { in_group | in_deployment } + in_group = IN GROUP { deployment_id | deployment_name } + in_deployment = IN { deployment_id | deployment_name } - # ID of group - group_id = ID '' + # ID of deployment + deployment_id = ID '' - # Name of group - group_name = '' + # Name of deployment + deployment_name = '' # Should folers be deleted recursively? recursive = RECURSIVE @@ -395,11 +405,11 @@ class DropStageFolderHandler(SQLHandler): Arguments --------- - * ````: The path to the folder to delete in a Stage. - * ````: The ID of the workspace group in which the - Stage is attached. - * ````: The name of the workspace group in which the + * ````: The path to the folder to delete in a Stage. + * ````: The ID of the deployment in which the Stage is attached. + * ````: The name of the deployment in which + which the Stage is attached. Remarks ------- @@ -409,9 +419,9 @@ class DropStageFolderHandler(SQLHandler): Example ------- The following command recursively deletes a folder from a Stage - attached to a workspace group named **wsg1**:: + attached to a deployment named **wsg1**:: - DROP STAGE FOLDER '/data/' IN GROUP 'wsg1' RECURSIVE; + DROP STAGE FOLDER '/data/' IN 'wsg1' RECURSIVE; See Also -------- @@ -420,7 +430,7 @@ class DropStageFolderHandler(SQLHandler): """ # noqa: E501 def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]: - wg = get_workspace_group(params) + wg = get_deployment(params) if params['recursive']: wg.stage.removedirs(params['stage_path']) else: @@ -434,17 +444,19 @@ def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]: class CreateStageFolderHandler(SQLHandler): """ CREATE STAGE FOLDER stage_path - [ in_group ] + [ in ] [ overwrite ]; - # Workspace group - in_group = IN GROUP { group_id | group_name } + # Deployment + in = { in_group | in_deployment } + in_group = IN GROUP { deployment_id | deployment_name } + in_deployment = IN { deployment_id | deployment_name } - # ID of group - group_id = ID '' + # ID of deployment + deployment_id = ID '' - # Name of group - group_name = '' + # Name of deployment + deployment_name = '' # Path to stage folder stage_path = '' @@ -458,31 +470,31 @@ class CreateStageFolderHandler(SQLHandler): Arguments --------- - * ````: The path in a Stage where the folder + * ````: The path in a Stage where the folder is created. The path must end with a trailing slash (/). - * ````: The ID of the workspace group in which + * ````: The ID of the deployment in which the Stage is attached. - * ````: The name of the workspace group in + * ````: The name of the deployment in which which the Stage is attached. Remarks ------- * If the ``OVERWRITE`` clause is specified, any existing folder at the specified path is overwritten. - * The ``IN GROUP`` clause specifies the ID or the name of - the workspace group in which the Stage is attached. + * The ``IN`` clause specifies the ID or the name of + the deployment in which the Stage is attached. Example ------- The following command creates a folder in a Stage attached - to a workspace group named **wsg1**:: + to a deployment named **wsg1**:: - CREATE STAGE FOLDER `/data/csv/` IN GROUP 'wsg1'; + CREATE STAGE FOLDER `/data/csv/` IN 'wsg1'; """ def run(self, params: Dict[str, Any]) -> Optional[FusionSQLResult]: - wg = get_workspace_group(params) + wg = get_deployment(params) wg.stage.mkdir(params['stage_path'], overwrite=params['overwrite']) return None diff --git a/singlestoredb/fusion/handlers/utils.py b/singlestoredb/fusion/handlers/utils.py index b7b193ca..27ffc177 100644 --- a/singlestoredb/fusion/handlers/utils.py +++ b/singlestoredb/fusion/handlers/utils.py @@ -4,9 +4,11 @@ from typing import Any from typing import Dict from typing import Optional +from typing import Union from ...exceptions import ManagementError from ...management import manage_workspaces +from ...management.workspace import StarterWorkspace from ...management.workspace import Workspace from ...management.workspace import WorkspaceGroup from ...management.workspace import WorkspaceManager @@ -160,3 +162,111 @@ def get_workspace(params: Dict[str, Any]) -> Workspace: raise ValueError('clusters and shared workspaces are not currently supported') raise KeyError('no workspace was specified') + + +def get_deployment( + params: Dict[str, Any], +) -> Union[WorkspaceGroup, StarterWorkspace]: + """ + Find a starter workspace matching deployment_id or deployment_name. + + This function will get a starter workspace or ID from the + following parameters: + + * params['deployment_name'] + * params['deployment_id'] + * params['group']['deployment_name'] + * params['group']['deployment_id'] + * params['in_deployment']['deployment_name'] + * params['in_deployment']['deployment_id'] + + Or, from the SINGLESTOREDB_WORKSPACE_GROUP + or SINGLESTOREDB_CLUSTER environment variables. + + """ + manager = get_workspace_manager() + + deployment_name = params.get('deployment_name') or \ + (params.get('in_deployment') or {}).get('deployment_name') or \ + (params.get('group') or {}).get('deployment_name') + if deployment_name: + workspace_groups = [ + x for x in manager.workspace_groups + if x.name == deployment_name + ] + + starter_workspaces = [] + if not workspace_groups: + filtered_starter_workspaces = [ + x for x in manager.starter_workspaces + if x.name == deployment_name + ] + + if not filtered_starter_workspaces: + raise KeyError( + f'no deployment found with name: {deployment_name}', + ) + + starter_workspaces = filtered_starter_workspaces + + if len(workspace_groups) > 1: + ids = ', '.join(x.id for x in workspace_groups) + raise ValueError( + f'more than one workspace group with given name was found: {ids}', + ) + + if len(starter_workspaces) > 1: + ids = ', '.join(x.id for x in starter_workspaces) + raise ValueError( + f'more than one starter workspace with given name was found: {ids}', + ) + + if workspace_groups: + return workspace_groups[0] + else: + return starter_workspaces[0] + + deployment_id = params.get('deployment_id') or \ + (params.get('in_deployment') or {}).get('deployment_id') or \ + (params.get('group') or {}).get('deployment_id') + if deployment_id: + try: + return manager.get_workspace_group(deployment_id) + except ManagementError as exc: + if exc.errno == 404: + try: + return manager.get_starter_workspace(deployment_id) + except ManagementError as exc: + if exc.errno == 404: + raise KeyError(f'no deployment found with ID: {deployment_id}') + raise + else: + raise + + if os.environ.get('SINGLESTOREDB_WORKSPACE_GROUP'): + try: + return manager.get_workspace_group( + os.environ['SINGLESTOREDB_WORKSPACE_GROUP'], + ) + except ManagementError as exc: + if exc.errno == 404: + raise KeyError( + 'no workspace found with ID: ' + f'{os.environ["SINGLESTOREDB_WORKSPACE_GROUP"]}', + ) + raise + + if os.environ.get('SINGLESTOREDB_CLUSTER'): + try: + return manager.get_starter_workspace( + os.environ['SINGLESTOREDB_CLUSTER'], + ) + except ManagementError as exc: + if exc.errno == 404: + raise KeyError( + 'no starter workspace found with ID: ' + f'{os.environ["SINGLESTOREDB_CLUSTER"]}', + ) + raise + + raise KeyError('no deployment was specified') diff --git a/singlestoredb/management/workspace.py b/singlestoredb/management/workspace.py index 0e06dbab..7ff68628 100644 --- a/singlestoredb/management/workspace.py +++ b/singlestoredb/management/workspace.py @@ -406,12 +406,12 @@ class Stage(object): Stage manager. This object is not instantiated directly. - It is returned by ``WorkspaceGroup.stage``. + It is returned by ``WorkspaceGroup.stage`` or ``StarterWorkspace.stage``. """ - def __init__(self, workspace_group: WorkspaceGroup, manager: WorkspaceManager): - self._workspace_group = workspace_group + def __init__(self, deployment_id: str, manager: WorkspaceManager): + self._deployment_id = deployment_id self._manager = manager def open( @@ -593,7 +593,7 @@ def _upload( self.remove(stage_path) self._manager._put( - f'stage/{self._workspace_group.id}/fs/{stage_path}', + f'stage/{self._deployment_id}/fs/{stage_path}', files={'file': content}, headers={'Content-Type': None}, ) @@ -625,7 +625,7 @@ def mkdir(self, stage_path: PathLike, overwrite: bool = False) -> StageObject: self.remove(stage_path) self._manager._put( - f'stage/{self._workspace_group.id}/fs/{stage_path}?isFile=false', + f'stage/{self._deployment_id}/fs/{stage_path}?isFile=false', ) return self.info(stage_path) @@ -668,7 +668,7 @@ def rename( self.remove(new_path) self._manager._patch( - f'stage/{self._workspace_group.id}/fs/{old_path}', + f'stage/{self._deployment_id}/fs/{old_path}', json=dict(newPath=new_path), ) @@ -689,7 +689,7 @@ def info(self, stage_path: PathLike) -> StageObject: """ res = self._manager._get( - re.sub(r'/+$', r'/', f'stage/{self._workspace_group.id}/fs/{stage_path}'), + re.sub(r'/+$', r'/', f'stage/{self._deployment_id}/fs/{stage_path}'), params=dict(metadata=1), ).json() @@ -772,7 +772,7 @@ def _listdir(self, stage_path: PathLike, *, recursive: bool = False) -> List[str """ res = self._manager._get( - f'stage/{self._workspace_group.id}/fs/{stage_path}', + f'stage/{self._deployment_id}/fs/{stage_path}', ).json() if recursive: out = [] @@ -848,7 +848,7 @@ def download_file( raise IsADirectoryError(f'stage path is a directory: {stage_path}') out = self._manager._get( - f'stage/{self._workspace_group.id}/fs/{stage_path}', + f'stage/{self._deployment_id}/fs/{stage_path}', ).content if local_path is not None: @@ -912,7 +912,7 @@ def remove(self, stage_path: PathLike) -> None: f'use rmdir or removedirs: {stage_path}', ) - self._manager._delete(f'stage/{self._workspace_group.id}/fs/{stage_path}') + self._manager._delete(f'stage/{self._deployment_id}/fs/{stage_path}') def removedirs(self, stage_path: PathLike) -> None: """ @@ -925,7 +925,7 @@ def removedirs(self, stage_path: PathLike) -> None: """ stage_path = re.sub(r'/*$', r'', str(stage_path)) + '/' - self._manager._delete(f'stage/{self._workspace_group.id}/fs/{stage_path}') + self._manager._delete(f'stage/{self._deployment_id}/fs/{stage_path}') def rmdir(self, stage_path: PathLike) -> None: """ @@ -942,7 +942,7 @@ def rmdir(self, stage_path: PathLike) -> None: if self.listdir(stage_path): raise OSError(f'stage folder is not empty, use removedirs: {stage_path}') - self._manager._delete(f'stage/{self._workspace_group.id}/fs/{stage_path}') + self._manager._delete(f'stage/{self._deployment_id}/fs/{stage_path}') def __str__(self) -> str: """Return string representation.""" @@ -1411,7 +1411,7 @@ def stage(self) -> Stage: raise ManagementError( msg='No workspace manager is associated with this object.', ) - return Stage(self, self._manager) + return Stage(self.id, self._manager) stages = stage @@ -1598,6 +1598,104 @@ def workspaces(self) -> NamedList[Workspace]: ) +class StarterWorkspace(object): + """ + SingleStoreDB starter workspace definition. + + This object is not instantiated directly. It is used in the results + of API calls on the :class:`WorkspaceManager`. Existing starter workspaces are + accessed by either :attr:`WorkspaceManager.starter_workspaces` or by calling + :meth:`WorkspaceManager.get_starter_workspace`. + + See Also + -------- + :meth:`WorkspaceManager.get_starter_workspace` + :attr:`WorkspaceManager.starter_workspaces` + + """ + + name: str + id: str + + def __init__( + self, + name: str, + id: str, + ): + #: Name of the starter workspace + self.name = name + + #: Unique ID of the starter workspace + self.id = id + + self._manager: Optional[WorkspaceManager] = None + + def __str__(self) -> str: + """Return string representation.""" + return vars_to_str(self) + + def __repr__(self) -> str: + """Return string representation.""" + return str(self) + + @classmethod + def from_dict( + cls, obj: Dict[str, Any], manager: 'WorkspaceManager', + ) -> 'StarterWorkspace': + """ + Construct a StarterWorkspace from a dictionary of values. + + Parameters + ---------- + obj : dict + Dictionary of values + manager : WorkspaceManager, optional + The WorkspaceManager the StarterWorkspace belongs to + + Returns + ------- + :class:`StarterWorkspace` + + """ + out = cls( + name=obj['name'], + id=obj['virtualWorkspaceID'], + ) + out._manager = manager + return out + + @property + def organization(self) -> Organization: + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + return self._manager.organization + + @property + def stage(self) -> Stage: + """Stage manager.""" + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + return Stage(self.id, self._manager) + + stages = stage + + @property + def starter_workspaces(self) -> NamedList[StarterWorkspace]: + """Return a list of available starter workspaces.""" + if self._manager is None: + raise ManagementError( + msg='No workspace manager is associated with this object.', + ) + res = self._manager._get('sharedtier/virtualWorkspaces') + return NamedList( + [StarterWorkspace.from_dict(item, self._manager) for item in res.json()], + ) + + class Billing(object): """Billing information.""" @@ -1705,6 +1803,12 @@ def workspace_groups(self) -> NamedList[WorkspaceGroup]: res = self._get('workspaceGroups') return NamedList([WorkspaceGroup.from_dict(item, self) for item in res.json()]) + @property + def starter_workspaces(self) -> NamedList[StarterWorkspace]: + """Return a list of available starter workspaces.""" + res = self._get('sharedtier/virtualWorkspaces') + return NamedList([StarterWorkspace.from_dict(item, self) for item in res.json()]) + @property def organizations(self) -> Organizations: """Return the organizations.""" @@ -1904,6 +2008,23 @@ def get_workspace(self, id: str) -> Workspace: res = self._get(f'workspaces/{id}') return Workspace.from_dict(res.json(), manager=self) + def get_starter_workspace(self, id: str) -> StarterWorkspace: + """ + Retrieve a starter workspace definition. + + Parameters + ---------- + id : str + ID of the starter workspace + + Returns + ------- + :class:`StarterWorkspace` + + """ + res = self._get(f'sharedtier/virtualWorkspaces/{id}') + return StarterWorkspace.from_dict(res.json(), manager=self) + def manage_workspaces( access_token: Optional[str] = None,