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
- Python 3.11+ (
tomllib), Typer,keyringandtomli-wfor writing TOML. - A config location from storing app data with platformdirs and token storage from storing tokens with keyring.
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.
# ~/.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:
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
- Make the active profile visible. Show it in
profile list, in--verboseoutput, 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
kubectlcontexts. A fastmytool profile currentcommand that prints just the name makes that easy. - Keep global options before the subcommand.
mytool --profile prod deployis how Click-based CLIs parse group options; document it, or also accept--profileon individual commands if users expect it after. The trade-offs are covered in global options vs per-command options. - Offer
profile addandprofile 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.