From a82c1e154a40c2d9ad5ab639df47cdca8400ef12 Mon Sep 17 00:00:00 2001 From: Dhiren-Mhatre Date: Tue, 9 Sep 2025 01:02:05 +0530 Subject: [PATCH 1/3] feat:added profile functionality Signed-off-by: Dhiren-Mhatre --- gen3/cli/__main__.py | 48 +++++++++++++++-- gen3/cli/configure.py | 72 ++++++++++++++++++++++--- gen3/configure.py | 120 +++++++++++++++++++++++++++++++++++++++--- 3 files changed, 221 insertions(+), 19 deletions(-) diff --git a/gen3/cli/__main__.py b/gen3/cli/__main__.py index 5a51be11b..f81f205ec 100644 --- a/gen3/cli/__main__.py +++ b/gen3/cli/__main__.py @@ -21,15 +21,28 @@ class AuthFactory: - def __init__(self, refresh_file): + def __init__(self, refresh_file, profile_name=None): self.refresh_file = refresh_file + self.profile_name = profile_name self._cache = None def get(self): - """Lazy factory""" + """Lazy factory with profile support""" if self._cache: return self._cache - self._cache = gen3.auth.Gen3Auth(refresh_file=self.refresh_file) + + if self.profile_name and not self.refresh_file: + try: + import gen3.configure as config_tool + profile_creds = config_tool.get_profile_credentials(self.profile_name) + self._cache = gen3.auth.Gen3Auth( + endpoint=profile_creds['api_endpoint'], + refresh_token=profile_creds.get('access_token') + ) + except Exception as e: + raise ValueError(f"Error creating auth from profile '{self.profile_name}': {e}") + else: + self._cache = gen3.auth.Gen3Auth(refresh_file=self.refresh_file) return self._cache @@ -51,6 +64,12 @@ def get(self): default=os.getenv("GEN3_ENDPOINT", None), help="commons hostname - optional if API Key given in `auth`", ) +@click.option( + "--profile", + "profile_name", + default=os.getenv("GEN3_PROFILE", None), + help="Profile name to use for authentication (compatible with cdis-data-client profiles)", +) @click.option( "-v", "verbose_logs", @@ -91,6 +110,7 @@ def main( ctx, auth_config, endpoint, + profile_name, verbose_logs, very_verbose_logs, only_error_logs, @@ -99,10 +119,28 @@ def main( ): """Gen3 Command Line Interface""" ctx.ensure_object(dict) + + if profile_name and not auth_config: + try: + import gen3.configure as config_tool + profile_creds = config_tool.get_profile_credentials(profile_name) + import tempfile + import json + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump(profile_creds, f) + auth_config = f.name + ctx.obj["temp_cred_file"] = f.name + if not endpoint: + endpoint = profile_creds.get('api_endpoint') + except Exception as e: + click.echo(f"Error loading profile '{profile_name}': {e}") + ctx.exit(1) + ctx.obj["auth_config"] = auth_config ctx.obj["endpoint"] = endpoint ctx.obj["commons_url"] = commons_url - ctx.obj["auth_factory"] = AuthFactory(auth_config) + ctx.obj["profile_name"] = profile_name + ctx.obj["auth_factory"] = AuthFactory(auth_config, profile_name) if silent: # we still need to define the logger, the log_level here doesn't @@ -139,6 +177,8 @@ def main( main.add_command(wss.wss) main.add_command(discovery.discovery) main.add_command(configure.configure) +main.add_command(configure.list_profiles, name="list-profiles") +main.add_command(configure.show_profile, name="show-profile") main.add_command(objects.objects) main.add_command(drs_pull.drs_pull) main.add_command(file.file) diff --git a/gen3/cli/configure.py b/gen3/cli/configure.py index d6bf9bfad..4a0d08824 100644 --- a/gen3/cli/configure.py +++ b/gen3/cli/configure.py @@ -6,20 +6,76 @@ @click.command() -@click.option("--profile", help="name of the profile to name for this credentials") -@click.option("--cred", help="path to the credentials.json") -def configure(profile, cred): - """[unfinished] Commands to configure multiple profiles with corresponding credentials - - ./gen3 configure --profile= --cred= +@click.option("--profile", required=True, help="name of the profile to name for this credentials") +@click.option("--cred", help="path to the credentials.json file") +@click.option("--apiendpoint", help="API endpoint URL (optional if derivable from credentials)") +def configure(profile, cred, apiendpoint): + """Configure multiple profiles with corresponding credentials + + Compatible with cdis-data-client profile format. + + Examples: + ./gen3 configure --profile= --cred= + ./gen3 configure --profile= --cred= --apiendpoint=https://data.mycommons.org """ + if not cred: + click.echo("Error: --cred option is required") + return + logging.info(f"Configuring profile [ {profile} ] with credentials at {cred}") try: - profile_title, new_lines = config_tool.get_profile_from_creds(profile, cred) + profile_title, new_lines = config_tool.get_profile_from_creds( + profile, cred, apiendpoint + ) lines = config_tool.get_current_config_lines() config_tool.update_config_lines(lines, profile_title, new_lines) + + click.echo(f"Profile '{profile}' has been configured successfully.") + + profiles = config_tool.list_profiles() + if len(profiles) > 1: + click.echo(f"Available profiles: {', '.join(profiles)}") + + except Exception as e: + logging.error(str(e)) + click.echo(f"Error configuring profile: {str(e)}") + raise e + + +@click.command() +def list_profiles(): + """List all available profiles from both gen3sdk and cdis-data-client configs""" + try: + profiles = config_tool.list_profiles() + if profiles: + click.echo("Available profiles:") + for profile in profiles: + click.echo(f" - {profile}") + else: + click.echo("No profiles found. Use 'gen3 configure' to create one.") + except Exception as e: + click.echo(f"Error listing profiles: {str(e)}") + raise e + + +@click.command() +@click.option("--profile", required=True, help="Profile name to show details for") +def show_profile(profile): + """Show details for a specific profile""" + try: + profile_data = config_tool.parse_profile_from_config(profile) + if profile_data: + click.echo(f"Profile '{profile}' details:") + for key, value in profile_data.items(): + if key in ['api_key', 'access_key', 'access_token']: + masked_value = value[:8] + '...' if len(value) > 8 else '***' + click.echo(f" {key}: {masked_value}") + else: + click.echo(f" {key}: {value}") + else: + click.echo(f"Profile '{profile}' not found") except Exception as e: - logging.warning(str(e)) + click.echo(f"Error showing profile: {str(e)}") raise e diff --git a/gen3/configure.py b/gen3/configure.py index 5322cf6a4..8cac5219a 100644 --- a/gen3/configure.py +++ b/gen3/configure.py @@ -19,30 +19,50 @@ """ import json +import os from os.path import expanduser from pathlib import Path from collections import OrderedDict import gen3.auth as auth_tool +import configparser from cdislogging import get_logger logging = get_logger("__name__") CONFIG_FILE_PATH = expanduser("~/.gen3/config") +GEN3_CLIENT_CONFIG_PATH = expanduser("~/.gen3/gen3_client_config.ini") -def get_profile_from_creds(profile, cred): +def get_profile_from_creds(profile, cred, apiendpoint=None): + """Create profile configuration from credentials file with validation""" with open(expanduser(cred)) as f: creds_from_json = json.load(f) credentials = OrderedDict() + + if "key_id" not in creds_from_json: + raise ValueError("key_id not found in credentials file") + if "api_key" not in creds_from_json: + raise ValueError("api_key not found in credentials file") + credentials["key_id"] = creds_from_json["key_id"] credentials["api_key"] = creds_from_json["api_key"] - credentials["api_endpoint"] = auth_tool.endpoint_from_token( - credentials["api_key"] - ) - credentials["access_key"] = auth_tool.get_access_token_with_key(credentials) - credentials["use_shepherd"] = "" - credentials["min_shepherd_version"] = "" + + if apiendpoint: + if not apiendpoint.startswith(('http://', 'https://')): + raise ValueError("API endpoint must start with http:// or https://") + credentials["api_endpoint"] = apiendpoint.rstrip('/') + else: + credentials["api_endpoint"] = auth_tool.endpoint_from_token( + credentials["api_key"] + ) + + try: + credentials["access_key"] = auth_tool.get_access_token_with_key(credentials) + except Exception as e: + logging.warning(f"Could not validate credentials with endpoint: {e}") + credentials["access_key"] = "" + profile_line = "[" + profile + "]\n" new_lines = [key + "=" + value + "\n" for key, value in credentials.items()] new_lines.append("\n") # Adds an empty line between two profiles. @@ -76,3 +96,89 @@ def update_config_lines(lines, profile_title, new_lines): with open(CONFIG_FILE_PATH, "a+") as configFile: configFile.write(profile_title) configFile.writelines(new_lines) + + +def parse_profile_from_config(profile_name): + """Parse profile configuration from config file, checking both gen3sdk and cdis-data-client formats""" + + try: + with open(CONFIG_FILE_PATH, 'r') as f: + lines = f.readlines() + profile_line = f"[{profile_name}]\n" + if profile_line in lines: + profile_idx = lines.index(profile_line) + profile_data = {} + for i in range(profile_idx + 1, len(lines)): + if lines[i].startswith('[') or lines[i].strip() == '': + break + if '=' in lines[i]: + key, value = lines[i].strip().split('=', 1) + profile_data[key] = value + return profile_data + except FileNotFoundError: + pass + + try: + if os.path.exists(GEN3_CLIENT_CONFIG_PATH): + config = configparser.ConfigParser() + config.read(GEN3_CLIENT_CONFIG_PATH) + if profile_name in config: + profile_data = dict(config[profile_name]) + if 'access_token' in profile_data: + profile_data['access_key'] = profile_data['access_token'] + return profile_data + except Exception as e: + logging.warning(f"Error reading cdis-data-client config: {e}") + + return None + + +def list_profiles(): + """List all available profiles from both config files""" + profiles = set() + + try: + with open(CONFIG_FILE_PATH, 'r') as f: + lines = f.readlines() + for line in lines: + if line.startswith('[') and line.endswith(']\n'): + profile_name = line[1:-2] + profiles.add(profile_name) + except FileNotFoundError: + pass + + try: + if os.path.exists(GEN3_CLIENT_CONFIG_PATH): + config = configparser.ConfigParser() + config.read(GEN3_CLIENT_CONFIG_PATH) + for section in config.sections(): + profiles.add(section) + except Exception as e: + logging.warning(f"Error reading cdis-data-client config: {e}") + + return sorted(list(profiles)) + + +def get_profile_credentials(profile_name): + """Get credentials for a specific profile, compatible with both config formats""" + profile_data = parse_profile_from_config(profile_name) + if not profile_data: + raise ValueError(f"Profile '{profile_name}' not found in any config file") + + required_fields = ['key_id', 'api_key', 'api_endpoint'] + for field in required_fields: + if field not in profile_data or not profile_data[field]: + raise ValueError(f"Profile '{profile_name}' missing required field: {field}") + + credentials = { + 'key_id': profile_data['key_id'], + 'api_key': profile_data['api_key'], + 'api_endpoint': profile_data['api_endpoint'] + } + + if 'access_key' in profile_data: + credentials['access_token'] = profile_data['access_key'] + elif 'access_token' in profile_data: + credentials['access_token'] = profile_data['access_token'] + + return credentials From eb45de6cfac9f8eeffeecae3403deb15152c23cf Mon Sep 17 00:00:00 2001 From: Dhiren-Mhatre Date: Mon, 15 Sep 2025 22:52:39 +0530 Subject: [PATCH 2/3] addressed feedback Signed-off-by: Dhiren-Mhatre --- gen3/cli/__main__.py | 9 +-- gen3/cli/configure.py | 7 +- gen3/configure.py | 159 ++++++++++++++++------------------------ tests/test_configure.py | 49 ++++++------- 4 files changed, 91 insertions(+), 133 deletions(-) diff --git a/gen3/cli/__main__.py b/gen3/cli/__main__.py index f81f205ec..3f4520754 100644 --- a/gen3/cli/__main__.py +++ b/gen3/cli/__main__.py @@ -18,6 +18,9 @@ import gen3 from gen3 import logging as sdklogging from gen3.cli import nih +import gen3.configure as config_tool +import tempfile +import json class AuthFactory: @@ -33,7 +36,6 @@ def get(self): if self.profile_name and not self.refresh_file: try: - import gen3.configure as config_tool profile_creds = config_tool.get_profile_credentials(self.profile_name) self._cache = gen3.auth.Gen3Auth( endpoint=profile_creds['api_endpoint'], @@ -122,10 +124,7 @@ def main( if profile_name and not auth_config: try: - import gen3.configure as config_tool profile_creds = config_tool.get_profile_credentials(profile_name) - import tempfile - import json with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: json.dump(profile_creds, f) auth_config = f.name @@ -177,7 +176,7 @@ def main( main.add_command(wss.wss) main.add_command(discovery.discovery) main.add_command(configure.configure) -main.add_command(configure.list_profiles, name="list-profiles") +main.add_command(configure.list_all_profiles, name="list-profiles") main.add_command(configure.show_profile, name="show-profile") main.add_command(objects.objects) main.add_command(drs_pull.drs_pull) diff --git a/gen3/cli/configure.py b/gen3/cli/configure.py index 4a0d08824..36fa91580 100644 --- a/gen3/cli/configure.py +++ b/gen3/cli/configure.py @@ -26,11 +26,10 @@ def configure(profile, cred, apiendpoint): logging.info(f"Configuring profile [ {profile} ] with credentials at {cred}") try: - profile_title, new_lines = config_tool.get_profile_from_creds( + profile_name, credentials = config_tool.get_profile_from_creds( profile, cred, apiendpoint ) - lines = config_tool.get_current_config_lines() - config_tool.update_config_lines(lines, profile_title, new_lines) + config_tool.update_config_profile(profile_name, credentials) click.echo(f"Profile '{profile}' has been configured successfully.") @@ -45,7 +44,7 @@ def configure(profile, cred, apiendpoint): @click.command() -def list_profiles(): +def list_all_profiles(): """List all available profiles from both gen3sdk and cdis-data-client configs""" try: profiles = config_tool.list_profiles() diff --git a/gen3/configure.py b/gen3/configure.py index 8cac5219a..bbb7cfc7d 100644 --- a/gen3/configure.py +++ b/gen3/configure.py @@ -21,8 +21,6 @@ import json import os from os.path import expanduser -from pathlib import Path -from collections import OrderedDict import gen3.auth as auth_tool import configparser @@ -30,24 +28,24 @@ logging = get_logger("__name__") -CONFIG_FILE_PATH = expanduser("~/.gen3/config") -GEN3_CLIENT_CONFIG_PATH = expanduser("~/.gen3/gen3_client_config.ini") +CONFIG_FILE_PATH = expanduser("~/.gen3/gen3_client_config.ini") def get_profile_from_creds(profile, cred, apiendpoint=None): """Create profile configuration from credentials file with validation""" with open(expanduser(cred)) as f: creds_from_json = json.load(f) - credentials = OrderedDict() - + if "key_id" not in creds_from_json: raise ValueError("key_id not found in credentials file") if "api_key" not in creds_from_json: raise ValueError("api_key not found in credentials file") - - credentials["key_id"] = creds_from_json["key_id"] - credentials["api_key"] = creds_from_json["api_key"] - + + credentials = { + "key_id": creds_from_json["key_id"], + "api_key": creds_from_json["api_key"] + } + if apiendpoint: if not apiendpoint.startswith(('http://', 'https://')): raise ValueError("API endpoint must start with http:// or https://") @@ -56,129 +54,96 @@ def get_profile_from_creds(profile, cred, apiendpoint=None): credentials["api_endpoint"] = auth_tool.endpoint_from_token( credentials["api_key"] ) - + try: - credentials["access_key"] = auth_tool.get_access_token_with_key(credentials) + credentials["access_token"] = auth_tool.get_access_token_with_key(credentials) except Exception as e: logging.warning(f"Could not validate credentials with endpoint: {e}") - credentials["access_key"] = "" - - profile_line = "[" + profile + "]\n" - new_lines = [key + "=" + value + "\n" for key, value in credentials.items()] - new_lines.append("\n") # Adds an empty line between two profiles. - return profile_line, new_lines + credentials["access_token"] = "" + return profile, credentials -def get_current_config_lines(): - """Read lines from the config file if exists in ~/.gen3 folder, else create new config file""" - try: - with open(CONFIG_FILE_PATH) as configFile: - logging.info(f"Reading existing config file at {CONFIG_FILE_PATH}") - return configFile.readlines() - except FileNotFoundError: - Path(CONFIG_FILE_PATH).touch() - logging.info(f"Config file doesn't exist at {CONFIG_FILE_PATH}, creating one") - return [] +def ensure_config_dir(): + """Ensure the ~/.gen3 directory exists""" + config_dir = os.path.dirname(CONFIG_FILE_PATH) + os.makedirs(config_dir, exist_ok=True) -def update_config_lines(lines, profile_title, new_lines): - """Update config file contents with the new profile values""" - if profile_title in lines: - profile_line_index = lines.index(profile_title) - next_profile_index = len(lines) - for i in range(profile_line_index, len(lines)): - if lines[i][0] == "[": - next_profile_index = i - break - del lines[profile_line_index:next_profile_index] +def update_config_profile(profile_name, credentials): + """Update config file with new profile using configparser""" + ensure_config_dir() - with open(CONFIG_FILE_PATH, "a+") as configFile: - configFile.write(profile_title) - configFile.writelines(new_lines) + config = configparser.ConfigParser() + if os.path.exists(CONFIG_FILE_PATH): + config.read(CONFIG_FILE_PATH) + + if profile_name not in config: + config.add_section(profile_name) + + for key, value in credentials.items(): + config.set(profile_name, key, str(value)) + + with open(CONFIG_FILE_PATH, 'w') as configfile: + config.write(configfile) + + logging.info(f"Profile '{profile_name}' saved to {CONFIG_FILE_PATH}") def parse_profile_from_config(profile_name): - """Parse profile configuration from config file, checking both gen3sdk and cdis-data-client formats""" - - try: - with open(CONFIG_FILE_PATH, 'r') as f: - lines = f.readlines() - profile_line = f"[{profile_name}]\n" - if profile_line in lines: - profile_idx = lines.index(profile_line) - profile_data = {} - for i in range(profile_idx + 1, len(lines)): - if lines[i].startswith('[') or lines[i].strip() == '': - break - if '=' in lines[i]: - key, value = lines[i].strip().split('=', 1) - profile_data[key] = value - return profile_data - except FileNotFoundError: - pass - + """Parse profile configuration from config file using configparser""" + if not os.path.exists(CONFIG_FILE_PATH): + return None + try: - if os.path.exists(GEN3_CLIENT_CONFIG_PATH): - config = configparser.ConfigParser() - config.read(GEN3_CLIENT_CONFIG_PATH) - if profile_name in config: - profile_data = dict(config[profile_name]) - if 'access_token' in profile_data: - profile_data['access_key'] = profile_data['access_token'] - return profile_data + config = configparser.ConfigParser() + config.read(CONFIG_FILE_PATH) + if profile_name in config: + profile_data = dict(config[profile_name]) + # Normalize access_token vs access_key + if 'access_token' in profile_data and 'access_key' not in profile_data: + profile_data['access_key'] = profile_data['access_token'] + return profile_data except Exception as e: - logging.warning(f"Error reading cdis-data-client config: {e}") - + logging.warning(f"Error reading config file: {e}") + return None def list_profiles(): - """List all available profiles from both config files""" - profiles = set() - - try: - with open(CONFIG_FILE_PATH, 'r') as f: - lines = f.readlines() - for line in lines: - if line.startswith('[') and line.endswith(']\n'): - profile_name = line[1:-2] - profiles.add(profile_name) - except FileNotFoundError: - pass - + """List all available profiles from config file""" + if not os.path.exists(CONFIG_FILE_PATH): + return [] + try: - if os.path.exists(GEN3_CLIENT_CONFIG_PATH): - config = configparser.ConfigParser() - config.read(GEN3_CLIENT_CONFIG_PATH) - for section in config.sections(): - profiles.add(section) + config = configparser.ConfigParser() + config.read(CONFIG_FILE_PATH) + return sorted(config.sections()) except Exception as e: - logging.warning(f"Error reading cdis-data-client config: {e}") - - return sorted(list(profiles)) + logging.warning(f"Error reading config file: {e}") + return [] def get_profile_credentials(profile_name): - """Get credentials for a specific profile, compatible with both config formats""" + """Get credentials for a specific profile""" profile_data = parse_profile_from_config(profile_name) if not profile_data: - raise ValueError(f"Profile '{profile_name}' not found in any config file") - + raise ValueError(f"Profile '{profile_name}' not found in config file") + required_fields = ['key_id', 'api_key', 'api_endpoint'] for field in required_fields: if field not in profile_data or not profile_data[field]: raise ValueError(f"Profile '{profile_name}' missing required field: {field}") - + credentials = { 'key_id': profile_data['key_id'], 'api_key': profile_data['api_key'], 'api_endpoint': profile_data['api_endpoint'] } - + if 'access_key' in profile_data: credentials['access_token'] = profile_data['access_key'] elif 'access_token' in profile_data: credentials['access_token'] = profile_data['access_token'] - + return credentials diff --git a/tests/test_configure.py b/tests/test_configure.py index ac2fab155..0f6f355a7 100644 --- a/tests/test_configure.py +++ b/tests/test_configure.py @@ -15,26 +15,14 @@ def mock_access_key(_): profile = "DummyProfile" -expected_profile_line = f"[{profile}]\n" creds = {"key_id": "1234", "api_key": "abc"} -new_lines = [ - f"key_id={creds['key_id']}\n", - f"api_key={creds['api_key']}\n", - f"api_endpoint={mock_endpoint(None)}\n", - f"access_key={mock_access_key(None)}\n", - "use_shepherd=\n", - "min_shepherd_version=\n", -] +expected_credentials = { + "key_id": creds['key_id'], + "api_key": creds['api_key'], + "api_endpoint": mock_endpoint(None), + "access_token": mock_access_key(None), +} -lines_with_profile = [ - f"[{profile}]\n", - f"key_id=random_key\n", - f"api_key=random_api_key\n", - f"api_endpoint=random_endpoint\n", - f"access_key=random_access_key\n", - "use_shepherd=random_boolean\n", - "min_shepherd_version=random_version\n", -] @patch("gen3.auth.endpoint_from_token", mock_endpoint) @@ -47,26 +35,33 @@ def test_get_profile_from_creds(monkeypatch): with open(test_file_name, "w+") as cred_file: json.dump(creds, cred_file) - profile_line, lines = config_tool.get_profile_from_creds( + profile_name, credentials = config_tool.get_profile_from_creds( profile, test_file_name ) finally: if os.path.exists(test_file_name): os.remove(test_file_name) - assert profile_line == expected_profile_line - for line, new_line in zip(lines, new_lines): - assert line == new_line + assert profile_name == profile + assert credentials == expected_credentials -@pytest.mark.parametrize("test_lines", [[], lines_with_profile]) -def test_update_config_lines(test_lines, monkeypatch): +def test_update_config_profile(monkeypatch): file_name = str(uuid.uuid4()) monkeypatch.setattr(config_tool, "CONFIG_FILE_PATH", file_name) try: - config_tool.update_config_lines(test_lines, expected_profile_line, new_lines) - with open(file_name, "r") as f: - assert f.readlines() == [expected_profile_line] + new_lines + config_tool.update_config_profile(profile, expected_credentials) + + # Verify the config was written correctly using configparser + import configparser + config = configparser.ConfigParser() + config.read(file_name) + + assert profile in config + assert config[profile]['key_id'] == expected_credentials['key_id'] + assert config[profile]['api_key'] == expected_credentials['api_key'] + assert config[profile]['api_endpoint'] == expected_credentials['api_endpoint'] + assert config[profile]['access_token'] == expected_credentials['access_token'] finally: if os.path.exists(file_name): os.remove(file_name) From 422b33accb05e5ecdc428c373d6bdce180fc9eee Mon Sep 17 00:00:00 2001 From: Dhiren-Mhatre Date: Thu, 2 Oct 2025 18:24:22 +0530 Subject: [PATCH 3/3] fixed test Signed-off-by: Dhiren-Mhatre --- gen3/configure.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gen3/configure.py b/gen3/configure.py index bbb7cfc7d..36ab6ddc6 100644 --- a/gen3/configure.py +++ b/gen3/configure.py @@ -67,7 +67,8 @@ def get_profile_from_creds(profile, cred, apiendpoint=None): def ensure_config_dir(): """Ensure the ~/.gen3 directory exists""" config_dir = os.path.dirname(CONFIG_FILE_PATH) - os.makedirs(config_dir, exist_ok=True) + if config_dir: + os.makedirs(config_dir, exist_ok=True) def update_config_profile(profile_name, credentials):