Architecture

Writing Custom argparse Actions in Python

Extend argparse with custom Action classes: KEY=VALUE options, deprecated aliases, environment defaults and path checks, when to prefer type=, and how to test them.

Updated

argparse covers most option behaviour out of the box: storing values, boolean flags, counting -vvv, collecting repeated options into a list. Sooner or later a tool needs something slightly different — --set region=eu --set replicas=3 collected into a dictionary, an old flag name that still works but prints a deprecation warning, an option whose default comes from an environment variable and says so in --help. argparse's extension point for these is the Action: a small class whose __call__ method decides what happens when an option is seen. This guide shows when a custom action is the right tool (and when type= or post-parse validation is better), writes four practical actions, and tests them. It belongs to the command-line parsing with argparse topic.

Prerequisites

  • Python 3.10+; only the standard library is needed.
  • A CLI built on argparse — for example a bootstrap script or a tool that must avoid third-party dependencies, the case discussed in argparse vs Click vs Typer.

Built-in actions first

Built-in actions first Built-in argparse actions and what each does, to check before writing a custom action. Built-in actions first action= Does store / store_const save the value or a constant store_true / store_false boolean flag append / extend collect repeated values into a list count count occurrences: -vvv BooleanOptionalAction --flag / --no-flag pair version / help print and exit Write a custom action only when none of these, plus type=, can express the behaviour.

Before writing an action, check whether a built-in one plus a type= function covers the need. The division of labour matters: type= converts and validates a single value (a string into a duration, a path that must exist), while an Action decides what the option does with converted values (store, accumulate, merge into a dict, trigger a side effect). If you are only converting, write a type= function — it is simpler and composes with every built-in action.

import argparse
import re


def duration(text: str) -> float:
    """type= converter: '30s', '5m', '2h' -> seconds."""
    m = re.fullmatch(r"(\d+(?:\.\d+)?)([smh])", text)
    if not m:
        raise argparse.ArgumentTypeError(f"expected a duration like 30s, 5m or 2h, got {text!r}")
    return float(m[1]) * {"s": 1, "m": 60, "h": 3600}[m[2]]

Raising argparse.ArgumentTypeError produces the standard error format and exit status 2.

How an action is called

When a custom Action runs argparse matches an option string, calls the action with the parser, the namespace and the converted values, and the action writes to the namespace. When a custom Action runs parse_args type= converter Action.__call__ Namespace "30s" → convert 30.0 (parser, ns, 30.0, "--timeout") setattr(ns, dest, value) type= converts one value; an Action decides what the option does with it.

When argparse matches an option string, it converts the raw strings with type=, then calls the action instance with four arguments: the parser, the namespace being filled in, the converted values, and the option string the user actually typed. The action's job is to update the namespace — usually with setattr(namespace, self.dest, ...). Everything configurable about the option (nargs, default, help, metavar) is passed to the action's constructor, so custom actions subclass argparse.Action and usually only override __call__.

The recipe: four useful actions

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

import argparse
import os
import sys
from collections.abc import Sequence
from pathlib import Path
from typing import Any


class KeyValueAction(argparse.Action):
    """--set KEY=VALUE, repeatable; collects into a dict."""

    def __call__(self, parser: argparse.ArgumentParser, namespace: argparse.Namespace,
                 values: Any, option_string: str | None = None) -> None:
        key, sep, value = str(values).partition("=")
        if not sep or not key:
            parser.error(f"argument {option_string}: expected KEY=VALUE, got {values!r}")
        current = dict(getattr(namespace, self.dest, None) or {})
        current[key] = value
        setattr(namespace, self.dest, current)


class DeprecatedAlias(argparse.Action):
    """An old option name that still works, warns once, and stores into the new dest."""

    def __init__(self, option_strings: Sequence[str], dest: str, replacement: str, **kwargs: Any):
        self.replacement = replacement
        super().__init__(option_strings, dest, **kwargs)

    def __call__(self, parser, namespace, values, option_string=None):
        print(f"warning: {option_string} is deprecated; use {self.replacement}", file=sys.stderr)
        setattr(namespace, self.dest, values)


class EnvDefault(argparse.Action):
    """Default from an environment variable, shown in --help."""

    def __init__(self, option_strings: Sequence[str], dest: str, envvar: str,
                 required: bool = False, default: Any = None, **kwargs: Any):
        if envvar in os.environ:
            default = os.environ[envvar]
        if required and default is not None:
            required = False                     # satisfied by the environment
        kwargs["help"] = f"{kwargs.get('help', '')} [env: {envvar}]".strip()
        super().__init__(option_strings, dest, default=default, required=required, **kwargs)

    def __call__(self, parser, namespace, values, option_string=None):
        setattr(namespace, self.dest, values)


class ExistingDir(argparse.Action):
    """Store a Path, but only if it is an existing directory."""

    def __call__(self, parser, namespace, values, option_string=None):
        path = Path(values).expanduser()
        if not path.is_dir():
            parser.error(f"argument {option_string or self.dest}: {values!r} is not a directory")
        setattr(namespace, self.dest, path.resolve())

And a parser that uses them:

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

import argparse
from collections.abc import Sequence

from mytool.actions import DeprecatedAlias, EnvDefault, ExistingDir, KeyValueAction


def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="deploy", allow_abbrev=False,
                                description="Deploy a site directory.")
    p.add_argument("--directory", "-C", dest="directory", action=ExistingDir, default=None,
                   metavar="DIR", help="Directory to deploy.")
    p.add_argument("--dir", dest="directory", action=DeprecatedAlias, replacement="--directory",
                   help=argparse.SUPPRESS)
    p.add_argument("--set", dest="settings", action=KeyValueAction, default={},
                   metavar="KEY=VALUE", help="Override a setting (repeatable).")
    p.add_argument("--token", action=EnvDefault, envvar="DEPLOY_TOKEN", required=True,
                   help="API token.")
    return p


