$ cat docs/argparse.md
updated Today · 18-24 min read · published
argparse Introduction
argparse is the stdlib toolkit for command-line interfaces. It validates types, generates --help , and supports subcommands. For ergonomic typed CLIs, many teams also use Typer (built on Click) — covered briefly below.
Design CLIs like APIs: clear verbs, validated inputs, non-zero exit codes on failure, and help text that teaches usage.
ℹ info
Prefer
type=Path and choices over post-hoc string checks. Pair with
pathlib .
Basic Parser
Copy 1 import argparse 2 from pathlib import Path 3 4 def build_parser() -> argparse.ArgumentParser: 5 p = argparse.ArgumentParser( 6 prog="forge", 7 description="Example ForgeLearn CLI", 8 epilog="Docs: https://forgelearn.dev/docs/python/argparse", 9 ) 10 p.add_argument("input", type=Path, help="input file path") 11 p.add_argument("-o", "--output", type=Path, default=Path("out.txt")) 12 p.add_argument("-n", "--count", type=int, default=1, help="repeat count") 13 p.add_argument("-v", "--verbose", action="count", default=0, 14 help="-v / -vv for more logging") 15 p.add_argument("--format", choices=["json", "csv", "text"], default="text") 16 return p 17 18 if __name__ == "__main__": 19 args = build_parser().parse_args() 20 print(args.input, args.output, args.count, args.verbose, args.format)
add_argument pattern Meaning "input", type=Path Positional required Path --flag, action=store_true Boolean flag action=count Increment per -v choices=[...] Enum-like restriction nargs='+' One or more values nargs='*' Zero or more required=True on optional Force --opt
Custom Types & Validation
Copy 1 import argparse 2 from pathlib import Path 3 4 def existing_file(s: str) -> Path: 5 p = Path(s) 6 if not p.is_file(): 7 raise argparse.ArgumentTypeError(f"not a file: {s}") 8 return p 9 10 def positive_int(s: str) -> int: 11 n = int(s) 12 if n <= 0: 13 raise argparse.ArgumentTypeError("must be > 0") 14 return n 15 16 parser = argparse.ArgumentParser() 17 parser.add_argument("config", type=existing_file) 18 parser.add_argument("--workers", type=positive_int, default=4) 19 args = parser.parse_args()
✓ best practice
Raise ArgumentTypeError from custom type callables — argparse turns them into clean usage errors.
Subcommands
Use add_subparsers for git-like verbs: tool run , tool list .
Copy 1 import argparse 2 from pathlib import Path 3 4 def cmd_run(args: argparse.Namespace) -> int: 5 print("run", args.job, "dry" if args.dry_run else "live") 6 return 0 7 8 def cmd_list(args: argparse.Namespace) -> int: 9 for p in Path(args.dir).glob(args.pattern): 10 print(p) 11 return 0 12 13 def main(argv: list[str] | None = None) -> int: 14 parser = argparse.ArgumentParser(prog="jobs") 15 sub = parser.add_subparsers(dest="command", required=True) 16 17 run_p = sub.add_parser("run", help="run a job") 18 run_p.add_argument("job") 19 run_p.add_argument("--dry-run", action="store_true") 20 run_p.set_defaults(func=cmd_run) 21 22 list_p = sub.add_parser("list", help="list files") 23 list_p.add_argument("--dir", type=Path, default=Path(".")) 24 list_p.add_argument("--pattern", default="*") 25 list_p.set_defaults(func=cmd_list) 26 27 args = parser.parse_args(argv) 28 return args.func(args) 29 30 if __name__ == "__main__": 31 raise SystemExit(main())
Mutually Exclusive Groups
Copy 1 import argparse 2 3 parser = argparse.ArgumentParser() 4 g = parser.add_mutually_exclusive_group(required=True) 5 g.add_argument("--json", action="store_true", help="JSON output") 6 g.add_argument("--plain", action="store_true", help="plain text") 7 g.add_argument("--csv", action="store_true", help="CSV output") 8 9 auth = parser.add_argument_group("authentication") 10 auth.add_argument("--token") 11 auth.add_argument("--user") 12 13 args = parser.parse_args()
Also useful: add_argument_group for help-section organization without mutual exclusion.
Defaults from Environment
env_defaults.py wrap Python
Copy 1 import argparse 2 import os 3 4 parser = argparse.ArgumentParser() 5 parser.add_argument( 6 "--host", 7 default=os.getenv("APP_HOST", "127.0.0.1"), 8 help="bind host (env APP_HOST)", 9 ) 10 parser.add_argument( 11 "--port", 12 type=int, 13 default=int(os.getenv("APP_PORT", "8000")), 14 ) 15 args = parser.parse_args()
🔥 pro tip
For complex config, parse CLI with argparse then validate with
Pydantic Settings .
Typer Overview
Typer gives you type-hint-driven CLIs with automatic help. Great for internal tools; argparse remains ideal when you want zero dependencies.
Copy 1 # pip install typer 2 import typer 3 from pathlib import Path 4 5 app = typer.Typer(help="Typer demo") 6 7 @app.command() 8 def convert( 9 input: Path = typer.Argument(..., exists=True, readable=True), 10 output: Path = typer.Option(Path("out.json")), 11 verbose: bool = False, 12 ) -> None: 13 """Convert INPUT to OUTPUT.""" 14 if verbose: 15 typer.echo(f"{input} -> {output}") 16 output.write_text(input.read_text(encoding="utf-8").upper(), encoding="utf-8") 17 18 @app.command("list-ext") 19 def list_ext(ext: str = ".py") -> None: 20 for p in Path(".").rglob(f"*{ext}"): 21 typer.echo(p) 22 23 if __name__ == "__main__": 24 app()
Exit Codes & Testing
Copy 1 import argparse 2 3 def parse(argv: list[str]) -> argparse.Namespace: 4 p = argparse.ArgumentParser() 5 p.add_argument("--n", type=int, required=True) 6 return p.parse_args(argv) 7 8 def main(argv: list[str] | None = None) -> int: 9 try: 10 args = parse(argv if argv is not None else None) 11 except SystemExit as e: 12 return int(e.code or 0) 13 if args.n < 0: 14 print("n must be >= 0", file=__import__("sys").stderr) 15 return 2 16 print(args.n * 2) 17 return 0 18 19 # pytest: assert main(["--n", "3"]) == 0
Code Convention 0 Success 1 Runtime / unexpected failure 2 CLI usage error (argparse default) 130 SIGINT (128+2)
Production Patterns
Keep build_parser() separate from main() for testability Use set_defaults(func=...) for subcommand dispatch Document env overrides in help strings Never trust raw strings for paths — type=Path + existence checks Return int from main(); raise SystemExit(main()) Copy 1 """Production-shaped CLI entrypoint.""" 2 from __future__ import annotations 3 import argparse 4 import logging 5 from pathlib import Path 6 7 log = logging.getLogger("forge") 8 9 def build_parser() -> argparse.ArgumentParser: 10 p = argparse.ArgumentParser(prog="forge") 11 p.add_argument("path", type=Path) 12 p.add_argument("-v", "--verbose", action="store_true") 13 return p 14 15 def run(path: Path) -> None: 16 text = path.read_text(encoding="utf-8") 17 log.info("read %s bytes from %s", len(text), path) 18 19 def main(argv: list[str] | None = None) -> int: 20 args = build_parser().parse_args(argv) 21 logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO) 22 try: 23 run(args.path) 24 except OSError as e: 25 log.error("%s", e) 26 return 1 27 return 0 28 29 if __name__ == "__main__": 30 raise SystemExit(main())
nargs, const & append
Advanced argument shapes: optional value with const, accumulating lists, and remainder args.
Copy 1 import argparse 2 3 p = argparse.ArgumentParser() 4 p.add_argument("--mode", nargs="?", const="auto", default="off", 5 help="omit->off, --mode->auto, --mode X->X") 6 p.add_argument("--tag", action="append", default=[], help="repeatable") 7 p.add_argument("--ids", nargs="+", type=int, help="one or more ints") 8 p.add_argument("paths", nargs="*", help="zero or more paths") 9 p.add_argument("rest", nargs=argparse.REMAINDER, help="after --") 10 # example: tool --tag a --tag b --ids 1 2 3 -- --weird 11 print(p.parse_args())
Parent Parsers & Sharing Flags
Copy 1 import argparse 2 3 common = argparse.ArgumentParser(add_help=False) 4 common.add_argument("-v", "--verbose", action="store_true") 5 common.add_argument("--config", default="config.toml") 6 7 main = argparse.ArgumentParser(parents=[common]) 8 sub = main.add_subparsers(dest="cmd", required=True) 9 sub.add_parser("build", parents=[common], help="build artifact") 10 sub.add_parser("deploy", parents=[common], help="deploy artifact") 11 print(main.parse_args(["build", "-v"]))
$ Blueprint — Engineering Documentation · Section ID: PYTHON-ARGPARSE · Revision: 1.0