Runtime

Supporting Multiple Profiles and Accounts in CLIs

Let one Python CLI work with several accounts and environments: named profiles in config, per-profile keychain tokens, --profile precedence and production guards.

Updated

Your CLI started with one account and one API URL. Now people use it against staging and production, for two customer tenants, or with a personal and a work identity. Without explicit support they juggle it by hand: logging out and in, exporting different environment variables in different terminals, keeping two config files and symlinking between them. Eventually someone runs mytool db reset in the terminal they thought was pointed at staging. Profiles — named bundles of settings, each with its own credential — make multiple accounts a first-class feature, the way the AWS CLI, gcloud configurations and kubectl contexts do. This guide implements them for a Typer CLI: profile config in TOML, tokens per profile in the keychain, a clear precedence for choosing the active profile, and guards that make production harder to hit by accident. It is part of the secrets and credentials topic.

Prerequisites

Settings in config, secrets in the keychain

A profile has two halves with very different handling. The settings — API URL, account name, region, output preferences — are not secret: users want to read them, edit them, back them up and share them with colleagues. The credential is secret and must never be written into that file. So settings go into one table per profile in the config file, and each profile's token goes into the keychain under a key derived from the profile name.

Profiles on disk and in the keychain Non-secret profile settings live in the config file under one table per profile, while each profile token lives in the keychain under a key derived from the profile name. Profiles on disk and in the keychain config.toml no secrets here [profiles.prod] url, account [profiles.staging] url, account keychain: mytool/prod token keychain: mytool/staging token settings are safe to share and back up; tokens stay in the keychain Splitting settings from secrets is what lets people commit or sync their config freely.
# ~/.config/mytool/config.toml
default_profile = "staging"

[profiles.staging]
url = "https://staging.example.com"
account = "ana@example.com"

[profiles.prod]
url = "https://api.example.com"
account = "ana@example.com"
protected = true

Which profile is active?

Choosing the active profile uses the same precedence as every other setting, so users do not have to learn a special rule:

Which profile is active? The precedence for choosing an account profile: a command line flag, then an environment variable, then the default recorded in config. Which profile is active? --profile staging flag this invocation only MYTOOL_PROFILE=staging env this shell or CI job default = "prod" in config.toml config set by: mytool profile use prod the only profile, if there is one implicit no need to choose The same precedence as every other setting, so users never have to learn a special rule.

A --profile flag affects one invocation. MYTOOL_PROFILE affects a shell session or a CI job. default_profile in config is the persistent choice, set by a profile use command. And when exactly one profile exists, it is used without anyone having to name it. This mirrors the general scheme described in config precedence: flags, env, files and defaults.

The recipe

# src/mytool/profiles.py
from __future__ import annotations

import os
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import tomli_w


class ProfileError(Exception):
    pass


@dataclass(frozen=True)
class Profile:
    name: str
    url: str
    account: str | None = None
    protected: bool = False

    @property
    def keyring_user(self) -> str:
        return f"profile:{self.name}"


def load_config(path: Path) -> dict[str, Any]:
    if not path.exists():
        return {}
    with path.open("rb") as fh:
        return tomllib.load(fh)


