diff --git a/docs/cli/references/command_line_interface.md b/docs/cli/references/command_line_interface.md index da7e1887..4c0da31c 100644 --- a/docs/cli/references/command_line_interface.md +++ b/docs/cli/references/command_line_interface.md @@ -48,6 +48,7 @@ rp login --profile staging # save browser credentials in a named profile Credentials are stored in `~/.runpod/config.toml`. Use `--profile NAME` with either login method to update only that profile; the default is `default`. +Set `RUNPOD_PROFILE=NAME` to select the profile for all commands and for `runpod` in Python; `--profile` overrides it, and `RUNPOD_API_KEY` overrides any stored profile. ### Flash apps diff --git a/docs/cli/start_here.md b/docs/cli/start_here.md index 75a97b69..adaa94ae 100644 --- a/docs/cli/start_here.md +++ b/docs/cli/start_here.md @@ -12,6 +12,8 @@ Store your Runpod API key by running `rp login` (approve in the browser, then pa Pass `--profile staging` with either login method to update a named credential profile without changing the others. The default profile is `default`. +Set `RUNPOD_PROFILE=staging` to use that profile for every command, including `rp login`. `--profile` takes precedence over `RUNPOD_PROFILE`, and `RUNPOD_API_KEY` takes precedence over any stored profile. + ### SSH Add an SSH key to your account by running `rp ssh add`. To use an existing key pass `--key` or `--key-file`. Keys are stored in `~/.runpod/ssh/`. If no key is specified a new one is generated and stored. diff --git a/docs/getting_Started.md b/docs/getting_Started.md index 10e0692f..cf322a81 100644 --- a/docs/getting_Started.md +++ b/docs/getting_Started.md @@ -4,18 +4,31 @@ The Runpod python library is a powerful library providing SDK functions, API acc ## Credentials File -This python library supports a credentials file saved to `~/.runpod/credentials.toml` that contains the following information: +`rp login` stores your API key in `~/.runpod/config.toml`, one table per profile: ```toml -[profile] +[default] api_key = "YOUR_RUNPOD_API_KEY" ``` + ### Profile -By default, all credentials are stored under the `default` profile. To switch profiles you can use the `--profile` argument on CLI commands or change the profile within a script. +By default, credentials are stored under and read from the `default` profile. To store a key under another profile, pass `--profile` to `rp login`: + +```bash +rp login --profile my_profile +``` + +To use that profile, set the `RUNPOD_PROFILE` environment variable before importing `runpod`. The library reads the profile and its API key once, at import time. + +```bash +export RUNPOD_PROFILE=my_profile +``` + +`RUNPOD_API_KEY`, when set, takes precedence over any stored profile. Inside a script, you can also set the key directly: ```python import runpod -runpod.profile = "my_profile" +runpod.api_key = "YOUR_RUNPOD_API_KEY" ``` diff --git a/runpod/__init__.py b/runpod/__init__.py index 6add070c..36f398c3 100644 --- a/runpod/__init__.py +++ b/runpod/__init__.py @@ -87,6 +87,18 @@ ] +DEFAULT_PROFILE = "default" + + +def _selected_profile() -> str: + """credential profile in ~/.runpod/config.toml to use. + + set RUNPOD_PROFILE to pick a named profile (saved with + `rp login --profile NAME`); unset or blank means "default". + """ + return os.environ.get("RUNPOD_PROFILE", "").strip() or DEFAULT_PROFILE + + def _load_api_key(): """resolve the api key from the environment, then stored credentials. @@ -103,7 +115,7 @@ def _load_api_key(): ).get_credentials(profile) except Exception: # noqa: BLE001 - no stored key is a valid state return None - return creds["api_key"] if creds else None + return creds.get("api_key") if creds else None def __getattr__(name): @@ -119,7 +131,7 @@ def __getattr__(name): # ------------------------------- Config Paths ------------------------------- # SSH_KEY_PATH = os.path.expanduser("~/.runpod/ssh") -profile = "default" # pylint: disable=invalid-name +profile = _selected_profile() # pylint: disable=invalid-name api_key = _load_api_key() # pylint: disable=invalid-name diff --git a/runpod/rp_cli/main.py b/runpod/rp_cli/main.py index e878f7bc..f98650b2 100644 --- a/runpod/rp_cli/main.py +++ b/runpod/rp_cli/main.py @@ -21,6 +21,12 @@ _FLASH_UPDATE_CHECK_EXCLUDED = frozenset({"dev"}) +def _default_profile() -> str: + from runpod import _selected_profile + + return _selected_profile() + + @click.group() @click.pass_context def cli(ctx): @@ -631,7 +637,10 @@ async def _run(): help="Store this API key directly (skips the browser flow).", ) @click.option( - "--profile", default="default", show_default=True, help="Credential profile." + "--profile", + default=_default_profile, + show_default="RUNPOD_PROFILE, else default", + help="Credential profile.", ) @click.option( "--agent", diff --git a/tests/test_cli/test_management_commands.py b/tests/test_cli/test_management_commands.py index c2101b2e..67f2e478 100644 --- a/tests/test_cli/test_management_commands.py +++ b/tests/test_cli/test_management_commands.py @@ -377,6 +377,35 @@ def test_login_updates_only_selected_profile( assert functions.get_credentials(untouched)["api_key"] == f"old-{untouched}" assert functions.get_credentials("production")["api_key"] == "keep-production" + def test_login_saves_to_profile_from_env(self, tmp_path, monkeypatch): + from runpod.cli.groups.config import functions + + credentials = tmp_path / "config.toml" + credentials.write_text('[default]\napi_key = "keep-default"\n') + monkeypatch.setattr(functions, "CREDENTIAL_FILE", str(credentials)) + monkeypatch.setenv("RUNPOD_PROFILE", "team") + + result = _runner().invoke(cli, ["login", "--api-key", "new-key"]) + + assert result.exit_code == 0, result.output + assert functions.get_credentials("team")["api_key"] == "new-key" + assert functions.get_credentials("default")["api_key"] == "keep-default" + + def test_login_profile_flag_overrides_env(self, tmp_path, monkeypatch): + from runpod.cli.groups.config import functions + + credentials = tmp_path / "config.toml" + monkeypatch.setattr(functions, "CREDENTIAL_FILE", str(credentials)) + monkeypatch.setenv("RUNPOD_PROFILE", "team") + + result = _runner().invoke( + cli, ["login", "--profile", "other", "--api-key", "new-key"] + ) + + assert result.exit_code == 0, result.output + assert functions.get_credentials("other")["api_key"] == "new-key" + assert functions.get_credentials("team") is None + def test_browser_flow_error(self): from runpod.apps.auth import LoginError diff --git a/tests/test_profile.py b/tests/test_profile.py new file mode 100644 index 00000000..7751de03 --- /dev/null +++ b/tests/test_profile.py @@ -0,0 +1,79 @@ +"""Credential profile selection via RUNPOD_PROFILE.""" + +import importlib + +import pytest + +import runpod + + +class TestSelectedProfile: + def test_defaults_to_default_profile(self, monkeypatch): + monkeypatch.delenv("RUNPOD_PROFILE", raising=False) + assert runpod._selected_profile() == "default" + + def test_env_selects_profile(self, monkeypatch): + monkeypatch.setenv("RUNPOD_PROFILE", "team") + assert runpod._selected_profile() == "team" + + def test_env_value_is_stripped(self, monkeypatch): + monkeypatch.setenv("RUNPOD_PROFILE", " team ") + assert runpod._selected_profile() == "team" + + @pytest.mark.parametrize("value", ["", " "]) + def test_blank_env_falls_back_to_default(self, monkeypatch, value): + monkeypatch.setenv("RUNPOD_PROFILE", value) + assert runpod._selected_profile() == "default" + + +class TestApiKeyFromSelectedProfile: + def test_loads_key_from_selected_profile(self, tmp_path, monkeypatch): + from runpod.cli.groups.config import functions + + credentials = tmp_path / "config.toml" + credentials.write_text( + '[default]\napi_key = "default-key"\n[team]\napi_key = "team-key"\n' + ) + monkeypatch.setattr(functions, "CREDENTIAL_FILE", str(credentials)) + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + monkeypatch.setattr(runpod, "profile", "team") + + assert runpod._load_api_key() == "team-key" + + def test_api_key_env_wins_over_profile(self, tmp_path, monkeypatch): + from runpod.cli.groups.config import functions + + credentials = tmp_path / "config.toml" + credentials.write_text('[team]\napi_key = "team-key"\n') + monkeypatch.setattr(functions, "CREDENTIAL_FILE", str(credentials)) + monkeypatch.setenv("RUNPOD_API_KEY", "env-key") + monkeypatch.setattr(runpod, "profile", "team") + + assert runpod._load_api_key() == "env-key" + + +class TestImportWithSelectedProfile: + @pytest.fixture + def reload_runpod(self, monkeypatch): + """reload runpod under the test env, then again under the restored env.""" + yield lambda: importlib.reload(runpod) + monkeypatch.undo() + importlib.reload(runpod) + + def test_s3_only_profile_imports_without_api_key( + self, tmp_path, monkeypatch, reload_runpod + ): + from runpod.cli.groups.config import functions + + credentials = tmp_path / "config.toml" + credentials.write_text( + '[storage]\ns3_access_key = "id"\ns3_secret_key = "secret"\n' + ) + monkeypatch.setattr(functions, "CREDENTIAL_FILE", str(credentials)) + monkeypatch.delenv("RUNPOD_API_KEY", raising=False) + monkeypatch.setenv("RUNPOD_PROFILE", "storage") + + reload_runpod() + + assert runpod.profile == "storage" + assert runpod.api_key is None