Skip to content
VLSI Mentor

Python Part II · Module 22 · Building Command-Line VLSI Tools

Help and Error Messages Another Engineer Can Act On

argparse already produces good errors for the mistakes it knows about — a missing argument, a bad integer, a value outside a declared list. Everything else is yours, and the default behaviour of a script meeting a value it does not like is to fail somewhere downstream with a message about Python rather than about the command. The difference between the two is not politeness. It decides whether the colleague who mistyped a simulator name fixes it in four seconds or spends twenty minutes deciding your tool is broken, and then tells you so.

Intermediate17 min readPythonPart IIAutomationVerification FlowTooling

Part II · Module 22 · Chapter 22.3 · Help and Error Messages Another Engineer Can Act On

1. The Same Typo, Twice

A colleague types --sim vcss. Here is what two scripts do about it:

Azvya Education Pvt. Ltd.VLSI Mentor
raw.py
import argparse

RUNNERS = {"vcs": "run_vcs", "xrun": "run_xrun", "vsim": "run_vsim"}

parser = argparse.ArgumentParser(prog="raw.py")
parser.add_argument("--sim", default="vcs")
args = parser.parse_args()

print("launching", RUNNERS[args.sim])
Azvya Education Pvt. Ltd.VLSI Mentor
helpful.py
import argparse

SUPPORTED = ["vcs", "xrun", "vsim"]
RUNNERS = {"vcs": "run_vcs", "xrun": "run_xrun", "vsim": "run_vsim"}

parser = argparse.ArgumentParser(prog="helpful.py")
parser.add_argument("--sim", default="vcs")
args = parser.parse_args()

if args.sim not in SUPPORTED:
    parser.error(f"unknown simulator {args.sim!r} — choose: {', '.join(SUPPORTED)}")

print("launching", RUNNERS[args.sim])
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
import sys

