Skip to content
VLSI Mentor

Python Part II · Module 32 · Python, Tcl and Bash in EDA Flows

Bash Calling Python: Arguments, Exit Status, Environment

This is the boundary every flow has, and the canonical annotation for it is one line that most engineers would pass in review. It gets the hardest part right — the quotation marks — and then loses the failure code, names an interpreter that may not exist, and passes an empty string where it meant to pass nothing. All three interpreters are installed here, so none of that is argued: it is measured, one handoff at a time.

Intermediate20 min readPythonPart IIAutomationBashExit Codes

Part II · Module 32 · Chapter 32.3 · Bash Calling Python: Arguments, Exit Status, Environment

1. Three Channels, One Line

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
CHANNELS = [
    ("arguments", "argv", "ordered strings", "§3, §4, §5"),
    ("environment", "exported variables", "unordered strings", "§6"),
    ("exit status", "one integer, 0-255", "a single number", "§7"),
]

print(f"{'channel':<14} {'carried as':<22} {'shape':<20} covered in")
for name, how, shape, where in CHANNELS:
    print(f"{name:<14} {how:<22} {shape:<20} {where}")

print()
print("channels:", len(CHANNELS))
print("the annotation uses:", "all three, in one line")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
channel        carried as             shape                covered in
arguments      argv                   ordered strings      §3, §4, §5
environment    exported variables     unordered strings    §6
exit status    one integer, 0-255     a single number      §7

channels: 3
the annotation uses: all three, in one line

Three channels, and the annotation touches all three in a single line — which is why it is worth taking apart carefully rather than approving.

2. The Annotation

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
python parse_logs.py "$LOGDIR" || exit 1

The quotation marks around $LOGDIR are the part most people get wrong, and they are correct here. Start with what happens without them:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
from pathlib import Path

Path("argv.py").write_text(
    "import sys\n"
    "print(f'  argc={len(sys.argv) - 1} argv={sys.argv[1:]!r}')\n",
    encoding="utf-8")

Path("s.sh").write_text(
    'LOGDIR="/proj/my runs/nightly"\n'
    'echo "unquoted:"\n'
    'python3 argv.py $LOGDIR\n'
    'echo "quoted:"\n'
    'python3 argv.py "$LOGDIR"\n', encoding="utf-8")

r = subprocess.run(["bash", "s.sh"], stdout=subprocess.PIPE,
                   stderr=subprocess.STDOUT, text=True)
print(r.stdout, end="")
print()
print("the directory name contains:", "one space")
print("the unquoted form passed   :", "two arguments")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
unquoted:
  argc=2 argv=['/proj/my', 'runs/nightly']
quoted:
  argc=1 argv=['/proj/my runs/nightly']

the directory name contains: one space
the unquoted form passed   : two arguments

One space in a path produced two arguments, and a program expecting one would either reject the second or silently ignore it. The annotation's quotes prevent exactly this, and they are the reason the line survives review — everything from Section 3 onwards is about what the quotes do not fix.

3. The Interpreter Might Not Exist

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
from pathlib import Path

Path("s.sh").write_text(
    'for name in python python3 python2; do\n'
    '  if command -v $name >/dev/null 2>&1; then\n'
    '    echo "  $name: found"\n'
    '  else\n'
    '    echo "  $name: NOT FOUND"\n'
    '  fi\n'
    'done\n', encoding="utf-8")

r = subprocess.run(["bash", "s.sh"], stdout=subprocess.PIPE,
                   stderr=subprocess.STDOUT, text=True)
print(r.stdout, end="")
print()
print("the annotation's literal command is:", "python parse_logs.py ...")
print("which on this machine would:", "fail with command not found")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  python: NOT FOUND
  python3: found
  python2: NOT FOUND

the annotation's literal command is: python parse_logs.py ...
which on this machine would: fail with command not found

python does not exist here. On many modern systems it does not: the name was deliberately left unbound after Python 2 so that a script saying python could not silently get the wrong major version.

Now the part that makes this worse than a typo:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
from pathlib import Path

Path("s.sh").write_text(
    'echo "--- bare call"\n'
    'python --version\n'
    'echo "rc=$?"\n'
    'echo "--- through a pipe"\n'
    'python --version 2>&1 | head -1\n'
    'echo "rc=$?"\n'
    'echo "--- pipe with pipefail"\n'
    'set -o pipefail\n'
    'python --version 2>&1 | head -1\n'
    'echo "rc=$?"\n', encoding="utf-8")

