Python Part II · Module 22 · Building Command-Line VLSI Tools
argparse: Required, Optional, Types and Defaults
Chapter 22.1 finished with a scoreboard: a seventeen-line hand-written parser got five of nine behaviours right, and the four it missed were exactly those needing the parser to know what this script's arguments actually are. This chapter supplies that knowledge — six lines of declaration, no parsing logic at all — and collects the other four, plus a help message nobody wrote. It also contains one keyword that reads as obviously correct and quietly does the wrong thing, which is worth more attention than everything that works.
Intermediate17 min readPythonPart IIAutomationVerification FlowTooling
Part II · Module 22 · Chapter 22.2 · argparse: Required, Optional, Types and Defaults
1. Declaring Instead of Parsing
import argparse
parser = argparse.ArgumentParser(prog="run_sim.py", description="Run one simulation.")
parser.add_argument("--test", required=True, help="UVM test name")
parser.add_argument("--seed", type=int, default=1, help="random seed")
parser.add_argument("--sim", default="xcelium", choices=["xcelium", "vcs", "questa"],
help="simulator")
parser.add_argument("--waves", action="store_true", help="dump waves")
args = parser.parse_args()
print(vars(args))
print("seed is", type(args.seed).__name__, "| waves is", type(args.waves).__name__)import subprocess
import sys
for extra in (["--test", "axi_smoke", "--seed", "4"], ["--test=axi_smoke", "--waves"]):
done = subprocess.run([sys.executable, "run_sim.py"] + extra, capture_output=True, text=True)
print(f"$ run_sim.py {' '.join(extra)}")
for line in done.stdout.strip().splitlines():
print(" " + line)$ run_sim.py --test axi_smoke --seed 4
{'test': 'axi_smoke', 'seed': 4, 'sim': 'xcelium', 'waves': False}
seed is int | waves is bool
$ run_sim.py --test=axi_smoke --waves
{'test': 'axi_smoke', 'seed': 1, 'sim': 'xcelium', 'waves': True}
seed is int | waves is boolThere is no parsing code. Four add_argument calls say what the arguments are, and parse_args() does the rest — including the thing Chapter 22.1's parser could not do: seed is an int because the declaration said type=int, not because anybody called int().
Both spellings still work, order still does not matter, and the defaults appeared for the arguments nobody passed.
2. The Scoreboard, Again
Chapter 22.1 Section 7 listed nine behaviours and scored the hand-written parser at five. The same nine, against these declarations:
import argparse
parser = argparse.ArgumentParser(prog="run_sim.py", description="Run one simulation.")
parser.add_argument("--test", required=True, help="UVM test name")
parser.add_argument("--seed", type=int, default=1, help="random seed")
parser.add_argument("--sim", default="xcelium", choices=["xcelium", "vcs", "questa"],
help="simulator")
parser.add_argument("--waves", action="store_true", help="dump waves")
args = parser.parse_args()
print(vars(args))import subprocess
import sys
CASES = [
("accepts --flag value", ["--test", "axi_smoke"]),
("accepts --flag=value", ["--test=axi_smoke"]),
("order does not matter", ["--waves", "--test", "axi_smoke"]),
("rejects a stray value", ["axi_smoke"]),
("rejects a flag with no value", ["--test"]),
("converts --seed to int", ["--test", "a", "--seed", "4"]),
("rejects a misspelled flag", ["--test", "a", "--sedd", "4"]),
("rejects a non-numeric seed", ["--test", "a", "--seed", "abc"]),
("answers -h", ["-h"]),
]
for label, args in CASES:
done = subprocess.run([sys.executable, "run_sim.py"] + args, capture_output=True, text=True)
behaved = done.returncode in (0, 2)
print(f"{label:<30} rc={done.returncode} {'ok' if behaved else 'BAD'}")accepts --flag value rc=0 ok
accepts --flag=value rc=0 ok
order does not matter rc=0 ok
rejects a stray value rc=2 ok
rejects a flag with no value rc=2 ok
converts --seed to int rc=0 ok
rejects a misspelled flag rc=2 ok
rejects a non-numeric seed rc=2 ok
answers -h rc=0 okNine of nine. The four that the hand-written parser silently got wrong — conversion, misspelled flags, validation, and help — are all here, and none of them required a line of parsing.
Notice the exit statuses: 0 for the five that succeeded or printed help, 2 for the four that were rejected. Section 5 is about that number.
3. What Each Keyword Buys
The canonical annotation is two of the declarations:
p.add_argument("--seed", type=int, default=1)
p.add_argument("--waves", action="store_true")Each keyword replaces something the hand-written parser would have needed code for:
| Keyword | Replaces |
|---|---|
type=int | an int() call and a try/except with a message |
default=1 | an entry in a defaults dict |
required=True | a check after parsing, and a message |
choices=[...] | a membership test, and a message listing the options |
action="store_true" | the special case for a flag with no value |
help="..." | the usage text you would have written and let drift |
The last row is the one most worth noticing. help= sits next to the declaration it describes, so adding an argument and documenting it are the same edit — which is why argparse's usage text does not go stale the way a hand-maintained one does. Chapter 22.1 Section 6 listed "no usage text" as a missing behaviour and the real cost was never the writing; it was the drift.
4. The Help Nobody Wrote
import argparse
parser = argparse.ArgumentParser(prog="run_sim.py", description="Run one simulation.")
parser.add_argument("--test", required=True, help="UVM test name")
parser.add_argument("--seed", type=int, default=1, help="random seed")
parser.add_argument("--sim", default="xcelium", choices=["xcelium", "vcs", "questa"],
help="simulator")
parser.add_argument("--waves", action="store_true", help="dump waves")
parser.parse_args()import subprocess
import sys
done = subprocess.run([sys.executable, "run_sim.py", "-h"], capture_output=True, text=True)
print("rc =", done.returncode)
print(done.stdout)rc = 0
usage: run_sim.py [-h] --test TEST [--seed SEED] [--sim {xcelium,vcs,questa}]
[--waves]
Run one simulation.
options:
-h, --help show this help message and exit
--test TEST UVM test name
--seed SEED random seed
--sim {xcelium,vcs,questa}
simulator
--waves dump wavesFour things in that output came from the declarations and none from a help string anybody typed.
The usage line shows --test TEST without brackets because it is required, and everything else in brackets because it is not. A reader learns which arguments are mandatory from the shape of one line.
{xcelium,vcs,questa} is the choices list. The allowed values are documented because they were declared.
-h and --help both exist, and -h exits 0 — asking for help is not an error, which matters for a script in a Makefile.
description= is the one sentence a reader needs before the flags, and prog= is the name shown. Without prog, argparse uses sys.argv[0], which is the path as invoked and can be long and machine-specific; setting it explicitly keeps the help text stable wherever the script is run from.
5. The Error Messages, and Exit Status 2
import argparse
parser = argparse.ArgumentParser(prog="run_sim.py", description="Run one simulation.")
parser.add_argument("--test", required=True, help="UVM test name")
parser.add_argument("--seed", type=int, default=1, help="random seed")
parser.add_argument("--sim", default="xcelium", choices=["xcelium", "vcs", "questa"],
help="simulator")
parser.add_argument("--waves", action="store_true", help="dump waves")
parser.parse_args()
print("ok")import subprocess
import sys
CASES = [
["--seed", "4"],
["--test", "a", "--sedd", "4"],
["--test", "a", "--seed", "abc"],
["--test", "a", "--sim", "icarus"],
]
for args in CASES:
done = subprocess.run([sys.executable, "run_sim.py"] + args, capture_output=True, text=True)
print(f"$ run_sim.py {' '.join(args):<28} rc={done.returncode}")
print(" " + done.stderr.strip().splitlines()[-1])
print(" stdout was:", repr(done.stdout))$ run_sim.py --seed 4 rc=2
run_sim.py: error: the following arguments are required: --test
stdout was: ''
$ run_sim.py --test a --sedd 4 rc=2
run_sim.py: error: unrecognized arguments: --sedd 4
stdout was: ''
$ run_sim.py --test a --seed abc rc=2
run_sim.py: error: argument --seed: invalid int value: 'abc'
stdout was: ''
$ run_sim.py --test a --sim icarus rc=2
run_sim.py: error: argument --sim: invalid choice: 'icarus' (choose from xcelium, vcs, questa)
stdout was: ''Those four messages are the baseline Chapter 22.3 improves on, so they are worth reading carefully. Each one names the argument, shows the value with repr-style quoting, and for choices lists the alternatives. The misspelled flag that Chapter 22.1's parser accepted as a new key is now unrecognized arguments: --sedd 4 — and note that it names the value too, which is what tells the user the flag swallowed something.
Three structural facts matter as much as the wording.
Every rejection exits 2. Not 1. Chapter 22.4 makes that a contract across the flow; for now it is argparse's convention and worth matching rather than fighting.
The message goes to stderr and stdout stays empty. That is Chapter 22.5's discipline, already observed: a script in a pipeline produces nothing on stdout when its arguments are wrong, so the thing reading it gets no half-result.
Usage is printed before the error. The full usage line is on stderr above the line shown here, so a user who gets an error also gets the spelling of every flag without asking.
6. type=bool Is a Trap
One keyword that looks exactly right:
import argparse
parser = argparse.ArgumentParser(prog="bt.py")
parser.add_argument("--waves", type=bool, default=False)
parser.add_argument("--dump", action="store_true")
args = parser.parse_args()
print(f"waves={args.waves!r} dump={args.dump!r}")import subprocess
import sys
for extra in ([], ["--waves", "True"], ["--waves", "False"], ["--waves", ""], ["--dump"]):
done = subprocess.run([sys.executable, "bt.py"] + extra, capture_output=True, text=True)
shown = " ".join(repr(x) for x in extra) or "(nothing)"
print(f"$ bt.py {shown:<22} {done.stdout.strip()}")$ bt.py (nothing) waves=False dump=False
$ bt.py '--waves' 'True' waves=True dump=False
$ bt.py '--waves' 'False' waves=True dump=False
$ bt.py '--waves' '' waves=False dump=False
$ bt.py '--dump' waves=False dump=TrueRead the third line. --waves False sets waves to True.
type=bool means call bool() on the string the user typed, and bool("False") is True because a non-empty string is truthy — Chapter 9.5. The only input that produces False is the empty string, which nobody types on purpose.
This is the same fact Chapter 21.1 Section 6 found going the other way: a boolean written to a CSV comes back as 'True' or 'False', and both are truthy. A boolean does not survive a round trip through text unless somebody converts it deliberately, and type=bool is not that conversion.
action="store_true" is the answer, and it is what the canonical annotation uses:
p.add_argument("--waves", action="store_true")The flag takes no value at all. Present means True, absent means False, and there is no string for bool() to mishandle. For the opposite default there is action="store_false", and for a flag that can be turned off again:
import argparse
parser = argparse.ArgumentParser(prog="pair.py")
parser.add_argument("--waves", action="store_true", default=False)
parser.add_argument("--no-waves", dest="waves", action="store_false")
args = parser.parse_args()
print(f"waves={args.waves!r}")import subprocess
import sys
for extra in ([], ["--waves"], ["--no-waves"], ["--waves", "--no-waves"]):
done = subprocess.run([sys.executable, "pair.py"] + extra, capture_output=True, text=True)
print(f"$ pair.py {' '.join(extra) or '(nothing)':<22} {done.stdout.strip()}")$ pair.py (nothing) waves=False
$ pair.py --waves waves=True
$ pair.py --no-waves waves=False
$ pair.py --waves --no-waves waves=Falsedest="waves" makes both flags write to the same attribute, so the last one on the command line wins — which is what a user expects, and what lets a wrapper script append --no-waves to a command it did not write.
7. What parse_args Hands Back
import argparse
parser = argparse.ArgumentParser(prog="ns.py")
parser.add_argument("--test", default="axi_smoke")
parser.add_argument("--max-errors", type=int, default=10)
parser.add_argument("--waves", action="store_true")
args = parser.parse_args()
print("type :", type(args).__name__)
print("attribute :", args.test)
print("dashes :", args.max_errors)
print("as a dict :", vars(args))
print("has 'test' :", hasattr(args, "test"), "| has 'nope':", hasattr(args, "nope"))import subprocess
import sys
done = subprocess.run([sys.executable, "ns.py", "--max-errors", "3"],
capture_output=True, text=True)
print("$ ns.py --max-errors 3")
for line in done.stdout.strip().splitlines():
print(" " + line)$ ns.py --max-errors 3
type : Namespace
attribute : axi_smoke
dashes : 3
as a dict : {'test': 'axi_smoke', 'max_errors': 3, 'waves': False}
has 'test' : True | has 'nope': Falseparse_args() returns a Namespace — an object whose attributes are the arguments. Two details catch people.
--max-errors becomes args.max_errors. Dashes are not legal in an attribute name, so argparse converts them to underscores. The flag a user types and the name your code reads are deliberately different, and dest= overrides the conversion when you want a third spelling.
vars(args) gives a plain dict, which is how a Namespace joins the rest of this curriculum — Chapter 21.6's config layering takes dicts, and the command line is just another layer:
DEFAULTS = {"sim": "xcelium", "timeout_s": 3600, "waves": False, "seeds": 1}
FROM_FILE = {"timeout_s": 7200}
FROM_CLI = {"waves": True, "seeds": 16}
effective = {**DEFAULTS, **FROM_FILE, **FROM_CLI}
for key in sorted(effective):
print(f" {key:<10} {effective[key]!r}") seeds 16
sim 'xcelium'
timeout_s 7200
waves TrueThat is Chapter 21.6's one-liner with vars(args) as the last and most authoritative layer, which is the natural place for it: a flag typed tonight should beat a file written last quarter.
There is one wrinkle, and it is worth knowing before you try it. A Namespace contains every declared argument, including those the user did not pass — they are there with their defaults. So {**file_cfg, **vars(args)} lets argparse's defaults overwrite the file, which is the opposite of what you want. The fix is to declare default=None on the arguments that may come from a file, and filter:
FROM_FILE = {"timeout_s": 7200, "sim": "vcs"}
NAMESPACE = {"timeout_s": None, "sim": None, "waves": True}
naive = {**FROM_FILE, **NAMESPACE}
filtered = {**FROM_FILE, **{k: v for k, v in NAMESPACE.items() if v is not None}}
print("naive :", dict(sorted(naive.items())))
print("filtered:", dict(sorted(filtered.items())))naive : {'sim': None, 'timeout_s': None, 'waves': True}
filtered: {'sim': 'vcs', 'timeout_s': 7200, 'waves': True}The naive merge threw away both file settings, because None is a value like any other — Chapter 21.6 Section 7's ambiguity, arriving from argparse rather than from a config file.
8. More Than One Value
Two declarations cover almost every remaining case:
import argparse
parser = argparse.ArgumentParser(prog="multi.py")
parser.add_argument("--tag", action="append", default=[], help="repeatable")
parser.add_argument("--tests", nargs="+", default=[], help="one or more test names")
parser.add_argument("--seed", type=int, nargs="*", default=[1], help="zero or more seeds")
args = parser.parse_args()
print("tag :", args.tag)
print("tests:", args.tests)
print("seed :", args.seed, [type(s).__name__ for s in args.seed])import subprocess
import sys
CASES = [
["--tag", "nightly", "--tag", "gls"],
["--tests", "axi_smoke", "axi_burst"],
["--seed", "1", "2", "3"],
[],
]
for args in CASES:
done = subprocess.run([sys.executable, "multi.py"] + args, capture_output=True, text=True)
print(f"$ multi.py {' '.join(args) or '(nothing)'}")
for line in done.stdout.strip().splitlines():
print(" " + line)$ multi.py --tag nightly --tag gls
tag : ['nightly', 'gls']
tests: []
seed : [1] ['int']
$ multi.py --tests axi_smoke axi_burst
tag : []
tests: ['axi_smoke', 'axi_burst']
seed : [1] ['int']
$ multi.py --seed 1 2 3
tag : []
tests: []
seed : [1, 2, 3] ['int', 'int', 'int']
$ multi.py (nothing)
tag : []
tests: []
seed : [1] ['int']action="append" collects a flag repeated several times — --tag nightly --tag gls. It is the right choice when each value is a separate decision, and when a wrapper script may add one to a command it did not build.
nargs="+" takes one or more values after a single flag — --tests a b c. Shorter to type, and it has a sharp edge: it consumes everything up to the next flag, so --tests a b --waves works and run_sim.py --tests a b trailing_positional does not do what you hoped.
type= applies to each element, which the third case shows: three integers, not one string.
default=[] on both is deliberate — without it the default is None, and every consumer then needs for tag in (args.tag or []):. Chapter 20.6's always-present rule, on a command line.
9. Predict, Run, Observe
import argparse
parser = argparse.ArgumentParser(prog="po.py")
parser.add_argument("--n", type=int, default=0)
parser.add_argument("--flag", action="store_true")
parser.add_argument("--b", type=bool, default=False)
parser.add_argument("--max-jobs", type=int, default=1)
args = parser.parse_args()
print(vars(args))import subprocess
import sys
CASES = [[], ["--n", "5"], ["--flag"], ["--b", "False"], ["--max-jobs", "8"], ["--n", "x"]]
for args in CASES:
done = subprocess.run([sys.executable, "po.py"] + args, capture_output=True, text=True)
shown = done.stdout.strip() or done.stderr.strip().splitlines()[-1]
print(f"$ po.py {' '.join(args) or '(nothing)':<18} rc={done.returncode} {shown}")The verified output:
$ po.py (nothing) rc=0 {'n': 0, 'flag': False, 'b': False, 'max_jobs': 1}
$ po.py --n 5 rc=0 {'n': 5, 'flag': False, 'b': False, 'max_jobs': 1}
$ po.py --flag rc=0 {'n': 0, 'flag': True, 'b': False, 'max_jobs': 1}
$ po.py --b False rc=0 {'n': 0, 'flag': False, 'b': True, 'max_jobs': 1}
$ po.py --max-jobs 8 rc=0 {'n': 0, 'flag': False, 'b': False, 'max_jobs': 8}
$ po.py --n x rc=2 po.py: error: argument --n: invalid int value: 'x'Row 1 — every declared argument is present with its default, even untouched ones.
Row 2 — type=int converted; the value is 5 and not '5'.
Row 3 — store_true needs no value.
Row 4 — --b False gives True. Section 6, in one row.
Row 5 — the flag is --max-jobs and the attribute is max_jobs.
Row 6 — a bad value is rc=2 with a message naming the argument, and nothing on stdout.
10. Exercises
Exercise 1 — declare four. Write the parser from Section 1 for a tool of your own and run it with two arguments supplied and two defaulted.
Exercise 2 — the scoreboard. Run Chapter 22.1's nine cases against your parser and record the exit status of each.
Exercise 3 — one keyword each. For each of type, default, required, choices and action, remove it and record what breaks.
Exercise 4 — help that cannot drift. Add a fifth argument with a help= string and confirm it appears in -h with no other edit.
Exercise 5 — prog. Remove prog= and run the script from another directory. Say what changes in the usage line.
Exercise 6 — four messages. Reproduce the four errors from Section 5. For each, note what the message names and whether stdout was empty.
Exercise 7 — the bool trap. Declare type=bool and pass False. Record the result and explain it using Chapter 9.5.
Exercise 8 — store_true and its pair. Add --waves and --no-waves sharing a dest. Confirm the last flag on the line wins.
Exercise 9 — Namespace to dict. Convert with vars() and merge it over a config dict. Show the naive merge destroying a file setting, then fix it with default=None and a filter.
Exercise 10 — dashes. Declare a flag with a dash in its name and print the attribute. Say why the two spellings differ.
Exercise 11 — append versus nargs. Collect three values both ways. Say which you would use for tags a wrapper script appends, and why.
Exercise 12 — the empty default. Declare a list argument without default=[] and show what every consumer then has to write.
11. Summary
- Declaring replaces parsing. Four
add_argumentcalls andparse_args()do everything Chapter 22.1's seventeen lines did, plus the four things they could not. - Nine of nine behaviours, including type conversion, misspelled-flag rejection, value validation and help.
- Each keyword replaces code:
typea conversion and its error,requireda check,choicesa membership test,action="store_true"the no-value special case. help=sits next to the declaration, so documenting an argument and adding it are the same edit — which is why the usage text does not drift.- The usage line shows requiredness by its brackets and lists
choicesinline. -hexits 0; asking for help is not an error.- Set
prog=so the help text does not contain the path the script was invoked by. - Every rejection exits 2, writes to stderr, and leaves stdout empty — a contract in Chapter 22.4 and a discipline in Chapter 22.5.
- Errors name the argument and show the value, and
choiceserrors list the alternatives. type=boolis a trap: it callsbool()on the string, andbool("False")isTrue. Only the empty string givesFalse.action="store_true"is the answer, withstore_falseand a shareddestfor an on/off pair where the last flag wins.parse_args()returns aNamespace, dashes become underscores, andvars()gives the plain dict Chapter 21.6's layering wants.- A
Namespaceholds every argument including the defaults, so merging it over a config overwrites the file — declaredefault=Noneand filter. action="append"for a repeatable flag,nargs="+"for several values after one flag, andtype=applies to each element.- Give list arguments
default=[], or every consumer writesor [].
The next chapter is about the gap between an error that is correct and an error a colleague can act on.
Where this fits
Part of the Python curriculum.