def save_config(path: Path, data: dict[str, Any]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(".tmp")
    tmp.write_bytes(tomli_w.dumps(data).encode())
    os.replace(tmp, path)                      # atomic; see the atomic-writes guide


def resolve(config: dict[str, Any], flag: str | None = None) -> tuple[Profile, str]:
    """Return the active profile and a description of why it was chosen."""
    profiles: dict[str, dict[str, Any]] = config.get("profiles", {})
    if flag:
        name, why = flag, "--profile"
    elif env := os.environ.get("MYTOOL_PROFILE"):
        name, why = env, "MYTOOL_PROFILE"
    elif config.get("default_profile"):
        name, why = config["default_profile"], "default_profile in config"
    elif len(profiles) == 1:
        name, why = next(iter(profiles)), "the only profile"
    else:
        raise ProfileError("no profile selected: pass --profile, set MYTOOL_PROFILE, "
                           "or run: mytool profile use NAME")
    if name not in profiles:
        known = ", ".join(sorted(profiles)) or "none"
        raise ProfileError(f"unknown profile {name!r} (from {why}); known profiles: {known}")
    p = profiles[name]
    return Profile(name, p["url"], p.get("account"), bool(p.get("protected", False))), why
# src/mytool/cli.py
from pathlib import Path

import typer

from mytool.profiles import Profile, ProfileError, load_config, resolve, save_config

app = typer.Typer()
profile_app = typer.Typer(help="Manage named profiles.")
app.add_typer(profile_app, name="profile")

CONFIG = Path.home() / ".config" / "mytool" / "config.toml"


@app.callback()
def main(
    ctx: typer.Context,
    profile: str = typer.Option(None, "--profile", "-p",
                                help="Profile to use for this command."),
    verbose: bool = typer.Option(False, "--verbose", "-v"),
) -> None:
    """A CLI with named profiles."""
    ctx.ensure_object(dict)
    ctx.obj["config"] = load_config(CONFIG)
    ctx.obj["flag"] = profile
    ctx.obj["verbose"] = verbose


def active(ctx: typer.Context) -> Profile:
    try:
        prof, why = resolve(ctx.obj["config"], ctx.obj["flag"])
    except ProfileError as exc:
        typer.echo(f"error: {exc}", err=True)
        raise typer.Exit(2)
    if ctx.obj["verbose"]:
        typer.echo(f"using profile {prof.name!r} ({why}) -> {prof.url}", err=True)
    return prof


@profile_app.command("list")
def list_profiles(ctx: typer.Context) -> None:
    """List profiles; the active one is marked with *."""
    config = ctx.obj["config"]
    try:
        current = resolve(config, ctx.obj["flag"])[0].name
    except ProfileError:
        current = None
    for name, p in sorted(config.get("profiles", {}).items()):
        mark = "*" if name == current else " "
        lock = "  [protected]" if p.get("protected") else ""
        typer.echo(f"{mark} {name:<10} {p['url']:<32} {p.get('account', '')}{lock}")


@profile_app.command("use")
def use(ctx: typer.Context, name: str) -> None:
    """Make NAME the default profile."""
    config = ctx.obj["config"]
    if name not in config.get("profiles", {}):
        typer.echo(f"error: unknown profile {name!r}", err=True)
        raise typer.Exit(2)
    config["default_profile"] = name
    save_config(CONFIG, config)
    typer.echo(f"default profile is now {name!r}", err=True)


@app.command("reset-db")
def reset_db(ctx: typer.Context, yes: bool = typer.Option(False, "--yes")) -> None:
    """Delete all data in the target environment."""
    prof = active(ctx)
    if prof.protected:
        typer.echo(f"profile {prof.name!r} is protected ({prof.url}).", err=True)
        typed = typer.prompt("type the profile name to continue", err=True)
        if typed != prof.name:
            typer.echo("aborted", err=True)
            raise typer.Exit(1)
    elif not yes:
        typer.confirm(f"reset {prof.url}?", abort=True, err=True)
    typer.echo(f"reset {prof.name}", err=True)


if __name__ == "__main__":
    app()

Choices worth noting

Why each profile was chosen is reported. resolve() returns a reason, printed in verbose mode. "using profile 'prod' (MYTOOL_PROFILE)" instantly explains the classic confusion of a variable exported three hours ago in a forgotten terminal.

Unknown names are errors, never fallbacks. A typo in --profile prdo must not quietly use the default profile; the error lists the known names.

Protected profiles demand typing the name. A yes/no confirmation is muscle memory; typing prod is not. --yes deliberately does not bypass it — automation against a protected profile should be a conscious decision, for example a separate MYTOOL_ALLOW_PROTECTED=1 variable in the pipeline. This builds on adding dry-run and confirmation to destructive commands.

Tokens are keyed by profile. keyring.get_password("mytool", prof.keyring_user) gives each profile its own credential, and auth login --profile prod stores into the right slot. Logging in to staging can never overwrite the production token.

UX considerations

Making the active profile visible Terminal output of listing profiles with the active one marked, switching default, and a production warning on a destructive command. Making the active profile visible bash $ mytool profile list staging https://staging.example.com ana@example.com * prod https://api.example.com ana@example.com $ mytool db reset profile "prod" is marked protected; type the profile name to continue: The more dangerous the command, the more visible the active profile should be.
  • Make the active profile visible. Show it in profile list, in --verbose output, and in the header of any command that changes remote state ("deploying to prod (https://api.example.com)").
  • Consider a shell prompt hook. Power users like seeing the active profile in their prompt, as they do with kubectl contexts. A fast mytool profile current command that prints just the name makes that easy.
  • Keep global options before the subcommand. mytool --profile prod deploy is how Click-based CLIs parse group options; document it, or also accept --profile on individual commands if users expect it after. The trade-offs are covered in global options vs per-command options.
  • Offer profile add and profile remove. Editing TOML by hand is fine for power users; commands with validation help everyone else and can store the token in the same step.
  • Never print tokens in profile list. Settings only; the credential stays in the keychain.

Testing the behaviour

Resolution is pure logic over a dictionary and the environment, so it can be tested exhaustively with monkeypatch:

# tests/test_profiles.py
import pytest

from mytool.profiles import ProfileError, resolve

CONFIG = {
    "default_profile": "staging",
    "profiles": {
        "staging": {"url": "https://staging.example.com"},
        "prod": {"url": "https://api.example.com", "protected": True},
    },
}


@pytest.fixture(autouse=True)
def no_env(monkeypatch):
    monkeypatch.delenv("MYTOOL_PROFILE", raising=False)


def test_flag_beats_env_beats_default(monkeypatch):
    monkeypatch.setenv("MYTOOL_PROFILE", "prod")
    assert resolve(CONFIG, "staging")[0].name == "staging"
    assert resolve(CONFIG)[0].name == "prod"
    monkeypatch.delenv("MYTOOL_PROFILE")
    assert resolve(CONFIG)[0].name == "staging"


def test_reason_is_reported(monkeypatch):
    monkeypatch.setenv("MYTOOL_PROFILE", "prod")
    assert resolve(CONFIG)[1] == "MYTOOL_PROFILE"


def test_single_profile_is_implicit():
    config = {"profiles": {"only": {"url": "https://x"}}}
    assert resolve(config)[0].name == "only"


def test_typo_is_an_error_listing_names():
    with pytest.raises(ProfileError, match="known profiles: prod, staging"):
        resolve(CONFIG, "prdo")


def test_protected_flag():
    assert resolve(CONFIG, "prod")[0].protected is True

For the protected-profile guard, drive the command with CliRunner and input="staging\n" to prove that typing the wrong name aborts. See testing interactive prompts and stdin.

Conclusion

Profiles turn multiple accounts from a source of accidents into a feature. Keep each profile's settings in a table in the config file and its token in the keychain, choose the active one with the familiar flag–env–config precedence, report why it was chosen, reject unknown names, and make protected profiles require deliberate confirmation. The result is a tool people can safely point at production and staging from the same terminal.

Frequently asked questions

Should profiles inherit from each other?

A small amount of inheritance — a [defaults] table merged under every profile — saves repetition when profiles share most settings. Avoid chains of profiles inheriting from profiles; they make "where did this value come from?" hard to answer.

How do I migrate users from a single-account setup?

On first run of the new version, if the old top-level settings exist and no profiles table does, create a default profile from them, move the token to the profile's keychain slot, and print one line explaining what happened.

Can a project directory pin a profile?

Yes: a project-level config file (discovered by walking up from the current directory) can set profile = "staging", slotting in between the environment variable and the user default. See discovering project config files by walking up directories.

What about CI, where there is no config file?

Let a profile be fully defined by environment variables — MYTOOL_URL plus MYTOOL_TOKEN — without any config. Profiles are a convenience for people; automation should not need them.