Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
run_sim.py
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__)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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 bool

There 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:

Azvya Education Pvt. Ltd.VLSI Mentor
run_sim.py
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))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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'}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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  ok

Nine 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

KeywordReplaces
type=intan int() call and a try/except with a message
default=1an entry in a defaults dict
required=Truea 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

Azvya Education Pvt. Ltd.VLSI Mentor
run_sim.py
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()
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 waves

Four 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

Azvya Education Pvt. Ltd.VLSI Mentor
run_sim.py
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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:

Azvya Education Pvt. Ltd.VLSI Mentor
bt.py
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}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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()}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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=True

Read 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
pair.py
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}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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()}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ pair.py (nothing)              waves=False
$ pair.py --waves                waves=True
$ pair.py --no-waves             waves=False
$ pair.py --waves --no-waves     waves=False

dest="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

Azvya Education Pvt. Ltd.VLSI Mentor
ns.py
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"))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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': False

parse_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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  seeds      16
  sim        'xcelium'
  timeout_s  7200
  waves      True

That 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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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())))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
multi.py
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])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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

Azvya Education Pvt. Ltd.VLSI Mentor
po.py
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))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ 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_argument calls and parse_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: type a conversion and its error, required a check, choices a 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 choices inline.
  • -h exits 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 choices errors list the alternatives.
  • type=bool is a trap: it calls bool() on the string, and bool("False") is True. Only the empty string gives False.
  • action="store_true" is the answer, with store_false and a shared dest for an on/off pair where the last flag wins.
  • parse_args() returns a Namespace, dashes become underscores, and vars() gives the plain dict Chapter 21.6's layering wants.
  • A Namespace holds every argument including the defaults, so merging it over a config overwrites the file — declare default=None and filter.
  • action="append" for a repeatable flag, nargs="+" for several values after one flag, and type= applies to each element.
  • Give list arguments default=[], or every consumer writes or [].

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.