def main(argv: Sequence[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    print(f"settings={args.settings} directory={args.directory}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

What each action teaches

KeyValueAction shows the accumulate-into-a-container pattern. It copies the existing dict before modifying it, because argparse passes the same default object to every parse; mutating it in place would leak values between parses — a subtle bug in tests that parse more than once.

DeprecatedAlias writes to the same dest as the new option, so the rest of the program never knows which spelling the user typed. help=argparse.SUPPRESS hides it from --help, so new users learn only the new name. The broader deprecation process is covered in versioning and deprecating CLI flags.

EnvDefault does its work in __init__, which runs when the parser is built: it reads the environment, adjusts default and required, and annotates the help text. __call__ only runs when the option is given on the command line, which then correctly overrides the environment — the precedence described in config precedence: flags, env, files and defaults.

ExistingDir validates using parser.error(), which prints the usage line and a standard message and exits with status 2 — the same shape as argparse's own errors. (This one could also be a type= function; as an action it shows how to report errors consistently.)

Custom actions in use Terminal output of a script using custom argparse actions for key=value pairs and a deprecated alias with a warning. Custom actions in use bash $ deploy --set region=eu --set replicas=3 --dir ./site warning: --dir is deprecated; use --directory settings={'region': 'eu', 'replicas': '3'} directory=./site $ deploy --set region deploy: error: argument --set: expected KEY=VALUE, got "region" parser.error() inside an action produces the standard usage error and exit code 2.

UX considerations

  • Use parser.error() for validation failures so every error — built-in or yours — looks the same and exits with 2.
  • Set metavar. --set KEY=VALUE in the usage line tells users the format before they get it wrong.
  • Mention repeatability in help for accumulating actions; argparse cannot know that --set may be given many times.
  • Keep actions free of side effects beyond the namespace. Printing a deprecation warning is fine; making network calls or writing files during parsing is not — parsing should be cheap and predictable, and --help must never trigger work.
  • Disable abbreviations. allow_abbrev=False prevents --dir from matching --directory silently, which matters when you have deprecated aliases with similar prefixes.

Testing the behaviour

Parsers are easy to test: call parse_args with a list, and catch SystemExit for errors. Use capsys to check messages:

# tests/test_actions.py
import pytest

from mytool.cli import build_parser


def parse(argv, monkeypatch, token="t"):
    monkeypatch.setenv("DEPLOY_TOKEN", token)
    return build_parser().parse_args(argv)


def test_key_values_accumulate(monkeypatch):
    args = parse(["--set", "region=eu", "--set", "replicas=3"], monkeypatch)
    assert args.settings == {"region": "eu", "replicas": "3"}


def test_defaults_do_not_leak_between_parses(monkeypatch):
    parse(["--set", "a=1"], monkeypatch)
    assert parse([], monkeypatch).settings == {}


def test_bad_pair_is_a_usage_error(monkeypatch, capsys):
    with pytest.raises(SystemExit) as info:
        parse(["--set", "region"], monkeypatch)
    assert info.value.code == 2
    assert "expected KEY=VALUE" in capsys.readouterr().err


def test_deprecated_alias_warns_and_stores(monkeypatch, capsys, tmp_path):
    args = parse(["--dir", str(tmp_path)], monkeypatch)
    assert str(args.directory) == str(tmp_path)
    assert "--dir is deprecated" in capsys.readouterr().err


def test_env_default_and_override(monkeypatch):
    assert parse([], monkeypatch, token="from-env").token == "from-env"
    assert parse(["--token", "from-flag"], monkeypatch).token == "from-flag"


def test_missing_required_env(monkeypatch):
    monkeypatch.delenv("DEPLOY_TOKEN", raising=False)
    with pytest.raises(SystemExit):
        build_parser().parse_args([])

The "do not leak" test catches the shared-mutable-default bug described above. Building a fresh parser inside each test (via build_parser()) also matters for EnvDefault, whose default is read when the parser is constructed.

Conclusion

Custom argparse actions are the right tool when an option's behaviour is non-standard — accumulating into a dict, aliasing a deprecated name, taking a default from the environment. For plain conversion and validation of a value, a type= function is simpler. Subclass argparse.Action, update the namespace in __call__, report problems with parser.error() so exit codes and formatting match argparse's own, avoid mutating shared defaults, and test by parsing lists of arguments. That covers most of what makes people reach for a heavier framework, without adding a dependency.

Frequently asked questions

Can an action consume a variable number of values?

Yes — pass nargs="+" or nargs="*" when adding the argument, and values arrives as a list. The action decides what to do with them; for example, a --set that accepts several pairs at once.

How do I make a flag action that takes no value?

Pass nargs=0 to the base constructor in __init__; values will be an empty list. That is how store_true and version work internally.

Why not validate everything after parse_args() instead?

For cross-option rules — "A requires B" — you must, as covered in mutually exclusive options in argparse. For single-option behaviour, doing it in an action keeps the logic next to the option definition and makes the error reference the exact option string the user typed.

How do actions interact with subparsers?

Each subparser is an ordinary ArgumentParser, so custom actions work on subcommand options exactly as on the main parser. Share common actions across subcommands with a parent parser (add_parser(..., parents=[common])), which copies the argument definitions — including their action classes — into every subcommand. Build the parent with add_help=False so it does not add a second -h. The subparser patterns are covered in argparse subparsers for subcommands.

Do these patterns exist in Click?

Mostly as built-ins: Click has envvar= on every option, multiple=True for accumulation, callbacks for per-option logic and custom ParamType classes for conversion — see writing custom Click parameter types.