diff --git a/docs/USER_GROUP_MGMT.md b/docs/USER_GROUP_MGMT.md index 8a19865..6055129 100644 --- a/docs/USER_GROUP_MGMT.md +++ b/docs/USER_GROUP_MGMT.md @@ -1,45 +1,53 @@ # GD User Group Management + This tool facilitates the management of user groups within a GoodData organization. It supports the creation, updating, and deletion of user groups, including the assignment of parent user groups as defined in the input details. ## Usage The tool requires the following argument: + - `user_group_csv` - a path to a CSV file that defines the user groups, their names, parent user groups, and active status. Optional arguments include: + - `-d | --delimiter` - column delimiter for the CSV files. This defines how the CSV is parsed. The default value is "`,`". - `-u | --ug_delimiter` - delimiter used to separate different parent user groups within the parent user group column. This must differ from the "delimiter" argument. Default value is "`|`". - `-q | --quotechar` - quotation character used to escape special characters (such as the delimiter) within the column values. The 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`). Use the tool like so: + ```sh python scripts/user_group_mgmt.py user_group_csv ``` + Where `user_group_csv` refers to the input CSV file. For custom delimiters, use the command: + ```sh python scripts/user_group_mgmt.py user_group_csv -d "," -u "|" ``` To display help for using arguments, run: + ```sh python scripts/user_group_mgmt.py -h ``` ## Input CSV File (`user_group_csv`) + The input CSV file defines the user groups to be managed. User groups not defined in the input file will not be modified. [Example input CSV.](examples/user_group_mgmt/input.csv) Expected CSV format: -| user_group_id | user_group_name | parent_user_groups | is_active | -|----------------|------------------|--------------------|-----------| -| ug_1 | Admins | | True | -| ug_2 | Developers | ug_1 | True | -| ug_3 | Testers | ug_1, ug_2 | True | -| ug_4 | TemporaryAccess | ug_2 | False | +| user_group_id | user_group_name | parent_user_groups | is_active | +| ------------- | --------------- | ------------------ | --------- | +| ug_1 | Admins | | True | +| ug_2 | Developers | ug_1 | True | +| ug_3 | Testers | ug_1, ug_2 | True | +| ug_4 | TemporaryAccess | ug_2 | False | Here, each `user_group_id` is the unique identifier for the user group. @@ -47,6 +55,4 @@ The `user_group_name` field is an optional name for the user group, defaulting t The `parent_user_groups` field specifies the parent user groups, defining hierarchical relationships. -The `is_active` field contains information about whether the user group should exist or be deleted from the organization. The `is_active` field is case-insensitive, recognizing `true` as the only affirmative value. Any other value is considered negative (e.g., `no` would evaluate to `False`). - -This documentation provides a comprehensive guide to using the GD User Group Management tool effectively within your GoodData organization. \ No newline at end of file +The `is_active` field holds boolean values containing information about whether the user group should exist or be deleted from the organization. diff --git a/docs/USER_MGMT.md b/docs/USER_MGMT.md index ee848f8..28f1b2e 100644 --- a/docs/USER_MGMT.md +++ b/docs/USER_MGMT.md @@ -1,4 +1,5 @@ # GD User Management + Tool which helps manage user entities in an GoodData organization. Users can be created, updated, and deleted. This includes creation of any new userGroups which would be provided in user details. @@ -6,30 +7,37 @@ Users can be created, updated, and deleted. This includes creation of any new us ## Usage The tool requires the following argument on input: + - `user_csv` - a path to a csv file defining user entities, their relevant attributes, userGroup memberships, 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 "`,`" - `-u | --ug_delimiter` - userGroups column value delimiter. Use this to separate the different userGroups defined in the userGroup column. Default value is "`|`". Note that `--delimiter` and `--ug_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`). Use the tool like so: + ```sh python scripts/user_mgmt.py user_csv ``` + Where `user_csv` refers to input csv. If you would like to define custom delimiters, use the tool like so: + ```sh python scripts/user_mgmt.py user_csv -d "," -u "|" ``` To show the help for using arguments, call: + ```sh python scripts/user_mgmt.py -h ``` ## Input CSV file (user_csv) + The input CSV file defines the user entities which you might want to manage. Note that GD organization users that are not defined in the input will not be modified in any way. [Example input csv.](examples/user_mgmt/input.csv) @@ -37,7 +45,7 @@ The input CSV file defines the user entities which you might want to manage. Not Following format of the csv is expected: | user_id | firstname | lastname | email | auth_id | user_groups | is_active | -|----------------------|-----------|----------|-------------------------|-----------|-------------|-----------| +| -------------------- | --------- | -------- | ----------------------- | --------- | ----------- | --------- | | jozef.mrkva | jozef | mrkva | jozef.mrkva@test.com | auth_id_1 | | True | | bartolomej.brokolica | | | | | | False | | peter.pertzlen | peter | pertzlen | peter.pertzlen@test.com | auth_id_3 | ug_1, ug_2 | True | @@ -52,4 +60,4 @@ The `firstname`, `lastname`, `email`, and `auth_id` fields are optional attribut The `user_groups` field specifies user group memberships of the user. -Lastly, the `is_active` field contains information about whether the user should or should not exist in the organization. The `is_active` field is case-insensitive and considers `true` as the only value taken as positive. Any other value in this field is considered negative (e.g.: `blabla` would evaluate to `False`). +Lastly, the `is_active` field holds boolean values containing information about whether the user should or should not exist in the organization. diff --git a/scripts/user_group_mgmt.py b/scripts/user_group_mgmt.py index cb754a5..278b0af 100644 --- a/scripts/user_group_mgmt.py +++ b/scripts/user_group_mgmt.py @@ -11,54 +11,27 @@ # 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 csv import logging import os import re -import sys - -from dataclasses import dataclass, field from pathlib import Path -from typing import Any -from gooddata_sdk import GoodDataSdk -from gooddata_sdk.catalog.user.entity_model.user import CatalogUserGroup +from gooddata_pipelines import ( + UserGroupIncrementalLoad, + UserGroupProvisioner, +) +from gooddata_sdk.utils import PROFILES_FILE_PATH +from utils.logger import 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}$" -PROFILES_FILE = "profiles.yaml" -PROFILES_DIRECTORY = ".gooddata" -PROFILES_FILE_PATH = Path.home() / PROFILES_DIRECTORY / PROFILES_FILE -LOG_FORMAT = "%(asctime)s [%(levelname)s] %(message)s" +setup_logging() logger = logging.getLogger(__name__) -handler = logging.StreamHandler(sys.stdout) -handler.setFormatter(logging.Formatter(fmt=LOG_FORMAT)) -logger.addHandler(handler) -logger.setLevel(logging.INFO) - - -# TODO - simplify after complete switch to SDK -def create_clients(args: argparse.Namespace) -> GoodDataSdk: - """Creates GoodData SDK client.""" - gdc_auth_token = os.environ.get("GDC_AUTH_TOKEN") - gdc_hostname = os.environ.get("GDC_HOSTNAME") - - if gdc_hostname and gdc_auth_token: - logger.info("Using GDC_HOSTNAME and GDC_AUTH_TOKEN envvars.") - return GoodDataSdk.create(gdc_hostname, gdc_auth_token) - - profile_config, profile = args.profile_config, args.profile - if os.path.exists(profile_config): - logger.info( - f"Using GoodData profile {profile} " f"sourced from {profile_config}." - ) - return GoodDataSdk.create_from_profile(profile, profile_config) - - raise RuntimeError( - "No GoodData credentials provided. Please export required ENVVARS " - "(GDC_HOSTNAME, GDC_AUTH_TOKEN) or provide path to GD profile config." - ) def create_parser() -> argparse.ArgumentParser: @@ -135,201 +108,34 @@ def validate_args(args: argparse.Namespace) -> None: raise RuntimeError("The quotechar argument must be exactly one character long.") -@dataclass -class TargetUserGroup: - user_group_id: str - user_group_name: str - parent_user_groups: list[str] - is_active: bool = field(compare=False) - - @classmethod - def from_csv_row(cls, row: list[Any], parent_user_group_delimiter: str = ","): - """Creates GDUserGroupTarget from csv row.""" - user_group_id, user_group_name, parent_user_groups, is_active = row - user_group_name_or_id = user_group_name or user_group_id - parent_user_groups = ( - parent_user_groups.split(parent_user_group_delimiter) - if parent_user_groups - else [] - ) - return TargetUserGroup( - user_group_id=user_group_id, - user_group_name=user_group_name_or_id, - parent_user_groups=parent_user_groups, - is_active=str(is_active).lower() == "true", - ) - - -def read_users_groups_from_csv(args: argparse.Namespace) -> list[TargetUserGroup]: +def read_users_groups_from_csv( + args: argparse.Namespace, +) -> list[UserGroupIncrementalLoad]: """Reads users from csv file.""" - # TODO - handling of csv files with and without headers - user_groups: list[TargetUserGroup] = [] - with open(args.user_group_csv, "r") as f: - reader = csv.reader( - f, delimiter=args.delimiter, quotechar=args.quotechar, skipinitialspace=True - ) - next(reader) # Skip header - for row in reader: - if not csv_row_is_valid(row): - continue - try: - user_group = TargetUserGroup.from_csv_row(row, args.ug_delimiter) - except Exception as e: - logger.error(f'Unable to load following row: "{row}". Error: "{e}"') - continue - user_groups.append(user_group) - - return user_groups - - -def csv_row_is_valid(row: list[Any]) -> bool: - """Validates csv row.""" - try: - user_group_id, user_group_name, parent_user_group, is_active = row - except ValueError as e: - logger.error( - "Unable to parse csv row. " - "Most probably an incorrect amount of values was defined. " - f'Skipping following row: "{row}". Error: "{e}".' - ) - return False - - if not user_group_id: - logger.error( - f'user_group_id field seems to be empty. Skipping following row: "{row}".' - ) - return False - - if not is_active: - logger.error( - f'is_active field seems to be empty. Skipping following row: "{row}".' - ) - return False - - return True - - -class UserGroupManager: - def __init__( - self, client_sdk: GoodDataSdk, target_user_groups: list[TargetUserGroup] - ): - self.sdk = client_sdk - self.target_user_groups = target_user_groups - self.gd_user_groups = self._get_gd_user_groups() - - def _get_gd_user_groups(self) -> list[CatalogUserGroup]: - try: - return self.sdk.catalog_user.list_user_groups() - except Exception as e: - logger.error(f"Failed to list user groups from GoodData: {e}") - return [] + user_groups: list[UserGroupIncrementalLoad] = [] + raw_user_groups = read_csv_file_to_dict( + args.user_group_csv, args.delimiter, args.quotechar + ) + for raw_user_group in raw_user_groups: + processed_user_group = dict(raw_user_group) + parent_user_groups = raw_user_group["parent_user_groups"] - @staticmethod - def _is_changed(group: TargetUserGroup, existing_group: CatalogUserGroup) -> bool: - """Checks if user group has some changes and needs to be updated.""" - group.parent_user_groups.sort() - parents_changed = group.parent_user_groups != existing_group.get_parents - name_changed = group.user_group_name != existing_group.name - return parents_changed or name_changed + if parent_user_groups: + processed_user_group["parent_user_groups"] = parent_user_groups.split( + args.ug_delimiter + ) + else: + processed_user_group["parent_user_groups"] = [] - def _create_or_update_user_group( - self, group_id, group_name, parent_user_groups, action - ) -> None: - """Creates or updates user group in the project.""" - catalog_user_group = CatalogUserGroup.init( - user_group_id=group_id, - user_group_name=group_name, - user_group_parent_ids=parent_user_groups, - ) try: - self.sdk.catalog_user.create_or_update_user_group(catalog_user_group) - logger.info(f"Succeeded to {action} user group {group_id}") + user_group = UserGroupIncrementalLoad.model_validate(processed_user_group) + user_groups.append(user_group) except Exception as e: - if hasattr(e, "body") and e.body: - message = eval(e.body).get("detail", e) - else: - message = e.args[0] if e.args else str(e) - logger.error(f"Failed to {action} user group {group_id}: {message}") - - def _create_missing_user_groups(self, group_ids_to_create) -> None: - """Provisions user groups that don't exist.""" - groups_to_create = [ - group - for group in self.target_user_groups - if group.user_group_id in group_ids_to_create - ] - - for group in groups_to_create: - logger.info( - f'User group "{group.user_group_id}" does not exist, creating...' + logger.error( + f'Unable to load following row: "{raw_user_group}". Error: "{e}"' ) - self._create_or_update_user_group( - group.user_group_id, - group.user_group_name, - group.parent_user_groups, - "create", - ) - - def _update_existing_user_groups(self, group_ids_to_update) -> None: - """Update existing user groups and update ws_permissions.""" - groups_to_update = [ - group - for group in self.target_user_groups - if group.user_group_id in group_ids_to_update - ] - - existing_groups = {group.id: group for group in self.gd_user_groups} - - for group in groups_to_update: - existing_group = existing_groups[group.user_group_id] - if self._is_changed(group, existing_group): - logger.info(f"Updating user group {group.user_group_id}...") - self._create_or_update_user_group( - group.user_group_id, - group.user_group_name, - group.parent_user_groups, - "update", - ) - - def _delete_user_group(self, group_ids_to_delete) -> None: - """Deletes user group from the project.""" - for user_group_id in group_ids_to_delete: - try: - logger.info(f'Deleting user group"{user_group_id}"') - self.sdk.catalog_user.delete_user_group(user_group_id) - except Exception as e: - logger.error(f'Failed to deleted user group "{user_group_id}": {e}') - - def manage_user_groups(self) -> None: - """Manages multiple users groups based on the provided input.""" - - logger.info( - f"Starting user group management run of {len(self.target_user_groups)} user groups..." - ) - - gd_group_ids = {group.id for group in self.gd_user_groups} - - active_target_groups = { - group.user_group_id - for group in self.target_user_groups - if group.is_active is True - } - inactive_target_groups = { - group.user_group_id - for group in self.target_user_groups - if group.is_active is False - } - - group_ids_to_create = active_target_groups.difference(gd_group_ids) - self._create_missing_user_groups(group_ids_to_create) - - group_ids_to_update = active_target_groups.intersection(gd_group_ids) - self._update_existing_user_groups(group_ids_to_update) - - group_ids_to_delete = inactive_target_groups.intersection(gd_group_ids) - self._delete_user_group(group_ids_to_delete) - - logger.info("User group management run finished.") + continue + return user_groups def user_group_mgmt(args): @@ -339,10 +145,17 @@ def user_group_mgmt(args): try: validate_args(args) - client_sdk = create_clients(args) - target_user_groups = read_users_groups_from_csv(args) - user_group_manager = UserGroupManager(client_sdk, target_user_groups) - user_group_manager.manage_user_groups() + + provisioner = create_provisioner( + UserGroupProvisioner, args.profile_config, args.profile + ) + + provisioner.logger.subscribe(logger) + + validated_user_groups = read_users_groups_from_csv(args) + + provisioner.incremental_load(validated_user_groups) + except RuntimeError as e: logger.error(f"Runtime error has occurred: {e}") diff --git a/scripts/utils/utils.py b/scripts/utils/utils.py index 6774ef0..a953289 100644 --- a/scripts/utils/utils.py +++ b/scripts/utils/utils.py @@ -12,7 +12,9 @@ logger = logging.getLogger(__name__) -def read_csv_file_to_dict(file_path: str) -> list[dict[str, str]]: +def read_csv_file_to_dict( + file_path: str, delimiter: str = ",", quotechar: str = '"' +) -> list[dict[str, str]]: """Read a CSV file and return its content as a list of dictionaries. Args: @@ -22,7 +24,7 @@ def read_csv_file_to_dict(file_path: str) -> list[dict[str, str]]: a row in the CSV file, with keys as column headers and values as row values. """ with open(file_path, "r", encoding="utf-8") as file: - return list(csv.DictReader(file)) + return list(csv.DictReader(file, delimiter=delimiter, quotechar=quotechar)) def create_provisioner( diff --git a/tests/test_user_group_mgmt.py b/tests/test_user_group_mgmt.py index cdb7fa4..55aebc7 100644 --- a/tests/test_user_group_mgmt.py +++ b/tests/test_user_group_mgmt.py @@ -10,34 +10,26 @@ # # 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 os +import sys + +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../scripts")) +) + import argparse + import pytest -from unittest import mock -from dataclasses import dataclass +from gooddata_pipelines import UserGroupIncrementalLoad -from gooddata_sdk.catalog.user.entity_model.user import CatalogUserGroup from scripts import user_group_mgmt +from scripts.user_group_mgmt import read_users_groups_from_csv TEST_CSV_PATH = "tests/data/user_group_mgmt/input.csv" -@dataclass -class MockUserGroup: - id: str - name: str - parent_ids: list[str] - - def to_sdk(self): - return CatalogUserGroup.init( - user_group_id=self.id, - user_group_name=self.name, - user_group_parent_ids=self.parent_ids, - ) - - -@mock.patch("os.path.exists") -def test_conflicting_delimiters_raises_error(path_exists): - path_exists.return_value = True +def test_conflicting_delimiters_raises_error(monkeypatch): + monkeypatch.setattr("os.path.exists", lambda path: True) args = argparse.Namespace( user_group_csv="", delimiter=",", ug_delimiter=",", quotechar='"' ) @@ -45,92 +37,61 @@ def test_conflicting_delimiters_raises_error(path_exists): user_group_mgmt.validate_args(args) -def test_from_csv_row_standard(): - row = ["ug_1", "Admins", "ug_2|ug_3", "True"] - result = user_group_mgmt.TargetUserGroup.from_csv_row(row, "|") - expected = user_group_mgmt.TargetUserGroup( - user_group_id="ug_1", - user_group_name="Admins", - parent_user_groups=["ug_2", "ug_3"], - is_active=True, - ) - assert result == expected, "Standard row should be parsed correctly" - - -def test_from_csv_row_no_parent_groups(): - row = ["ug_2", "Developers", "", "True"] - result = user_group_mgmt.TargetUserGroup.from_csv_row(row, "|") - expected = user_group_mgmt.TargetUserGroup( - user_group_id="ug_2", - user_group_name="Developers", - parent_user_groups=[], - is_active=True, - ) - assert ( - result == expected - ), "Row without parent user groups should be parsed correctly" - - -def test_from_csv_row_fallback_name(): - row = ["ug_3", "", "", "False"] - result = user_group_mgmt.TargetUserGroup.from_csv_row(row, "|") - expected = user_group_mgmt.TargetUserGroup( - user_group_id="ug_3", - user_group_name="ug_3", - parent_user_groups=[], - is_active=False, - ) - assert result == expected, "Row with empty name should fallback to user group ID" - - -def test_from_csv_row_invalid_is_active(): - row = ["ug_4", "Testers", "ug_1", "not_a_boolean"] - result = user_group_mgmt.TargetUserGroup.from_csv_row(row, "|") - expected = user_group_mgmt.TargetUserGroup( - user_group_id="ug_4", - user_group_name="Testers", - parent_user_groups=["ug_1"], - is_active=False, - ) - assert result == expected, "Invalid 'is_active' value should default to False" - - -def prepare_sdk(): - def mock_list_user_groups(): - return [ - MockUserGroup("ug_1", "Admins", []).to_sdk(), - MockUserGroup("ug_4", "TemporaryAccess", ["ug_2"]).to_sdk(), - ] +@pytest.fixture +def mock_read_csv_file_to_dict(mocker): + """ + Fixture to mock read_csv_file_to_dict in scripts.user_group_mgmt. + """ - sdk = mock.Mock() - sdk.catalog_user.list_user_groups = mock_list_user_groups - return sdk - - -@mock.patch("scripts.user_group_mgmt.create_clients") -def test_user_group_mgmt_e2e(create_client): - sdk = prepare_sdk() - create_client.return_value = sdk + def _mock(return_value): + return mocker.patch( + "scripts.user_group_mgmt.read_csv_file_to_dict", + return_value=return_value, + ) - args = argparse.Namespace( - user_group_csv=TEST_CSV_PATH, - delimiter=",", - ug_delimiter="|", - quotechar='"', - verbose=False, + return _mock + + +@pytest.mark.parametrize( + "dict_row", + [ + { + "user_group_id": "ug_1", + "user_group_name": "Admins", + "parent_user_groups": "ug_2|ug_3", + "is_active": "True", + }, + { + "user_group_id": "ug_2", + "user_group_name": "Developers", + "parent_user_groups": "", + "is_active": "True", + }, + { + "user_group_id": "ug_3", + "user_group_name": "", + "parent_user_groups": "ug1", + "is_active": "False", + }, + ], +) +def test_from_csv_row_standard(mock_read_csv_file_to_dict, dict_row): + mock_read_csv_file_to_dict([dict_row]) + result = read_users_groups_from_csv( + argparse.Namespace( + user_group_csv="", delimiter=",", ug_delimiter="|", quotechar='"' + ) ) - - user_group_mgmt.user_group_mgmt(args) - - expected_create_or_update_calls = [ - mock.call(CatalogUserGroup.init("ug_2", "Developers", ["ug_1"])), - mock.call(CatalogUserGroup.init("ug_3", "Testers", ["ug_1", "ug_2"])), + expected = [ + UserGroupIncrementalLoad( + user_group_id=dict_row["user_group_id"], + user_group_name=dict_row["user_group_name"] or dict_row["user_group_id"], + parent_user_groups=( + dict_row["parent_user_groups"].split("|") + if dict_row["parent_user_groups"] + else [] + ), + is_active=dict_row["is_active"], + ) ] - sdk.catalog_user.create_or_update_user_group.assert_has_calls( - expected_create_or_update_calls, any_order=True - ) - - expected_delete_calls = [mock.call("ug_4")] - sdk.catalog_user.delete_user_group.assert_has_calls( - expected_delete_calls, any_order=True - ) + assert result == expected