diff --git a/gen3/cli/__main__.py b/gen3/cli/__main__.py index 5a51be11..3f452075 100644 --- a/gen3/cli/__main__.py +++ b/gen3/cli/__main__.py @@ -18,18 +18,33 @@ 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: - 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: + 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 +66,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 +112,7 @@ def main( ctx, auth_config, endpoint, + profile_name, verbose_logs, very_verbose_logs, only_error_logs, @@ -99,10 +121,25 @@ def main( ): """Gen3 Command Line Interface""" ctx.ensure_object(dict) + + if profile_name and not auth_config: + try: + profile_creds = config_tool.get_profile_credentials(profile_name) + 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 +176,8 @@ def main( main.add_command(wss.wss) main.add_command(discovery.discovery) main.add_command(configure.configure) +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) main.add_command(file.file) diff --git a/gen3/cli/configure.py b/gen3/cli/configure.py index d6bf9bfa..36fa9158 100644 --- a/gen3/cli/configure.py +++ b/gen3/cli/configure.py @@ -6,20 +6,75 @@ @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) - lines = config_tool.get_current_config_lines() - config_tool.update_config_lines(lines, profile_title, new_lines) + profile_name, credentials = config_tool.get_profile_from_creds( + profile, cred, apiendpoint + ) + config_tool.update_config_profile(profile_name, credentials) + + 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_all_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 5322cf6a..36ab6ddc 100644 --- a/gen3/configure.py +++ b/gen3/configure.py @@ -19,60 +19,132 @@ """ 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") +CONFIG_FILE_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() - 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"] = "" - 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 - - -def get_current_config_lines(): - """Read lines from the config file if exists in ~/.gen3 folder, else create new config file""" + + 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"], + "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://") + credentials["api_endpoint"] = apiendpoint.rstrip('/') + else: + credentials["api_endpoint"] = auth_tool.endpoint_from_token( + credentials["api_key"] + ) + + try: + 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_token"] = "" + + return profile, credentials + + +def ensure_config_dir(): + """Ensure the ~/.gen3 directory exists""" + config_dir = os.path.dirname(CONFIG_FILE_PATH) + if config_dir: + os.makedirs(config_dir, exist_ok=True) + + +def update_config_profile(profile_name, credentials): + """Update config file with new profile using configparser""" + ensure_config_dir() + + 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 using configparser""" + if not os.path.exists(CONFIG_FILE_PATH): + return None + + try: + 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 config file: {e}") + + return None + + +def list_profiles(): + """List all available profiles from config file""" + if not os.path.exists(CONFIG_FILE_PATH): + return [] + 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") + config = configparser.ConfigParser() + config.read(CONFIG_FILE_PATH) + return sorted(config.sections()) + except Exception as e: + logging.warning(f"Error reading config file: {e}") return [] -def update_config_lines(lines, profile_title, new_lines): - """Update config file contents with the new profile values""" +def get_profile_credentials(profile_name): + """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 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 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] + 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'] - with open(CONFIG_FILE_PATH, "a+") as configFile: - configFile.write(profile_title) - configFile.writelines(new_lines) + return credentials diff --git a/tests/test_configure.py b/tests/test_configure.py index ac2fab15..0f6f355a 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)