for script in ["raw.py", "helpful.py"]:
    done = subprocess.run([sys.executable, script, "--sim", "vcss"],
                          capture_output=True, text=True)
    body = (done.stdout.strip() or done.stderr.strip())
    print(f"$ {script} --sim vcss")
    print(f"   rc={done.returncode}")
    print(f"   {body.splitlines()[-1]}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ raw.py --sim vcss
   rc=1
   KeyError: 'vcss'
$ helpful.py --sim vcss
   rc=2
   helpful.py: error: unknown simulator 'vcss' — choose: vcs, xrun, vsim

Both are true. Only one of them is about the command the user typed.

KeyError: 'vcss' names a Python concept, arrives with a traceback pointing into raw.py, and says nothing about what was expected. The reasonable reading, for somebody who is not the author, is this tool is broken — and the reasonable next action is to tell you so. The canonical purpose for this chapter is exactly that: the gap between the two messages decides whether your colleague fixes their command or files a bug against you.

The second cost four lines.

2. What Makes a Message Actionable

Those two outputs differ in four specific ways, and the list generalises:

raw.pyhelpful.py
names the thing that was wrongno — names a dict lookupunknown simulator
shows the value as typed'vcss', by accident'vcss', deliberately, with !r
says what is allowednochoose: vcs, xrun, vsim
says whose fault it isrc=1, with a tracebackrc=2, no traceback

The fourth row is the one people leave out. A traceback is a statement that the program went wrong; its absence, plus argparse's exit status 2, says the command went wrong. Chapter 22.4 turns that distinction into a contract; here it is the difference between a user reading a stack trace and a user reading a sentence.

The !r matters more than it looks. unknown simulator vcss and unknown simulator 'vcss ' look identical in prose and only one of them explains itself — Chapter 21.7 Section 3's argument, on a command line where a trailing space from a shell variable is entirely possible.

3. parser.error

Azvya Education Pvt. Ltd.VLSI Mentor
perr.py
import argparse

SUPPORTED = ["vcs", "xrun", "vsim"]

parser = argparse.ArgumentParser(prog="perr.py")
parser.add_argument("--sim", default="vcs")
args = parser.parse_args()

if args.sim not in SUPPORTED:
    parser.error(f"unknown simulator {args.sim!r} — choose: {', '.join(SUPPORTED)}")

print("ok")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
import sys

done = subprocess.run([sys.executable, "perr.py", "--sim", "vcss"],
                      capture_output=True, text=True)
print("rc        :", done.returncode)
print("stdout    :", repr(done.stdout))
print("stderr    :")
for line in done.stderr.strip().splitlines():
    print("   " + line)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
rc        : 2
stdout    : ''
stderr    :
   usage: perr.py [-h] [--sim SIM]
   perr.py: error: unknown simulator 'vcss' — choose: vcs, xrun, vsim

That is the canonical annotation:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
p.error(f"unknown simulator {name!r} — choose: {', '.join(SUPPORTED)}")

parser.error does four things in one call, and doing them by hand is what people get wrong:

It prints the usage line first, so a user who got the flag name wrong sees the right ones without asking.

It prefixes the message with the program name, which matters when the script is one step of a pipeline and the output is interleaved with other tools'.

It writes to stderr and leaves stdout empty — Chapter 22.5's discipline, and the reason a pipeline reading this tool's stdout gets nothing rather than half a result.

It exits 2, matching every error argparse raises itself, so a caller cannot tell your checks from the library's and does not need to.

The alternative people write is print(...) followed by sys.exit(1), which gets none of the four right.

4. A Type Function: Convert and Validate Together

parser.error runs after parsing. For a value that is wrong on its own, the check belongs in the declaration — as a function passed to type=:

Azvya Education Pvt. Ltd.VLSI Mentor
tf.py
import argparse

SUPPORTED = ["vcs", "xrun", "vsim"]


def positive_int(text):
    """A whole number greater than zero, or an argparse error."""
    try:
        value = int(text)
    except ValueError:
        raise argparse.ArgumentTypeError(f"{text!r} is not a whole number") from None
    if value <= 0:
        raise argparse.ArgumentTypeError(f"must be positive, got {value}")
    return value


def simulator(text):
    """One of the supported simulators."""
    if text in SUPPORTED:
        return text
    raise argparse.ArgumentTypeError(
        f"unknown simulator {text!r} — choose: {', '.join(SUPPORTED)}")


parser = argparse.ArgumentParser(prog="tf.py")
parser.add_argument("--seed", type=positive_int, default=1, metavar="N")
parser.add_argument("--sim", type=simulator, default="vcs", metavar="SIM")

args = parser.parse_args()
print(vars(args))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
import sys

for extra in (["--seed", "4"], ["--seed", "abc"], ["--seed", "-5"], ["--sim", "vcss"]):
    done = subprocess.run([sys.executable, "tf.py"] + extra, capture_output=True, text=True)
    body = (done.stdout.strip() or done.stderr.strip()).splitlines()[-1]
    print(f"$ tf.py {' '.join(extra):<14} rc={done.returncode}  {body}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ tf.py --seed 4       rc=0  {'seed': 4, 'sim': 'vcs'}
$ tf.py --seed abc     rc=2  tf.py: error: argument --seed: 'abc' is not a whole number
$ tf.py --seed -5      rc=2  tf.py: error: argument --seed: must be positive, got -5
$ tf.py --sim vcss     rc=2  tf.py: error: argument --sim: unknown simulator 'vcss' — choose: vcs, xrun, vsim

A type function is an ordinary function taking the user's string and returning the value you want. Three things make it the right home for a per-value check.

argparse prefixes argument --seed: for you. The function's message says what is wrong with the value; argparse says which argument it was. Neither has to know about the other.

It runs during parsing, so the error appears before any other argument is processed and before a line of your script runs.

It replaces both the conversion and the check, so there is no window in which args.seed holds an unvalidated string.

The exception type matters. argparse.ArgumentTypeError produces the message you wrote; any other exception produces a generic one:

Azvya Education Pvt. Ltd.VLSI Mentor
tf2.py
import argparse


def picky(text):
    if not text.isdigit():
        raise ValueError(f"{text!r} is not a whole number")
    return int(text)


def polite(text):
    if not text.isdigit():
        raise argparse.ArgumentTypeError(f"{text!r} is not a whole number")
    return int(text)


parser = argparse.ArgumentParser(prog="tf2.py")
parser.add_argument("--a", type=picky)
parser.add_argument("--b", type=polite)
parser.parse_args()
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
import sys

for extra in (["--a", "x"], ["--b", "x"]):
    done = subprocess.run([sys.executable, "tf2.py"] + extra, capture_output=True, text=True)
    print(f"$ tf2.py {' '.join(extra):<8} {done.stderr.strip().splitlines()[-1]}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ tf2.py --a x    tf2.py: error: argument --a: invalid picky value: 'x'
$ tf2.py --b x    tf2.py: error: argument --b: 'x' is not a whole number

invalid picky value: 'x' leaks the function's name into a user-facing message and says nothing about what picky wanted. ArgumentTypeError is what carries your wording through.

Note from None on the re-raise in tf.py. Without it the user would see the original ValueError chained beneath — Chapter 21.5 Section 8's form, for the same reason: the second message says everything the first does.

5. Saying What They Probably Meant

Chapter 21.7 Section 6 turned a config enumeration error into a fix with difflib. The same call works on an argument:

Azvya Education Pvt. Ltd.VLSI Mentor
dym.py
import argparse
import difflib

SUPPORTED = ["vcs", "xrun", "vsim"]


def simulator(text):
    if text in SUPPORTED:
        return text
    close = difflib.get_close_matches(text.lower(), SUPPORTED, n=1, cutoff=0.6)
    hint = f"did you mean {close[0]!r}?" if close else f"choose: {', '.join(SUPPORTED)}"
    raise argparse.ArgumentTypeError(f"unknown simulator {text!r} — {hint}")


parser = argparse.ArgumentParser(prog="dym.py")
parser.add_argument("--sim", type=simulator, default="vcs")
parser.parse_args()
print("ok")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
import sys

for value in ["vcs", "vcss", "vsym", "VCS", "icarus"]:
    done = subprocess.run([sys.executable, "dym.py", "--sim", value],
                          capture_output=True, text=True)
    body = (done.stdout.strip() or done.stderr.strip()).splitlines()[-1]
    print(f"$ dym.py --sim {value:<8} {body}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ dym.py --sim vcs      ok
$ dym.py --sim vcss     dym.py: error: argument --sim: unknown simulator 'vcss' — did you mean 'vcs'?
$ dym.py --sim vsym     dym.py: error: argument --sim: unknown simulator 'vsym' — did you mean 'vsim'?
$ dym.py --sim VCS      dym.py: error: argument --sim: unknown simulator 'VCS' — did you mean 'vcs'?
$ dym.py --sim icarus   dym.py: error: argument --sim: unknown simulator 'icarus' — choose: vcs, xrun, vsim

Three of the four mistakes get a specific suggestion, including the upper-case one — because text.lower() is passed to get_close_matches, which is the normalisation Chapter 21.7 Section 6 added after finding the case-sensitivity gap.

The fallback matters as much as the suggestion. icarus is not a typo of anything, so there is nothing to suggest, and the message lists the options instead. A hint that is sometimes absent still needs the message to be complete without it.

6. When Two Arguments Are Wrong Together

A type function sees one value. Chapter 21.7 Section 8's fourth kind of check needs two, and that is what parser.error after parsing is for:

Azvya Education Pvt. Ltd.VLSI Mentor
rel.py
import argparse

parser = argparse.ArgumentParser(prog="rel.py")
parser.add_argument("--waves", action="store_true")
parser.add_argument("--wave-file", metavar="PATH")
parser.add_argument("--sim", default="vcs", choices=["vcs", "xrun", "verilator"])

args = parser.parse_args()

if args.waves and args.wave_file is None:
    parser.error("--waves needs --wave-file")
if args.wave_file and not args.waves:
    parser.error("--wave-file given but --waves not set — did you mean to add --waves?")
if args.wave_file and args.wave_file.endswith(".fsdb") and args.sim == "verilator":
    parser.error(f"--sim verilator cannot write {args.wave_file!r} (.fsdb is not supported)")

print("ok", vars(args))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
import sys

CASES = [
    ["--waves", "--wave-file", "dump.fsdb"],
    ["--waves"],
    ["--wave-file", "dump.fsdb"],
    ["--waves", "--wave-file", "dump.fsdb", "--sim", "verilator"],
]

for args in CASES:
    done = subprocess.run([sys.executable, "rel.py"] + args, capture_output=True, text=True)
    body = (done.stdout.strip() or done.stderr.strip()).splitlines()[-1]
    print(f"$ rel.py {' '.join(args):<46} rc={done.returncode}")
    print(f"   {body}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ rel.py --waves --wave-file dump.fsdb                  rc=0
   ok {'waves': True, 'wave_file': 'dump.fsdb', 'sim': 'vcs'}
$ rel.py --waves                                        rc=2
   rel.py: error: --waves needs --wave-file
$ rel.py --wave-file dump.fsdb                          rc=2
   rel.py: error: --wave-file given but --waves not set — did you mean to add --waves?
$ rel.py --waves --wave-file dump.fsdb --sim verilator  rc=2
   rel.py: error: --sim verilator cannot write 'dump.fsdb' (.fsdb is not supported)

Every argument in cases 2, 3 and 4 is individually valid. The combinations are not, and no type= function could see it.

The third message is worth copying as a pattern. --wave-file without --waves is probably a user who thinks the file implies the flag, so the message does not just refuse — it names the likely intent and lets them confirm it. That costs eight words and removes a round trip.

So the rule for where a check goes:

  • One value, wrong on its own → a type= function. It runs during parsing and argparse names the argument.
  • Two or more values, wrong together → parser.error after parse_args().
  • A value from a fixed list, with no suggestion needed → choices=, and write nothing.

7. Help That Answers the Next Question

Azvya Education Pvt. Ltd.VLSI Mentor
full.py
import argparse

parser = argparse.ArgumentParser(
    prog="run_sim.py",
    description="Run one simulation and report PASS or FAIL.",
    epilog="example: run_sim.py --test axi_smoke --seed 1839274 --sim vcs",
)
parser.add_argument("--test", required=True, metavar="NAME", help="UVM test name")
parser.add_argument("--seed", type=int, default=1, metavar="N",
                    help="random seed (default: %(default)s)")
parser.add_argument("--sim", default="vcs", choices=["vcs", "xrun", "vsim"],
                    help="simulator (default: %(default)s)")
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, "full.py", "-h"], capture_output=True, text=True)
print(done.stdout)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
usage: run_sim.py [-h] --test NAME [--seed N] [--sim {vcs,xrun,vsim}]
                  [--waves]

Run one simulation and report PASS or FAIL.

options:
  -h, --help            show this help message and exit
  --test NAME           UVM test name
  --seed N              random seed (default: 1)
  --sim {vcs,xrun,vsim}
                        simulator (default: vcs)
  --waves               dump waves

example: run_sim.py --test axi_smoke --seed 1839274 --sim vcs

Three additions over Chapter 22.2's help, each answering a question a user has immediately after reading the flag list.

%(default)s puts the default into the help text, and argparse substitutes it — so the documented default cannot drift from the declared one. Writing (default: 1) by hand is the same drift problem Chapter 22.2 Section 3 described, one level down.

metavar="NAME" replaces the shouted TEST with something meaningful. Without it argparse uses the upper-cased flag name, which for --test reads as a placeholder repeated back rather than a hint. --seed N and --wave-file PATH say what kind of thing goes there.

epilog is one worked command. For a tool with four flags, an example is worth more than any amount of per-flag prose, because it shows the flags together — which is the thing the option list cannot do.

8. Three Things Not to Do

Azvya Education Pvt. Ltd.VLSI Mentor
bad.py
import argparse
import sys

parser = argparse.ArgumentParser(prog="bad.py")
parser.add_argument("--seed", default="1")
args = parser.parse_args()

try:
    seed = int(args.seed)
except Exception:
    print("bad seed")
    sys.exit(1)

print("seed", seed)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
import sys

for extra in (["--seed", "4"], ["--seed", "abc"]):
    done = subprocess.run([sys.executable, "bad.py"] + extra, capture_output=True, text=True)
    print(f"$ bad.py {' '.join(extra):<14} rc={done.returncode}  "
          f"stdout={done.stdout.strip()!r}  stderr={done.stderr.strip()!r}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ bad.py --seed 4       rc=0  stdout='seed 4'  stderr=''
$ bad.py --seed abc     rc=1  stdout='bad seed'  stderr=''

Three problems in six lines.

The message went to stdout. A pipeline reading this tool's output receives the string bad seed where it expected a result — Chapter 22.5's subject, and the reason parser.error writes to stderr.

The exit status is 1, not 2. A caller distinguishing the command was wrong from the work failed now cannot. Chapter 22.4.

except Exception with a message that drops the value. bad seed does not say which seed, does not say what was wanted, and would also catch a KeyboardInterrupt-adjacent surprise from something unrelated. type=positive_int from Section 4 replaces all six lines and gets every part right.

A fourth, which does not show in output: the message is too short to act on. bad seed tells a user that something about a seed was unacceptable and nothing else. The checklist from Section 2 is the test — name the thing, show the value, say what is allowed.

9. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
po3.py
import argparse


def even(text):
    value = int(text)
    if value % 2:
        raise argparse.ArgumentTypeError(f"{value} is odd")
    return value


def rough(text):
    return int(text) * 2


parser = argparse.ArgumentParser(prog="po3.py")
parser.add_argument("--e", type=even)
parser.add_argument("--r", type=rough)
parser.add_argument("--c", choices=["a", "b"])
args = parser.parse_args()
print(vars(args))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
import sys

for extra in (["--e", "4"], ["--e", "3"], ["--e", "x"], ["--r", "x"], ["--c", "z"], []):
    done = subprocess.run([sys.executable, "po3.py"] + extra, capture_output=True, text=True)
    body = (done.stdout.strip() or done.stderr.strip()).splitlines()[-1]
    print(f"$ po3.py {' '.join(extra) or '(nothing)':<14} rc={done.returncode}  {body}")

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ po3.py --e 4          rc=0  {'e': 4, 'r': None, 'c': None}
$ po3.py --e 3          rc=2  po3.py: error: argument --e: 3 is odd
$ po3.py --e x          rc=2  po3.py: error: argument --e: invalid even value: 'x'
$ po3.py --r x          rc=2  po3.py: error: argument --r: invalid rough value: 'x'
$ po3.py --c z          rc=2  po3.py: error: argument --c: invalid choice: 'z' (choose from a, b)
$ po3.py (nothing)      rc=0  {'e': None, 'r': None, 'c': None}

Row 2 — ArgumentTypeError carried the wording through, prefixed by the argument name.

Row 3 — the same function's uncaught ValueError from int("x") produced invalid even value, leaking the function name. A type function has to catch its own conversion errors, which is what Section 4's positive_int does.

Row 4 — the same for rough, which never tried.

Row 5 — choices gives a good message with no code.

Row 6 — an argument with no default and no value is None, not absent.

10. Exercises

Exercise 1 — two messages. Write the raw.py and helpful.py pair for a value in your own tool. Record both exit statuses and both last lines.

Exercise 2 — the four-row checklist. Score an error message from a tool you did not write against Section 2's four rows.

Exercise 3 — the quoting. Produce an error for a value with a trailing space, with and without !r. Say which you could debug.

Exercise 4 — parser.error. Replace a print plus sys.exit with parser.error and list the four things that changed.

Exercise 5 — a type function. Write one that converts and validates, and confirm argparse prefixes it with the argument name.

Exercise 6 — the wrong exception. Raise ValueError from a type function and compare the message with the ArgumentTypeError version.

Exercise 7 — catch your own conversion. Pass a non-numeric value to a type function that calls int() unguarded. Record what the user sees and fix it.

Exercise 8 — did you mean. Add difflib to a type function. Test four misspellings, including one in the wrong case and one that is not a typo of anything.

Exercise 9 — complete without the hint. Say why the fallback message matters more than the suggestion.

Exercise 10 — choices or a function. Name one argument in your tool where choices= is enough and one where it is not. Justify both.

Exercise 11 — two arguments together. Write a parser.error check for a combination your tool cannot run. Make the message name the likely intent.

Exercise 12 — help that does not drift. Add %(default)s and a metavar to two arguments, then change a default and confirm the help follows with no second edit.

Exercise 13 — the epilog. Write the one example command that best shows your tool's flags working together.

Exercise 14 — three mistakes. Reproduce Section 8's bad.py and fix each of its three problems in turn, confirming each fix separately.

11. Summary

  • The same typo is KeyError: 'vcss' or unknown simulator 'vcss' — choose: vcs, xrun, vsim, and the difference decides whether the user fixes it or reports it.
  • Four things make a message actionable: name the thing, show the value with !r, say what is allowed, and use an exit status that says whose fault it is.
  • A traceback says the program went wrong; its absence plus exit 2 says the command did.
  • parser.error prints usage, prefixes the program name, writes to stderr and exits 2 — four things print plus sys.exit(1) gets wrong.
  • A type= function is the home for a check on one value. It runs during parsing, so no unvalidated value ever reaches your code.
  • Raise argparse.ArgumentTypeError, or argparse prints invalid <function name> value and your wording is lost.
  • Catch your own conversion errors inside a type function, with from None, or an int() failure leaks the generic message.
  • difflib.get_close_matches on a lowered value turns most typos into a suggestion — and the fallback must be complete on its own.
  • choices= is enough when no suggestion is needed and no normalisation or external check is involved.
  • Two individually-valid arguments can be jointly wrong, which only parser.error after parse_args() can see.
  • Name the likely intent when refusing a plausible combination; eight words removes a round trip.
  • %(default)s keeps the documented default from drifting from the declared one.
  • metavar says what kind of thing goes there, where the upper-cased flag name says nothing.
  • An epilog example shows the flags together, which the option list cannot.
  • Never print usage from a string literal — it is correct the day it is written and wrong two months later.

The next chapter is about the number at the end of every one of these runs, and why printing REGRESSION FAILED is not the same as reporting it.

Where this fits

Part of the Python curriculum.