diff --git a/docs/USER_DATA_FILTER_MGMT.md b/docs/USER_DATA_FILTER_MGMT.md new file mode 100644 index 0000000..d0b90a4 --- /dev/null +++ b/docs/USER_DATA_FILTER_MGMT.md @@ -0,0 +1,55 @@ +# GD User Data Filter Management + +Tool which helps manage User Data Filters in a GoodData organization. + +User Data Filters can be created, updated, and deleted based on CSV input. + +## Usage + +The tool requires the following arguments on input: + +- `filepath` - a path to a csv file defining user data filters, their values, and target workspace +- `ldm_column_name` - LDM column name +- `maql_column_name` - MAQL column name in the form `{attribute/dataset.field}` + +Some other, _optional_, arguments are: + +- `-d | --delimiter` - column delimiter for the csv files. Use this to define how the csv is parsed. Default value is `,` +- `-q | --quotechar` - quotation character used to escape special characters (such as the delimiter) within the column field value. Default value is `"` If you need to escape the quotechar itself, you have to embed it in quotechars and then double the quotation character (e.g.: `"some""string"` will yield `some"string`). +- `-p | --profile-config` - optional path to GoodData profile config. If no path is provided, the default profiles file is used. +- `--profile` - GoodData profile to use. If no profile is provided, `default` is used. + +Use the tool like so: + +```sh +python scripts/user_data_filter_mgmt.py path/to/udfs.csv ldm_column_name maql_column_name +``` + +If you would like to define custom delimiters, use the tool like so: + +```sh +python scripts/user_data_filter_mgmt.py path/to/udfs.csv ldm_column_name maql_column_name -d "," +``` + +To show the help for using arguments, call: + +```sh +python scripts/user_data_filter_mgmt.py -h +``` + +## Input CSV file + +The input CSV file defines the user data filter values to be managed. All user data filters in all workspaces listed in the input will be overwritten based on the CSV content. + +Following format of the csv is expected: + +| workspace_id | udf_id | udf_value | +| ------------------------- | --------- | --------- | +| workspace_with_wdf_values | user_id_1 | 1 | +| workspace_with_wdf_values | user_id_2 | 2 | + +Here, each `workspace_id` is the ID of the workspace where the user data filter applies. + +The `user_data_filter_id` identifies the specific User Data Filter you want to assign or update for the given workspace. Should be equal to the ID of the user the UDF is applied to. + +The `udf_value` field specifies the value to be set for that User Data Filter. diff --git a/docs/WORKSPACE_MGMT.md b/docs/WORKSPACE_MGMT.md new file mode 100644 index 0000000..77d2a03 --- /dev/null +++ b/docs/WORKSPACE_MGMT.md @@ -0,0 +1,58 @@ +# GD Workspace Management + +Tool which helps manage child workspace entities in an GoodData organization. + +Workspaces can be created, updated, and deleted. This includes applying Workspace Data Filter values, when provided in input. + +## Usage + +The tool requires the following argument on input: + +- `filepath` - a path to a csv file defining workspace entities, their relevant attributes, workspace data filter configuration, and isActive state + +Some other, _optional_, arguments are: + +- `-d | --delimiter` - column delimiter for the csv files. Use this to define how the csv is parsed. Default value is `,` +- `-i | --inner-delimiter` - Workspace Data Filter values column delimiter. Use this to separate the different values defined in the `workspace_data_filter_values` column. Default value is `|`. Note that `--delimiter` and `--inner_delimiter` have to differ. +- `-q | --quotechar` - quotation character used to escape special characters (such as the delimiter) within the column field value. Default value is `"` If you need to escape the quotechar itself, you have to embed it in quotechars and then double the quotation character (e.g.: `"some""string"` will yield `some"string`). +- `-p | --profile-config` - optional path to GoodData profile config. If no path is provided, the default profiles file is used. +- `--profile` - GoodData profile to use. If no profile is provided, `default` is used. + +Use the tool like so: + +```sh +python scripts/workspace_mgmt.py path/to/workspace_definitions.csv +``` + +If you would like to define custom delimiters, use the tool like so: + +```sh +python scripts/workspace_mgmt.py path/to/workspace_definitions.csv -d "," -i "|" +``` + +To show the help for using arguments, call: + +```sh +python scripts/workspace_mgmt.py -h +``` + +## Input CSV file + +The input CSV file defines the workspace entities which you might want to manage. Note that GD organization workspaces that are not defined in the input will not be modified in any way. + +Following format of the csv is expected: + +| parent_id | workspace_id | workspace_name | workspace_data_filter_id | workspace_data_filter_values | is_active | +| ------------------- | ---------------------------- | ---------------------------- | ------------------------ | ---------------------------- | --------- | +| parent_workspace_id | workspace_with_wdf_values | Workspace With WDF Values | wdf_id | 1|2|3 | true | +| parent_workspace_id | workspace_without_wdf_values | Workspace Without WDF Values | | | true | + +Here, each `workspace_id` is the ID of the workspace to manage. + +The `parent_id` specifies the parent workspace under which the workspace should be placed. + +The `workspace_name` field specifies the display name of the workspace. + +The `workspace_data_filter_id` and `workspace_data_filter_values` fields specify Workspace Data Filter configuration. Leave `workspace_data_filter_values` empty if no values should be set. + +Lastly, the `is_active` field holds boolean values containing information about whether the workspace should or should not exist in the organization. diff --git a/requirements.txt b/requirements.txt index dd192b2..80ca7ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # GoodData Python SDK packages -gooddata_sdk>=1.51 -gooddata-pipelines>=1.51 +gooddata_sdk>=1.52 +gooddata-pipelines>=1.52 # Other dependencies # TODO: remove after full transition to GoodData SDK packages diff --git a/scripts/backup.py b/scripts/backup.py index 35ded04..371e566 100644 --- a/scripts/backup.py +++ b/scripts/backup.py @@ -534,7 +534,7 @@ def main(args: argparse.Namespace) -> None: process_batches_in_parallel(sdk, api, org_id, storage, batches) -if __name__ == "__main__": +def backup(): parser: argparse.ArgumentParser = create_parser() args: argparse.Namespace = parser.parse_args() @@ -545,3 +545,7 @@ def main(args: argparse.Namespace) -> None: logger.info("Backup completed!") except Exception as e: logger.error(f"Backup failed: {e}") + + +if __name__ == "__main__": + backup() diff --git a/scripts/custom_fields.py b/scripts/custom_fields.py index 0961a48..5af43b7 100644 --- a/scripts/custom_fields.py +++ b/scripts/custom_fields.py @@ -11,14 +11,14 @@ from custom_fields.custom_field_manager import ( # type: ignore[import] CustomFieldManager, ) +from utils.logger import get_logger, setup_logging # type: ignore[import] from utils.utils import read_csv_file_to_dict # type: ignore[import] +setup_logging() +logger = get_logger(__name__) -def main( - path_to_custom_datasets_csv: str, - path_to_custom_fields_csv: str, - check_relations: bool, -) -> None: + +def custome_fields() -> None: """Main function to run the custom fields script.""" # Get host and token from environment variables # TODO: add option to load credentials from profile @@ -26,6 +26,11 @@ def main( host = os.environ.get("GDC_HOSTNAME") token = os.environ.get("GDC_AUTH_TOKEN") + args: argparse.Namespace = parse_args() + path_to_custom_datasets_csv = args.path_to_custom_datasets_csv + path_to_custom_fields_csv = args.path_to_custom_fields_csv + check_relations: bool = args.check_relations + if not host: raise ValueError("GDC_HOSTNAME environment variable is not set.") if not token: @@ -72,8 +77,4 @@ def parse_args(): if __name__ == "__main__": - args: argparse.Namespace = parse_args() - path_to_custom_datasets_csv = args.path_to_custom_datasets_csv - path_to_custom_fields_csv = args.path_to_custom_fields_csv - check_relations: bool = args.check_relations - main(path_to_custom_datasets_csv, path_to_custom_fields_csv, check_relations) + custome_fields() diff --git a/scripts/permission_mgmt.py b/scripts/permission_mgmt.py index c4f97a6..cd93676 100644 --- a/scripts/permission_mgmt.py +++ b/scripts/permission_mgmt.py @@ -1,6 +1,5 @@ # (C) 2025 GoodData Corporation import argparse -import logging import os from pathlib import Path @@ -10,18 +9,18 @@ PermissionProvisioner, ) from gooddata_sdk.utils import PROFILES_FILE_PATH -from utils.logger import setup_logging # type: ignore[import] +from utils.logger import get_logger, setup_logging # type: ignore[import] from utils.utils import ( # type: ignore[import] create_provisioner, read_csv_file_to_dict, ) +setup_logging() +logger = get_logger(__name__) + def create_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Management of workspace permissions.") - parser.add_argument( - "-v", "--verbose", action="store_true", help="Turns on the debug log output." - ) parser.add_argument( "perm_csv", type=Path, @@ -109,13 +108,10 @@ def validate_args(args: argparse.Namespace) -> None: ) -if __name__ == "__main__": +def permission_mgmt(): parser = create_parser() args = parser.parse_args() - setup_logging(args.verbose) - logger = logging.getLogger(__name__) - permissions = read_permissions_from_csv(args) permission_manager = create_provisioner( @@ -125,3 +121,7 @@ def validate_args(args: argparse.Namespace) -> None: permission_manager.logger.subscribe(logger) permission_manager.incremental_load(permissions) + + +if __name__ == "__main__": + permission_mgmt() diff --git a/scripts/restore.py b/scripts/restore.py index 73e6db1..83d5f92 100644 --- a/scripts/restore.py +++ b/scripts/restore.py @@ -591,17 +591,25 @@ def create_client(args: argparse.Namespace) -> tuple[GoodDataSdk, GDApi]: ) -def main(args): - """Main entry point of the script.""" - if args.verbose: - logger.setLevel(logging.DEBUG) - +def validate_args(args: argparse.Namespace) -> None: + """Validates the arguments provided.""" if not os.path.exists(args.ws_csv): raise RuntimeError("Invalid path to csv given.") if not os.path.exists(args.conf): raise RuntimeError("Invalid path to backup storage configuration given.") + +def restore(): + """Main entry point of the script.""" + + parser = create_parser() + args = parser.parse_args() + validate_args(args) + + if args.verbose: + logger.setLevel(logging.DEBUG) + sdk, api = create_client(args) conf = BackupRestoreConfig(args.conf) @@ -619,6 +627,4 @@ def main(args): if __name__ == "__main__": - parser = create_parser() - args = parser.parse_args() - main(args) + restore() diff --git a/scripts/user_data_filter_mgmt.py b/scripts/user_data_filter_mgmt.py new file mode 100644 index 0000000..1ca2a71 --- /dev/null +++ b/scripts/user_data_filter_mgmt.py @@ -0,0 +1,127 @@ +# (C) 2025 GoodData Corporation +import argparse +import os +from pathlib import Path +from typing import Any + +from gooddata_pipelines import UserDataFilterFullLoad, UserDataFilterProvisioner +from gooddata_sdk.utils import PROFILES_FILE_PATH +from utils.logger import get_logger, setup_logging # type: ignore[import] +from utils.utils import ( # type: ignore[import] + create_provisioner, + read_csv_file_to_dict, +) + +# Setup logging +setup_logging() +logger = get_logger(__name__) + + +def create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Management of workspaces.") + parser.add_argument( + "filepath", + type=Path, + help="Path to csv with input data.", + ) + parser.add_argument( + "ldm_column_name", + type=str, + help="LDM column name.", + ) + parser.add_argument( + "maql_column_name", + type=str, + help="MAQL column name: {attribute/dataset.field}", + ) + parser.add_argument( + "-d", + "--delimiter", + type=str, + default=",", + help="Delimiter used to separate different columns in the workspace_csv.", + ) + parser.add_argument( + "-q", + "--quotechar", + type=str, + default='"', + help=( + "Character used for quoting (escaping) values " + "which contain delimiters or quotechars." + ), + ) + parser.add_argument( + "-p", + "--profile-config", + type=Path, + default=PROFILES_FILE_PATH, + help="Optional path to GoodData profile config. " + f'If no path is provided, "{PROFILES_FILE_PATH}" is used.', + ) + parser.add_argument( + "--profile", + type=str, + default="default", + help='GoodData profile to use. If no profile is provided, "default" is used.', + ) + return parser + + +def validate_args(args: argparse.Namespace) -> None: + """Validates the input arguments.""" + if not os.path.exists(args.filepath): + raise RuntimeError("Invalid path to input csv given.") + + +def validate_user_data_filter_data( + raw_user_data_filters: list[dict[str, Any]], +) -> list[UserDataFilterFullLoad]: + """Validate workspace against input model.""" + validated_user_data_filters: list[UserDataFilterFullLoad] = [] + for raw_user_data_filter in raw_user_data_filters: + validated_user_data_filter = UserDataFilterFullLoad( + workspace_id=raw_user_data_filter["workspace_id"], + udf_id=raw_user_data_filter["udf_id"], + udf_value=raw_user_data_filter["udf_value"], + ) + + validated_user_data_filters.append(validated_user_data_filter) + + return validated_user_data_filters + + +def udf_mgmt(): + """Main function for workspace management.""" + + # Create parser and parse arguments + parser = create_parser() + args = parser.parse_args() + + validate_args(args) + + # Read CSV input + raw_user_data_filters = read_csv_file_to_dict( + args.filepath, args.delimiter, args.quotechar + ) + + # Validate user data filter data + validated_user_data_filters = validate_user_data_filter_data(raw_user_data_filters) + + # Create provisioner and subscribe to logger + provisioner: UserDataFilterProvisioner = create_provisioner( + UserDataFilterProvisioner, args.profile_config, args.profile + ) + + provisioner.set_ldm_column_name(args.ldm_column_name) + provisioner.set_maql_column_name(args.maql_column_name) + + provisioner.logger.subscribe(logger) + + # Incremental load user data filters + provisioner.full_load(validated_user_data_filters) + + +if __name__ == "__main__": + # Main function + udf_mgmt() diff --git a/scripts/user_group_mgmt.py b/scripts/user_group_mgmt.py index 278b0af..7373941 100644 --- a/scripts/user_group_mgmt.py +++ b/scripts/user_group_mgmt.py @@ -11,7 +11,6 @@ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. import argparse -import logging import os import re from pathlib import Path @@ -21,25 +20,21 @@ UserGroupProvisioner, ) from gooddata_sdk.utils import PROFILES_FILE_PATH -from utils.logger import setup_logging # type: ignore[import] +from utils.logger import get_logger, setup_logging # type: ignore[import] from utils.utils import ( # type: ignore[import] create_provisioner, read_csv_file_to_dict, ) -UG_REGEX = r"^(?!\.)[.A-Za-z0-9_-]{1,255}$" - - setup_logging() -logger = logging.getLogger(__name__) +logger = get_logger(__name__) + +UG_REGEX = r"^(?!\.)[.A-Za-z0-9_-]{1,255}$" def create_parser() -> argparse.ArgumentParser: """Creates an argument parser.""" parser = argparse.ArgumentParser(description="Management of users and userGroups.") - parser.add_argument( - "-v", "--verbose", action="store_true", help="Turns on the debug log output." - ) parser.add_argument( "user_group_csv", type=Path, help="Path to csv with user groups definition." ) @@ -138,10 +133,11 @@ def read_users_groups_from_csv( return user_groups -def user_group_mgmt(args): +def user_group_mgmt(): """Main function for user management.""" - if args.verbose: - logger.setLevel(logging.DEBUG) + + parser = create_parser() + args = parser.parse_args() try: validate_args(args) @@ -161,6 +157,4 @@ def user_group_mgmt(args): if __name__ == "__main__": - parser = create_parser() - args = parser.parse_args() - user_group_mgmt(args) + user_group_mgmt() diff --git a/scripts/user_mgmt.py b/scripts/user_mgmt.py index d53567b..1378eb8 100644 --- a/scripts/user_mgmt.py +++ b/scripts/user_mgmt.py @@ -1,27 +1,23 @@ # (C) 2025 GoodData Corporation import argparse import csv -import logging import os import re from pathlib import Path from gooddata_pipelines import UserIncrementalLoad, UserProvisioner from gooddata_sdk.utils import PROFILES_FILE_PATH -from utils.logger import setup_logging # type: ignore[import] +from utils.logger import get_logger, setup_logging # type: ignore[import] from utils.utils import create_provisioner # type: ignore[import] -setup_logging() -logger = logging.getLogger(__name__) - UG_REGEX = r"^(?!\.)[.A-Za-z0-9_-]{1,255}$" +setup_logging() +logger = get_logger(__name__) + def create_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Management of users and userGroups.") - parser.add_argument( - "-v", "--verbose", action="store_true", help="Turns on the debug log output." - ) parser.add_argument( "user_csv", type=Path, help="Path to csv with user definitions." ) @@ -128,11 +124,11 @@ def validate_args(args: argparse.Namespace) -> None: raise RuntimeError("The quotechar argument must be exactly one character long.") -def user_mgmt(args: argparse.Namespace) -> None: +def user_mgmt() -> None: """Main function for user management.""" - if args.verbose: - logger.setLevel(logging.DEBUG) + parser = create_parser() + args = parser.parse_args() validate_args(args) users = read_users_from_csv( @@ -147,6 +143,4 @@ def user_mgmt(args: argparse.Namespace) -> None: if __name__ == "__main__": - parser = create_parser() - args = parser.parse_args() - user_mgmt(args) + user_mgmt() diff --git a/scripts/utils/logger.py b/scripts/utils/logger.py index 490f7ee..ffa1b03 100644 --- a/scripts/utils/logger.py +++ b/scripts/utils/logger.py @@ -72,3 +72,8 @@ def setup_logging(verbose: bool = False) -> None: root_logger.setLevel(min_level) root_logger.handlers.clear() root_logger.addHandler(LogHandler()) + + +def get_logger(name: str) -> logging.Logger: + """Returns a logger with the given name.""" + return logging.getLogger(name) diff --git a/scripts/workspace_mgmt.py b/scripts/workspace_mgmt.py new file mode 100644 index 0000000..2817b49 --- /dev/null +++ b/scripts/workspace_mgmt.py @@ -0,0 +1,148 @@ +# (C) 2025 GoodData Corporation +import argparse +import os +from pathlib import Path +from typing import Any + +from gooddata_pipelines import WorkspaceIncrementalLoad, WorkspaceProvisioner +from gooddata_sdk.utils import PROFILES_FILE_PATH +from utils.logger import get_logger, setup_logging # type: ignore[import] +from utils.utils import ( # type: ignore[import] + create_provisioner, + read_csv_file_to_dict, +) + +# Setup logging +setup_logging() +logger = get_logger(__name__) + + +def create_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Management of workspaces.") + parser.add_argument( + "filepath", + type=Path, + help="Path to CSV file with input data.", + ) + parser.add_argument( + "-d", + "--delimiter", + type=str, + default=",", + help="Delimiter used to separate different columns in the workspace_csv.", + ) + parser.add_argument( + "-i", + "--inner-delimiter", + type=str, + default="|", + help=( + "Delimiter used to separate different inner values within " + "the columns in the input csv which contain inner-delimiter separated values. " + 'This must differ from the "delimiter" argument.' + ), + ) + parser.add_argument( + "-q", + "--quotechar", + type=str, + default='"', + help=( + "Character used for quoting (escaping) values " + "which contain delimiters or quotechars." + ), + ) + parser.add_argument( + "-p", + "--profile-config", + type=Path, + default=PROFILES_FILE_PATH, + help="Optional path to GoodData profile config. " + f'If no path is provided, "{PROFILES_FILE_PATH}" is used.', + ) + parser.add_argument( + "--profile", + type=str, + default="default", + help='GoodData profile to use. If no profile is provided, "default" is used.', + ) + return parser + + +def validate_args(args: argparse.Namespace) -> None: + """Validates the input arguments.""" + if not os.path.exists(args.filepath): + raise RuntimeError("Invalid path to input csv given.") + + if args.delimiter == args.inner_delimiter: + raise RuntimeError( + "Delimiter and Workspace Data Filter Delimiter cannot be the same." + ) + + +def validate_workspace_data( + raw_workspaces: list[dict[str, Any]], + wdf_delimiter: str, +) -> list[WorkspaceIncrementalLoad]: + """Validate workspace against input model.""" + + validated_workspaces: list[WorkspaceIncrementalLoad] = [] + + for raw_workspace in raw_workspaces: + try: + if raw_workspace["workspace_data_filter_values"]: + workspace_data_filter_values = raw_workspace[ + "workspace_data_filter_values" + ].split(wdf_delimiter) + else: + workspace_data_filter_values = None + validated_workspace = WorkspaceIncrementalLoad( + parent_id=raw_workspace["parent_id"], + workspace_id=raw_workspace["workspace_id"], + workspace_name=raw_workspace["workspace_name"], + workspace_data_filter_id=raw_workspace["workspace_data_filter_id"], + workspace_data_filter_values=workspace_data_filter_values, + is_active=raw_workspace["is_active"], + ) + + validated_workspaces.append(validated_workspace) + except Exception as e: + logger.error( + f'Unable to load following row: "{raw_workspace}". Error: "{e}"' + ) + continue + + return validated_workspaces + + +def workspace_mgmt(): + """Main function for workspace management.""" + + # Create parser and parse arguments + parser = create_parser() + args = parser.parse_args() + + validate_args(args) + + # Read CSV input + raw_workspaces = read_csv_file_to_dict( + args.filepath, args.delimiter, args.quotechar + ) + + # Validate workspace data + validated_workspaces = validate_workspace_data(raw_workspaces, args.inner_delimiter) + + # Create provisioner and subscribe to logger + provisioner = create_provisioner( + WorkspaceProvisioner, args.profile_config, args.profile + ) + + provisioner.logger.subscribe(logger) + + # Incremental load workspaces + provisioner.incremental_load(validated_workspaces) + + +if __name__ == "__main__": + # Main function + workspace_mgmt() diff --git a/tests/test_restore.py b/tests/test_restore.py index b9c1159..4e39a04 100644 --- a/tests/test_restore.py +++ b/tests/test_restore.py @@ -125,7 +125,7 @@ def test_gd_client_no_creds_raises_error(): def test_bad_csv_path_raises_error(_, csv_path): args = argparse.Namespace(ws_csv=csv_path, verbose=False) with pytest.raises(RuntimeError): - restore.main(args) + restore.validate_args(args) @pytest.mark.parametrize("conf_path", ["", "bad/path"]) @@ -133,7 +133,7 @@ def test_bad_csv_path_raises_error(_, csv_path): def test_bad_conf_path_raises_error(_, conf_path): args = argparse.Namespace(conf=conf_path, ws_csv=".", verbose=False) with pytest.raises(RuntimeError): - restore.main(args) + restore.validate_args(args) def test_get_s3_storage(): @@ -457,8 +457,10 @@ def test_load_user_data_filters(): @mock.patch("scripts.restore.create_client") @mock.patch("scripts.restore.RestoreWorker._load_user_data_filters") @mock.patch("scripts.restore.zipfile") +@mock.patch("scripts.restore.create_parser") def test_e2e( - _, + create_parser, + zipfile_mock, _load_user_data_filters, create_client, create_backups_in_bucket, @@ -485,8 +487,13 @@ def test_e2e( create_backups_in_bucket(["ws_id_1", "ws_id_2"], is_e2e=True) + # Mock parser and its parse_args to return our args namespace + parser_mock = mock.Mock() + parser_mock.parse_args.return_value = args + create_parser.return_value = parser_mock + with mock.patch("scripts.restore.RestoreWorker._check_workspace_is_valid") as _: - restore.main(args) + restore.restore() assert_not_called_with( ws_catalog.put_declarative_ldm, "thiswsdoesnotexist", mock.ANY