r = subprocess.run(["bash", "s.sh"], stdout=subprocess.PIPE,
                   stderr=subprocess.STDOUT, text=True)
for line in r.stdout.splitlines():
    print(" ", line.replace("s.sh: line", "line"))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  --- bare call
  line 2: python: command not found
  rc=127
  --- through a pipe
  line 5: python: command not found
  rc=0
  --- pipe with pipefail
  line 9: python: command not found
  rc=127

The bare call reports 127 and the piped call reports 0. A missing interpreter is invisible to any wrapper that pipes the command's output, because a pipeline's exit status is its last command's — and head succeeded.

That is worth holding onto because piping a tool's output is completely routine: python x.py | tee run.log discards the status of python and reports tee's. Chapter 22.4 established the rule; here is the rule costing a flow its most basic check.

4. || exit 1 Discards the Failure Code

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
from pathlib import Path

Path("rc.py").write_text("import sys\nsys.exit(int(sys.argv[1]))\n",
                         encoding="utf-8")


def run(script):
    Path("s.sh").write_text(script, encoding="utf-8")
    return subprocess.run(["bash", "s.sh"]).returncode


print(f"{'python exits':>13} {'|| exit 1':>11} {'|| exit $?':>12}")
for n in (1, 2, 7, 42):
    a = run(f"python3 rc.py {n} || exit 1\nexit 0\n")
    b = run(f"python3 rc.py {n} || exit $?\nexit 0\n")
    print(f"{n:>13} {a:>11} {b:>12}")

print()
print("distinct codes the flow could have seen:", 4)
print("distinct codes `|| exit 1` lets through:", 1)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
 python exits   || exit 1   || exit $?
            1           1            1
            2           1            2
            7           1            7
           42           1           42

distinct codes the flow could have seen: 4
distinct codes `|| exit 1` lets through: 1

Four distinct failures became one. Chapter 25.6 spent a chapter building a classifier that distinguishes a design failure from a timeout from an infrastructure failure, and Chapter 30.1 Section 4 showed that distinction is the most useful line in a regression report. || exit 1 throws it away at the last hop.

|| exit $? preserves it, and there is an even simpler form worth knowing:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
from pathlib import Path

Path("rc.py").write_text("import sys\nsys.exit(int(sys.argv[1]))\n",
                         encoding="utf-8")

FORMS = {
    "|| exit 1": "python3 rc.py 7 || exit 1\nexit 0\n",
    "|| exit $?": "python3 rc.py 7 || exit $?\nexit 0\n",
    "bare call, bash exits with it": "python3 rc.py 7\n",
    "set -e": "set -e\npython3 rc.py 7\nexit 0\n",
    "explicit capture": "python3 rc.py 7\nrc=$?\n[ $rc -eq 0 ] || exit $rc\nexit 0\n",
}

for label, script in FORMS.items():
    Path("s.sh").write_text(script, encoding="utf-8")
    rc = subprocess.run(["bash", "s.sh"]).returncode
    print(f"  {label:<32} bash exits {rc}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  || exit 1                        bash exits 1
  || exit $?                       bash exits 7
  bare call, bash exits with it    bash exits 7
  set -e                           bash exits 7
  explicit capture                 bash exits 7

Four of the five forms preserve the code. The third is the one people overlook: a script whose last command is the call needs no exit at all, because a shell script's status is its last command's.

The fifth form — capture into a variable, then test — is the one to prefer when anything happens between the call and the exit, which is Section 7's subject.

5. Quoted Is Not the Same as Present

The quotes fix word splitting and introduce a different ambiguity:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
from pathlib import Path

Path("argv.py").write_text(
    "import sys\n"
    "print(f'argc={len(sys.argv) - 1} argv={sys.argv[1:]!r}')\n",
    encoding="utf-8")

CASES = [
    ("set to a path", 'L=/proj/run'),
    ("set to empty", 'L=""'),
    ("unset entirely", 'unset L'),
]

for label, assign in CASES:
    Path("s.sh").write_text(f'{assign}\npython3 argv.py "$L"\n', encoding="utf-8")
    r = subprocess.run(["bash", "s.sh"], stdout=subprocess.PIPE,
                       stderr=subprocess.STDOUT, text=True)
    print(f"  {label:<16} {r.stdout.strip()}")

Path("s.sh").write_text('unset L\npython3 argv.py $L\n', encoding="utf-8")
r = subprocess.run(["bash", "s.sh"], stdout=subprocess.PIPE,
                   stderr=subprocess.STDOUT, text=True)
print(f"  {'unset, unquoted':<16} {r.stdout.strip()}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  set to a path    argc=1 argv=['/proj/run']
  set to empty     argc=1 argv=['']
  unset entirely   argc=1 argv=['']
  unset, unquoted  argc=0 argv=[]

Empty and unset are indistinguishable once quoted — both arrive as one empty-string argument. The program receives [''], not [], so a check like if len(sys.argv) < 2 passes and the real problem surfaces later as an empty path.

The last row is the trap in the other direction: unquoted and unset passes no argument at all, so the two spellings fail differently and neither fails well.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
from pathlib import Path

Path("s.sh").write_text(
    'set -u\n'
    'L=""\n'
    'echo "empty under set -u: [$L]  rc=$?"\n', encoding="utf-8")
r = subprocess.run(["bash", "s.sh"], stdout=subprocess.PIPE,
                   stderr=subprocess.STDOUT, text=True)
print("  ", r.stdout.strip(), " script rc =", r.returncode)

Path("s.sh").write_text('set -u\necho "unset: [$NOPE]"\n', encoding="utf-8")
r = subprocess.run(["bash", "s.sh"], stdout=subprocess.PIPE,
                   stderr=subprocess.STDOUT, text=True)
print("  ", r.stdout.strip().replace("s.sh: line", "line"),
      " script rc =", r.returncode)
print()
print("set -u catches unset :", True)
print("set -u catches empty :", False)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
   empty under set -u: []  rc=0  script rc = 0
   line 2: NOPE: unbound variable  script rc = 1

set -u catches unset : True
set -u catches empty : False

set -u catches the unset case and not the empty one, which is the half most flows actually hit — a variable set from a command that produced nothing. The guard that covers both is a length test:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
from pathlib import Path

Path("s.sh").write_text(
    'require() {\n'
    '  if [ -z "${1:-}" ]; then\n'
    '    echo "  missing or empty: $2" >&2\n'
    '    return 1\n'
    '  fi\n'
    '  return 0\n'
    '}\n'
    'L=""\n'
    'require "$L" LOGDIR || echo "  guard fired, rc=$?"\n'
    'L=/proj/run\n'
    'require "$L" LOGDIR && echo "  guard passed for $L"\n', encoding="utf-8")

r = subprocess.run(["bash", "s.sh"], stdout=subprocess.PIPE,
                   stderr=subprocess.STDOUT, text=True)
print(r.stdout, end="")
print()
print("`[ -z \"${1:-}\" ]` covers:", "unset AND empty")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  missing or empty: LOGDIR
  guard fired, rc=1
  guard passed for /proj/run

`[ -z "${1:-}" ]` covers: unset AND empty

${1:-} is the detail that makes the guard work under set -u: without the :- default, referencing an unset $1 would abort the function before the test ran.

6. An Argument That Looks Like an Option

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
from pathlib import Path

Path("ap.py").write_text(
    "import argparse\n"
    "p = argparse.ArgumentParser(prog='parse_logs.py')\n"
    "p.add_argument('logdir')\n"
    "a = p.parse_args()\n"
    "print(f'  logdir={a.logdir!r}')\n", encoding="utf-8")

for label, args in (("a normal path", ["/proj/run"]),
                    ("an option-like value", ["--verbose"]),
                    ("the same, after --", ["--", "--verbose"])):
    r = subprocess.run(["python3", "ap.py"] + args, stdout=subprocess.PIPE,
                       stderr=subprocess.STDOUT, text=True)
    last = r.stdout.strip().splitlines()[-1] if r.stdout.strip() else "(no output)"
    print(f"  {label:<22} rc={r.returncode}  {last.strip()[:50]}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  a normal path          rc=0  logdir='/proj/run'
  an option-like value   rc=2  parse_logs.py: error: the following arguments are 
  the same, after --     rc=0  logdir='--verbose'

A value beginning with - is parsed as an option, so argparse reports a missing required argument and exits 2 — and the wrapper, if it used || exit 1, reports 1 and loses even that hint.

-- ends option parsing, which is why a careful wrapper writes python3 parse_logs.py -- "$LOGDIR". This is not hypothetical for a flow: a run directory named from a test called --sweep or a path assembled from a field that was empty will produce a leading dash sooner or later.

7. The Environment: Set Is Not Exported

The third channel is the one the canonical purpose asks about directly — which variables the child process actually receives:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
from pathlib import Path

Path("env.py").write_text(
    "import os\n"
    "for k in ('EXPORTED', 'NOT_EXPORTED', 'NEVER_SET'):\n"
    "    v = os.environ.get(k)\n"
    "    state = 'absent' if v is None else f'{v!r}'\n"
    "    print(f'  {k:<14} {state}')\n", encoding="utf-8")

Path("s.sh").write_text(
    'NOT_EXPORTED=plain\n'
    'export EXPORTED=exported\n'
    'echo "  bash sees NOT_EXPORTED as: [$NOT_EXPORTED]"\n'
    'python3 env.py\n', encoding="utf-8")

r = subprocess.run(["bash", "s.sh"], stdout=subprocess.PIPE,
                   stderr=subprocess.STDOUT, text=True)
print(r.stdout, end="")
print()
print("absent is not the same as empty:", "os.environ.get returns None, not ''")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  bash sees NOT_EXPORTED as: [plain]
  EXPORTED       'exported'
  NOT_EXPORTED   absent
  NEVER_SET      absent

absent is not the same as empty: os.environ.get returns None, not ''

Bash sees NOT_EXPORTED and the child does not. This is the commonest "but I set it" bug at this boundary, and the symptom is specific: the variable works when the engineer echoes it in the wrapper and is missing inside Python.

Note the three-way distinction, which matters for the same reason Chapter 29.2 Section 5's None mattered. A variable can be absent, present and empty, or present with a value, and os.environ.get(k) returns None, '' and the value — three different results that a careless os.environ.get(k, "") collapses to two:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import os

ENV = {"EMPTY": "", "SET": "/proj/run"}

for k in ("SET", "EMPTY", "ABSENT"):
    raw = ENV.get(k)
    collapsed = ENV.get(k, "")
    looks_absent = collapsed == ""
    print(f"  {k:<8} get()={raw!r:<12} get(k,'')={collapsed!r:<12} "
          f"looks_absent={looks_absent}")

print()
print("states a variable can be in     :", 3)
print("states that look absent via get(k,''):",
      sum(1 for k in ("SET", "EMPTY", "ABSENT") if ENV.get(k, "") == ""))
print("so a required path needs:", "`k in environ` first, then a truthiness test")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  SET      get()='/proj/run'  get(k,'')='/proj/run'  looks_absent=False
  EMPTY    get()=''           get(k,'')=''           looks_absent=True
  ABSENT   get()=None         get(k,'')=''           looks_absent=True

states a variable can be in     : 3
states that look absent via get(k,''): 2
so a required path needs: `k in environ` first, then a truthiness test

Two of the three states look absent through get(k, ""), and they are exactly the two with different causes: the wrapper exported an empty value and the wrapper forgot to export it. Chapter 24.2's environment discipline applies, and the pre-flight check from Chapter 21.7 is where it belongs.

8. The Boundary, as a Diagram

What crosses from bash into Python, and what comes back

Three blocks left to right. The first, the wrapper written in bash, holds the quoted arguments and the exported variables. An arrow labelled argv, env leads to the second, the Python program, which receives sys.argv and os.environ. An arrow labelled status leads to the third, the shell's dollar-question-mark, which holds one integer from 0 to 255.run.shquoted args, exportspython3argv, environ$?one int, 0-255argv envstatus12
Two channels go in and one comes back, and the return channel is a single integer between 0 and 255. Every defect in this chapter is a value that was altered or lost at one of these three crossings — the exit status most often, because it is the narrowest.

9. The Handoff, Assembled

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
from pathlib import Path

Path("parse_logs.py").write_text(
    'import os\n'
    'import sys\n'
    'import argparse\n'
    '\n'
    'REQUIRED_ENV = ("PROJ_ROOT",)\n'
    'EXIT_OK, EXIT_USAGE, EXIT_ENV, EXIT_FAILURES = 0, 2, 3, 1\n'
    '\n'
    'p = argparse.ArgumentParser(prog="parse_logs.py")\n'
    'p.add_argument("logdir")\n'
    'args = p.parse_args()\n'
    '\n'
    'missing = [k for k in REQUIRED_ENV if k not in os.environ]\n'
    'empty = [k for k in REQUIRED_ENV if os.environ.get(k) == ""]\n'
    'if missing or empty:\n'
    '    print(f"env: missing={missing} empty={empty}", file=sys.stderr)\n'
    '    sys.exit(EXIT_ENV)\n'
    'if not args.logdir:\n'
    '    print("logdir is empty", file=sys.stderr)\n'
    '    sys.exit(EXIT_USAGE)\n'
    '\n'
    'print(f"parsing {args.logdir} under {os.environ[\'PROJ_ROOT\']}")\n'
    'failures = 2 if "bad" in args.logdir else 0\n'
    'print(f"failures: {failures}")\n'
    'sys.exit(EXIT_FAILURES if failures else EXIT_OK)\n',
    encoding="utf-8")

Path("run.sh").write_text(
    '#!/bin/bash\n'
    'set -u\n'
    'PY=python3\n'
    'command -v "$PY" >/dev/null 2>&1 || { echo "no $PY" >&2; exit 127; }\n'
    '\n'
    'if [ -z "${LOGDIR:-}" ]; then\n'
    '  echo "LOGDIR is unset or empty" >&2\n'
    '  exit 2\n'
    'fi\n'
    '\n'
    '"$PY" parse_logs.py -- "$LOGDIR"\n'
    'rc=$?\n'
    'echo "wrapper: python exited $rc"\n'
    'exit $rc\n', encoding="utf-8")

import os
for label, env in (
        ("clean run", {"LOGDIR": "/proj/run1", "PROJ_ROOT": "/proj"}),
        ("failures found", {"LOGDIR": "/proj/bad_run", "PROJ_ROOT": "/proj"}),
        ("LOGDIR empty", {"LOGDIR": "", "PROJ_ROOT": "/proj"}),
        ("PROJ_ROOT not exported", {"LOGDIR": "/proj/run1"}),
        ("a dash-leading path", {"LOGDIR": "--sweep", "PROJ_ROOT": "/proj"})):
    e = dict(os.environ)
    for k in ("LOGDIR", "PROJ_ROOT"):
        e.pop(k, None)
    e.update(env)
    r = subprocess.run(["bash", "run.sh"], stdout=subprocess.PIPE,
                       stderr=subprocess.STDOUT, text=True, env=e)
    first = [l for l in r.stdout.strip().splitlines()][0] if r.stdout.strip() else ""
    print(f"{label:<24} rc={r.returncode:<4} {first[:46]}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
clean run                rc=0    parsing /proj/run1 under /proj
failures found           rc=1    parsing /proj/bad_run under /proj
LOGDIR empty             rc=2    LOGDIR is unset or empty
PROJ_ROOT not exported   rc=3    env: missing=['PROJ_ROOT'] empty=[]
a dash-leading path      rc=0    parsing --sweep under /proj

Five outcomes and five distinct exit codes, each meaning one thing. Read the last row carefully: a path of --sweep was accepted and parsed, because -- ended option parsing — the defect from Section 6, fixed.

The code assignments are the part worth copying. 0 for success, 1 for "the thing I was asked to check found problems", 2 for a usage error, 3 for a bad environment — so the wrapper's $? distinguishes my design has failures from the flow is misconfigured, which Chapter 30.1 Section 4 showed is the most useful split in a regression report. || exit 1 would have reported 1 for all four failures.

Note also that 127 is reserved by convention for a missing command, which is what bash itself returns, so the pre-flight check uses the same code rather than inventing one.

10. Arguments Have a Size Limit

The argv channel is strings, and Chapter 32.2 Section 4 called it narrow. It is also finite, in a way that a flow discovers at scale:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import os
import subprocess
from pathlib import Path

print("the kernel's limit on this machine:")
print("  os.sysconf('SC_ARG_MAX') =", os.sysconf("SC_ARG_MAX"), "bytes")
print()

Path("count.py").write_text("import sys\nprint(len(sys.argv) - 1)\n",
                            encoding="utf-8")

print(f"{'files':>8} {'argv bytes':>12}  result")
for n in (1000, 20000, 60000):
    args = ["python3", "count.py"] + \
           [f"/proj/rtl/block_{i:05d}.sv" for i in range(n)]
    size = sum(len(a) + 1 for a in args)
    try:
        r = subprocess.run(args, stdout=subprocess.PIPE,
                           stderr=subprocess.STDOUT, text=True)
        print(f"{n:>8} {size:>12}  rc={r.returncode} received {r.stdout.strip()}")
    except OSError as exc:
        print(f"{n:>8} {size:>12}  {type(exc).__name__}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
the kernel's limit on this machine:
  os.sysconf('SC_ARG_MAX') = 1048576 bytes

   files   argv bytes  result
    1000        25017  rc=0 received 1000
   20000       500017  rc=0 received 20000
   60000      1500017  OSError

Twenty thousand files cross and sixty thousand do not. The failure is an OSError raised by Python before the child ever starts, so there is no returncode to inspect and no log — a different failure shape from everything else in this chapter.

That number is worth knowing because an SoC filelist reaches it. Twenty-five characters per path and a limit near a megabyte gives roughly forty thousand paths, and the limit covers the environment too — so a flow that exports a large environment has less room for arguments than the raw number suggests.

This is the reason every EDA tool has a -f filelist flag:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
from pathlib import Path

PATHS = [f"/proj/rtl/block_{i:05d}.sv" for i in range(60000)]

Path("count_f.py").write_text(
    "import sys\n"
    "from pathlib import Path\n"
    "n = 0\n"
    "with open(sys.argv[2], encoding='utf-8') as f:\n"
    "    for line in f:\n"
    "        if line.strip():\n"
    "            n += 1\n"
    "print(n)\n", encoding="utf-8")

Path("files.f").write_text("\n".join(PATHS) + "\n", encoding="utf-8")

args = ["python3", "count_f.py", "-f", "files.f"]
print("argv entries   :", len(args) - 1)
print("argv bytes     :", sum(len(a) + 1 for a in args))
print("filelist bytes :", Path("files.f").stat().st_size)
r = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                   text=True)
print("rc             :", r.returncode)
print("paths delivered:", r.stdout.strip())
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
argv entries   : 3
argv bytes     : 30
filelist bytes : 1500000
rc             : 0
paths delivered: 60000

Sixty thousand paths delivered through thirty-one bytes of argv. The filelist moves the data from the argv channel to the file channel, which has no comparable limit — and that is the whole reason the flag exists, rather than it being a convenience.

The design rule follows directly, and it is the inverse of the usual advice:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
RULES = [
    ("a handful of scalars", "argv", "test name, seed, mode"),
    ("a list that can grow with the design", "a file", "sources, tests, corners"),
    ("anything with a newline in it", "a file", "32.3 §2 — argv is fine, the shell is not"),
    ("something secret", "neither", "argv is world-readable in ps output"),
]

print(f"{'what you are passing':<40} {'channel':<10} examples")
for what, ch, ex in RULES:
    print(f"{what:<40} {ch:<10} {ex}")

print()
print("the test:", "could this list grow with the size of the design?")
print("if yes  :", "it is a file, not an argument")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
what you are passing                     channel    examples
a handful of scalars                     argv       test name, seed, mode
a list that can grow with the design     a file     sources, tests, corners
anything with a newline in it            a file     32.3 §2 — argv is fine, the shell is not
something secret                         neither    argv is world-readable in ps output

the test: could this list grow with the size of the design?
if yes  : it is a file, not an argument

The test is whether the list grows with the design. A seed does not; a source list does — and the fourth row is worth its place because it is the one people get wrong in the other direction: a password or a token on a command line is visible to every user on the machine for as long as the process runs, which no amount of quoting fixes.

11. Predict, Run, Observe

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
import subprocess
from pathlib import Path

Path("rc.py").write_text("import sys\nsys.exit(7)\n", encoding="utf-8")


def run(script):
    Path("s.sh").write_text(script, encoding="utf-8")
    return subprocess.run(["bash", "s.sh"],
                          stdout=subprocess.DEVNULL,
                          stderr=subprocess.DEVNULL).returncode


# A
print("A:", run("python3 rc.py || exit 1\nexit 0\n"))

# B
print("B:", run("python3 rc.py || exit $?\nexit 0\n"))

# C
print("C:", run("python3 rc.py | cat\nexit $?\n"))

# D
print("D:", run("set -o pipefail\npython3 rc.py | cat\nexit $?\n"))

The verified output:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
A: 1
B: 7
C: 0
D: 7

A — the annotation's form. Seven became one.

B — $? preserves it.

C — a pipe reports cat's success, so a failing program looks fine. Section 3's mechanism, and the reason | tee run.log is dangerous in a wrapper.

D — pipefail recovers it, and is one line.

12. Exercises

Exercise 1 — three channels. For one boundary in your flow, list what crosses on each of the three channels.

Exercise 2 — break it with a space. Put a space in a directory name and run your wrapper unquoted. Report the argv.

Exercise 3 — audit your quotes. Find every $VAR in your wrapper that is not quoted, and say which could contain a space.

Exercise 4 — does python exist? Run command -v python on your build machines and report the answer for each.

Exercise 5 — pin the interpreter. Change one wrapper to a pinned interpreter and say which one you chose and why.

Exercise 6 — sys.executable. Explain when invoking the parent's own interpreter is better than naming one.

Exercise 7 — the pipe hides it. Run a failing command through | tee and report $?.

Exercise 8 — find a | tee in your flow. Say what status it is reporting.

Exercise 9 — pipefail. Add it to one wrapper and report what newly fails.

Exercise 10 — four codes into one. Run your Python with three different failure codes through || exit 1 and report what bash saw.

Exercise 11 — the last-command rule. Find a wrapper whose final line is the call, and confirm it needs no exit.

Exercise 12 — your code assignments. Write down what each non-zero code your Python returns means.

Exercise 13 — design versus infrastructure. Give your flow two codes that separate those, and say what consumes the difference.

Exercise 14 — empty versus unset. Pass a quoted empty variable and an unset one, and compare the argv.

Exercise 15 — set -u is half a guard. Demonstrate that it catches one and not the other.

Exercise 16 — the length guard. Write the [ -z "${1:-}" ] check and explain the :-.

Exercise 17 — a dash-leading value. Construct a path your flow could produce that begins with a dash.

Exercise 18 — add --. Put it in one wrapper and confirm the value survives.

Exercise 19 — exported or not. Find a variable your wrapper sets without exporting, and confirm Python cannot see it.

Exercise 20 — three states. For one required variable, handle absent, empty and set as three outcomes.

Exercise 21 — the collapsing default. Explain what os.environ.get(k, "") hides and why it matters here.

Exercise 22 — pre-flight the environment. Add the required-variable check to one Python entry point.

Exercise 23 — five outcomes, five codes. Reproduce Section 9's table for your own wrapper.

Exercise 24 — why 127. Say what 127 conventionally means and why the pre-flight check should reuse it.

13. Summary

  • Three channels cross this boundary — arguments, environment and one integer — and the annotation touches all three in one line.
  • The annotation's quotes are correct and load-bearing. One space in a path produced two arguments without them.
  • python does not exist on this machine, only python3; the name was deliberately left unbound after Python 2.
  • A pipe hides a missing interpreter. The bare call reported 127 and python --version | head -1 reported 0, because a pipeline's status is its last command's.
  • Pin the interpreter and pre-flight it with command -v python3 || exit 1, or invoke sys.executable from a parent Python.
  • || exit 1 collapsed exits of 1, 2, 7 and 42 into 1, discarding exactly the distinction Chapter 25.6 built a classifier for.
  • Four forms preserve the code, including the one people overlook: a script whose last command is the call needs no exit at all.
  • Quoted empty and quoted unset both arrive as argv=[''] — the program gets an empty string, not nothing — while unquoted unset passes no argument at all.
  • set -u catches unset and not empty, which is the half most flows hit; [ -z "${1:-}" ] covers both, and the :- is what keeps the guard alive under set -u.
  • A value beginning with a dash is eaten by argparse (rc=2), and -- before the operand fixes it.
  • A variable that is set but not exported is absent from the child, which is the commonest "but I set it" bug here.
  • A variable has three states — absent, empty, set — and get(k, "") reports two, merging the two with different causes.
  • Assign your exit codes deliberately: one for "found problems", separate ones for usage and environment, and reuse 127 for a missing command.
  • The argv channel is finite. ARG_MAX is 1,048,576 bytes here: 20,000 paths crossed and 60,000 raised OSError before the child started.
  • That failure has no returncode and no log, which is a different shape from every other defect in the chapter.
  • A filelist moves the data to the file channel — 60,000 paths delivered through 31 bytes of argv, which is why every tool has a -f flag.
  • The test is whether the list grows with the design: a seed does not, a source list does.
  • Never put a secret on a command line — argv is readable by every user on the machine for as long as the process runs, and no quoting changes that.

The next chapter crosses the other boundary, where Python does not call a program but writes one.

Where this fits

Part of the Python curriculum.