Python Part II · Module 32 · Python, Tcl and Bash in EDA Flows
Which Language Owns Which Layer — and Why It Varies
Chapter 32.1 set out the three layers and the reasoning behind them. This chapter takes the default division seriously enough to run it — all three interpreters exist here, so the canonical annotation's chain of four programs is executed rather than illustrated — and then spends the second half on why real projects divide the work differently and when they are right to. The useful output is not a rule but a list: four things cross a language boundary, one thing cannot, and each of them is a chapter in this module.
Intermediate20 min readPythonPart IIAutomationTclBash
Part II · Module 32 · Chapter 32.2 · Which Language Owns Which Layer — and Why It Varies
1. All Three Interpreters Are Here
This module is about handoffs between languages, and unusually for Part II every language involved is installed:
import subprocess
TOOLS = [
("python3", ["python3", "-c", "import sys; print(sys.version.split()[0])"]),
("tclsh", ["tclsh"]),
("bash", ["bash", "-c", "echo ${BASH_VERSION%%(*}"]),
]
for name, cmd in TOOLS:
if name == "tclsh":
r = subprocess.run(cmd, input="puts [info patchlevel]\n",
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
else:
r = subprocess.run(cmd, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
print(f" {name:<9} {r.stdout.strip()}")
print()
print("so every handoff in this module can be:", "run, not drawn")
print("what is still absent:", "any commercial EDA tool — 23.1 §1") python3 3.13.5
tclsh 8.5.9
bash 3.2.57
so every handoff in this module can be: run, not drawn
what is still absent: any commercial EDA tool — 23.1 §1One substitution runs through the module and is worth stating once. There is no xrun or dc_shell here, so where a chapter needs a tool that embeds Tcl, it invokes tclsh instead. The thing being demonstrated is the language boundary, and tclsh is the same language those tools embed — what is missing is the tool's design database, which is precisely the subject of Chapter 32.5 and is handled there by saying so rather than by pretending.
2. The Annotation Is a Chain
# run.sh -> run_regress.py -> xrun -> run.tcl (inside the tool)Four programs, three boundaries, and two languages that never meet:
CHAIN = [
("run.sh", "bash", "the shell layer", "a human types this"),
("run_regress.py", "python", "flow and data", "decides what to run"),
("xrun", "a binary", "the tool", "owns the design database"),
("run.tcl", "tcl", "inside the tool", "issues the tool's own commands"),
]
print(f"{'program':<16} {'language':<10} {'layer':<18} role")
for prog, lang, layer, role in CHAIN:
print(f"{prog:<16} {lang:<10} {layer:<18} {role}")
print()
print("programs :", len(CHAIN))
print("boundaries :", len(CHAIN) - 1)
print("does bash ever talk to tcl?", False)program language layer role
run.sh bash the shell layer a human types this
run_regress.py python flow and data decides what to run
xrun a binary the tool owns the design database
run.tcl tcl inside the tool issues the tool's own commands
programs : 4
boundaries : 3
does bash ever talk to tcl? FalseThe last line matters more than it looks. Bash and Tcl never exchange anything directly — Python sits between them, and the Tcl is not even invoked by Python but by the tool Python launched. Every claim about "how Tcl and Bash interact" in a flow like this is really a claim about two separate boundaries with Python in the middle.
3. The Chain, Executed
import subprocess
from pathlib import Path
# the innermost layer: what the tool's own interpreter runs
Path("run.tcl").write_text(
'puts " \\[tcl\\] reporting timing for corner [lindex $argv 0]"\n'
'if {[lindex $argv 0] eq "bad"} { exit 2 }\n'
'exit 0\n', encoding="utf-8")
# the tool: stood in for by tclsh (23.1 §1 — no commercial tool here)
Path("run_regress.py").write_text(
'import subprocess, sys\n'
'corner = sys.argv[1]\n'
'print(f" [python] launching the tool for corner {corner!r}")\n'
'r = subprocess.run(["tclsh", "run.tcl", corner])\n'
'print(f" [python] the tool returned {r.returncode}")\n'
'sys.exit(0 if r.returncode == 0 else 1)\n', encoding="utf-8")
Path("run.sh").write_text(
'#!/bin/bash\n'
'echo "[bash] starting"\n'
'python3 run_regress.py "$1"\n'
'rc=$?\n'
'echo "[bash] python returned $rc"\n'
'exit $rc\n', encoding="utf-8")
for corner in ("ss_125c", "bad"):
r = subprocess.run(["bash", "run.sh", corner], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
print(f"--- bash run.sh {corner}")
print(r.stdout, end="")
print(f" overall returncode: {r.returncode}")--- bash run.sh ss_125c
[bash] starting
[tcl] reporting timing for corner ss_125c
[python] launching the tool for corner 'ss_125c'
[python] the tool returned 0
[bash] python returned 0
overall returncode: 0
--- bash run.sh bad
[bash] starting
[tcl] reporting timing for corner bad
[python] launching the tool for corner 'bad'
[python] the tool returned 2
[bash] python returned 1
overall returncode: 1Four programs, two languages besides Python, and a failure that started in Tcl arriving at bash as a non-zero status. That is the whole flow in twenty lines, and every chapter after this one takes one piece of it apart.
Note what the status did on the way up: Tcl exited 2, Python turned that into 1, and bash reported 1. The translation is deliberate and it is a decision — Chapter 32.7 is about the places where it is made badly or not at all.
4. Four Things Cross a Boundary, and One Cannot
This is the list that makes the question answerable:
CROSSINGS = [
("arguments", "argv", "yes", "32.3"),
("environment", "exported variables", "yes", "32.3"),
("files", "a generated script, a log, a report", "yes", "32.4"),
("exit status", "one small integer", "yes", "32.7"),
("the tool's design database", "cells, nets, timing graph", "NO", "32.5"),
]
print(f"{'what':<28} {'carried as':<34} {'crosses?':<10} chapter")
for what, how, crosses, ch in CROSSINGS:
print(f"{what:<28} {how:<34} {crosses:<10} {ch}")
print()
print("things that cross:", sum(1 for c in CROSSINGS if c[2] == "yes"))
print("things that cannot:", sum(1 for c in CROSSINGS if c[2] == "NO"))
print("and the one that cannot is what decides ownership")what carried as crosses? chapter
arguments argv yes 32.3
environment exported variables yes 32.3
files a generated script, a log, a report yes 32.4
exit status one small integer yes 32.7
the tool's design database cells, nets, timing graph NO 32.5
things that cross: 4
things that cannot: 1
and the one that cannot is what decides ownershipFour channels, all narrow. Arguments are strings, the environment is strings, files are bytes, and the exit status is a single integer between 0 and 255. Nothing richer crosses a process boundary, which means every design decision in this module is about what can be reduced to those four things.
And the fifth row is why the default division exists at all:
WORK = [
("get_cells -hier *reg*", "needs the design database", "inside the tool"),
("report_timing -path full", "needs the timing graph", "inside the tool"),
("set_clock_groups", "changes tool state", "inside the tool"),
("which corners to run", "a list from a spec file", "outside"),
("compare two runs' WNS", "arithmetic over parsed numbers", "outside"),
("decide pass or fail", "a policy with a threshold", "outside"),
]
print(f"{'task':<28} {'why':<32} where")
for task, why, where in WORK:
print(f"{task:<28} {why:<32} {where}")
inside = [w for w in WORK if w[2] == "inside the tool"]
print()
print("tasks that must be inside:", len(inside))
print("the test :", "does it need the tool's own data structures?")task why where
get_cells -hier *reg* needs the design database inside the tool
report_timing -path full needs the timing graph inside the tool
set_clock_groups changes tool state inside the tool
which corners to run a list from a spec file outside
compare two runs' WNS arithmetic over parsed numbers outside
decide pass or fail a policy with a threshold outside
tasks that must be inside: 3
the test : does it need the tool's own data structures?One question decides it: does the work need the tool's own data structures? Three of six tasks do, and no amount of Python can do them from outside, because the thing they operate on does not cross the boundary. The other three need a spec file, some arithmetic and a policy, none of which the tool's interpreter is good at.
5. The Default, as a Diagram
The default division of a flow
The diagram is the default and the next section is the reason it is only a default.
6. Four Real Flows That Divide It Differently
FLOWS = [
("Tcl owns everything",
"a single tool_shell -f flow.tcl does the whole job",
"the flow never leaves one tool", "defensible"),
("Bash owns the flow",
"a 900-line run.sh with case statements",
"it grew that way and works", "defensible, until it is not"),
("Python owns everything including tool commands",
"python writes tcl for every step, even trivial ones",
"one language to hire for", "defensible, with a cost"),
("Make owns the ordering",
"Makefile targets, each a one-line tool call",
"dependency tracking and restart come free", "often the best"),
]
print(f"{'division':<46} verdict")
for name, what, why, verdict in FLOWS:
print(f"{name:<46} {verdict}")
print(f" {what}")
print(f" because: {why}")
print()
print("divisions that are simply wrong:", 0)division verdict
Tcl owns everything defensible
a single tool_shell -f flow.tcl does the whole job
because: the flow never leaves one tool
Bash owns the flow defensible, until it is not
a 900-line run.sh with case statements
because: it grew that way and works
Python owns everything including tool commands defensible, with a cost
python writes tcl for every step, even trivial ones
because: one language to hire for
Make owns the ordering often the best
Makefile targets, each a one-line tool call
because: dependency tracking and restart come free
divisions that are simply wrong: 0None of the four is simply wrong, which is the canonical purpose's point and is worth taking seriously rather than conceding politely.
Tcl owning everything is right when the flow genuinely never leaves one tool. A synthesis script that reads RTL, constrains, compiles and writes reports has nothing to gain from Python, and adding Python adds a boundary — four narrow channels — for no benefit.
Bash owning the flow is the one people apologise for, and it is fine while the flow is a sequence of commands with no data work. It stops being fine at the point where something needs to be parsed, compared or decided, and Module 30 is a catalogue of what bash does badly there.
Python owning everything is what this book teaches and it has a cost worth naming: every tool command becomes a generated string, so a one-line report_timing becomes a generated file, a subprocess.run, and a parse. Chapter 32.5 is about where that trade stops paying.
Make owning the ordering is the division least often considered and frequently the best, because it brings two things no amount of Python gets for free — a dependency graph and restart-from-where-it-failed.
7. What Goes Wrong in Each Direction
The cost of a misplaced boundary is specific, not vague:
MISTAKES = [
("data work in Tcl",
"parsing a report with string match in the tool's shell",
"no libraries, no tests, runs inside a 40-minute tool session"),
("data work in Bash",
"awk and sed pipelines computing a pass rate",
"30.4's every-value-is-a-string problem, with worse tooling"),
("tool commands in Python",
"re-implementing get_cells by parsing a netlist",
"a worse version of a tool API that already exists — 32.5"),
("flow decisions in Tcl",
"deciding which corners to run inside the tool",
"the decision cannot be tested without launching the tool"),
("flow decisions in the tool's session",
"a 6-hour run that decides at hour 5 that it should not have started",
"the pre-flight check 21.7 exists for this"),
]
print(f"{'misplaced work':<32} example")
for what, example, cost in MISTAKES:
print(f"{what:<32} {example}")
print(f"{'':<32} cost: {cost}")
print()
print("mistakes listed:", len(MISTAKES))
print("all of them are:", "work in a layer that cannot test it")misplaced work example
data work in Tcl parsing a report with string match in the tool's shell
cost: no libraries, no tests, runs inside a 40-minute tool session
data work in Bash awk and sed pipelines computing a pass rate
cost: 30.4's every-value-is-a-string problem, with worse tooling
tool commands in Python re-implementing get_cells by parsing a netlist
cost: a worse version of a tool API that already exists — 32.5
flow decisions in Tcl deciding which corners to run inside the tool
cost: the decision cannot be tested without launching the tool
flow decisions in the tool's session a 6-hour run that decides at hour 5 that it should not have started
cost: the pre-flight check 21.7 exists for this
mistakes listed: 5
all of them are: work in a layer that cannot test itEvery one of them is work placed in a layer that cannot test it. That is a sharper criterion than "use the right language", and it is checkable: ask how the work would be exercised in isolation. Parsing in Python is a function with a fixture; parsing in the tool's Tcl needs a tool licence and a design loaded.
8. The Boundary Count Is a Cost
Each boundary is a place where something can be dropped, so the number of them is worth knowing:
FLOWS = {
"tcl-only": ["tool_shell", "flow.tcl"],
"the default": ["run.sh", "run_regress.py", "xrun", "run.tcl"],
"deeply layered": ["submit.sh", "wrapper.sh", "flow.py", "step.py",
"tool", "step.tcl"],
}
print(f"{'flow':<18} {'programs':>9} {'boundaries':>11} things that can be dropped")
for name, progs in FLOWS.items():
b = len(progs) - 1
print(f"{name:<18} {len(progs):>9} {b:>11} {b} exit statuses, {b} argv lists")
print()
print("the default is:", len(FLOWS['the default']) - 1, "boundaries")
print("every boundary needs:", "an argv contract and a status contract")flow programs boundaries things that can be dropped
tcl-only 2 1 1 exit statuses, 1 argv lists
the default 4 3 3 exit statuses, 3 argv lists
deeply layered 6 5 5 exit statuses, 5 argv lists
the default is: 3 boundaries
every boundary needs: an argv contract and a status contractFive boundaries in the deeply layered flow, and Chapter 32.7 shows that each one is a place where a non-zero status can be silently lost. That is the argument against a layer that exists only to pass arguments through: wrapper.sh in that third row adds a boundary and no capability.
It is also the strongest argument for the Make division from Section 6. A Makefile with six one-line targets has six boundaries too — but Make checks every one of them by default and stops, which is a guarantee the hand-written chain has to re-earn at each hop.
9. The One Thing Bash Cannot Delegate
Section 6 listed divisions in which bash owns almost nothing, and there is one job it cannot hand over however the rest is arranged:
import os
import subprocess
from pathlib import Path
Path("show.py").write_text(
"import os\n"
"for k in ('LM_LICENSE_FILE', 'PATH_HAS_TOOL', 'TOOL_ROOT'):\n"
" print(f' {k:<17} {os.environ.get(k, \"<absent>\")!r}')\n",
encoding="utf-8")
Path("wrap.sh").write_text(
'# the shell layer sets up the environment the tool needs\n'
'export LM_LICENSE_FILE=27000@licsrv\n'
'export TOOL_ROOT=/tools/sta/2025.03\n'
'export PATH_HAS_TOOL=yes\n'
'python3 show.py\n', encoding="utf-8")
print("with the wrapper:")
r = subprocess.run(["bash", "wrap.sh"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True)
print(r.stdout, end="")
print("python invoked directly, no wrapper:")
e = {k: v for k, v in os.environ.items()
if k not in ("LM_LICENSE_FILE", "TOOL_ROOT", "PATH_HAS_TOOL")}
r = subprocess.run(["python3", "show.py"], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, env=e)
print(r.stdout, end="")with the wrapper:
LM_LICENSE_FILE '27000@licsrv'
PATH_HAS_TOOL 'yes'
TOOL_ROOT '/tools/sta/2025.03'
python invoked directly, no wrapper:
LM_LICENSE_FILE '<absent>'
PATH_HAS_TOOL '<absent>'
TOOL_ROOT '<absent>'Both runs executed the same Python. The difference is entirely in what existed before it started, and that is the point: a process cannot set up its own environment, because the environment is what it inherits.
Python can of course set variables for a child — subprocess.run(..., env=...) does exactly that, as the second run above demonstrates. What it cannot do is change the environment of the process that launched it, which matters because a licence server, a PATH entry and a module's shared libraries all have to be in place before the tool binary is even located:
TASKS = [
("choose which tool version", "a flow decision", "python"),
("put that version on PATH", "modifies this process's env", "the shell"),
("set LM_LICENSE_FILE", "read by the tool at startup", "the shell"),
("load a module's shared libraries", "dynamic linker env", "the shell"),
("decide which corners to run", "a spec file", "python"),
("record which version was used", "provenance — 29.1 §8", "python"),
]
print(f"{'task':<36} {'why':<34} owner")
for task, why, owner in TASKS:
print(f"{task:<36} {why:<34} {owner}")
shell = [t for t in TASKS if t[2] == "the shell"]
print()
print("tasks only the shell can do:", len(shell))
print("what they have in common :",
"they change the environment Python will inherit")task why owner
choose which tool version a flow decision python
put that version on PATH modifies this process's env the shell
set LM_LICENSE_FILE read by the tool at startup the shell
load a module's shared libraries dynamic linker env the shell
decide which corners to run a spec file python
record which version was used provenance — 29.1 §8 python
tasks only the shell can do: 3
what they have in common : they change the environment Python will inheritThree of six tasks can only happen in the shell, and the split inside the first two rows is the interesting one: choosing the tool version is a flow decision Python should make, and applying it is an environment change only the shell can perform.
That is why even a thoroughly Python-owned flow keeps a small run.sh at the top. The honest shape of that script is three lines of environment setup and one line invoking Python — and the warning sign is the day it grows a case statement, because that is a flow decision migrating into the one layer that cannot test it.
GOOD = ['module load sta/2025.03', 'export LM_LICENSE_FILE=27000@licsrv',
'exec python3 -m flow.run "$@"']
BAD_ADDITIONS = [
('case "$1" in sta) ... ;; syn) ... ;; esac', "a flow decision"),
('for c in ss ff tt; do ... done', "an iteration policy"),
('if [ -f prev/wns.txt ]; then ...', "a comparison — 29.4"),
]
print("a healthy run.sh:")
for l in GOOD:
print(" " + l)
print()
print("additions that mean the boundary has moved:")
for l, why in BAD_ADDITIONS:
print(f" {l:<44} {why}")
print()
print("lines in the healthy version:", len(GOOD))
print("the last line uses exec because:",
"the wrapper has nothing left to do — $? needs no forwarding")a healthy run.sh:
module load sta/2025.03
export LM_LICENSE_FILE=27000@licsrv
exec python3 -m flow.run "$@"
additions that mean the boundary has moved:
case "$1" in sta) ... ;; syn) ... ;; esac a flow decision
for c in ss ff tt; do ... done an iteration policy
if [ -f prev/wns.txt ]; then ... a comparison — 29.4
lines in the healthy version: 3
the last line uses exec because: the wrapper has nothing left to do — $? needs no forwardingexec on the last line is a small detail with a real consequence: it replaces the shell rather than spawning a child, so Python's exit status becomes the script's directly and there is no $? to forward — which removes three of the drop points Chapter 32.7 measures. A wrapper that has nothing to do after the call should not be there after the call.
10. Predict, Run, Observe
import subprocess
from pathlib import Path
Path("inner.tcl").write_text("exit 2\n", encoding="utf-8")
Path("mid.py").write_text(
"import subprocess, sys\n"
"r = subprocess.run(['tclsh', 'inner.tcl'])\n"
"sys.exit(r.returncode)\n", encoding="utf-8")
# A
r = subprocess.run(["tclsh", "inner.tcl"])
print("A:", r.returncode)
# B
r = subprocess.run(["python3", "mid.py"])
print("B:", r.returncode)
# C
Path("o.sh").write_text("python3 mid.py\nexit $?\n", encoding="utf-8")
r = subprocess.run(["bash", "o.sh"])
print("C:", r.returncode)
# D
print("D:", 4, 3)The verified output:
A: 2
B: 2
C: 2
D: 4 3A — Tcl's own exit status.
B — Python passed it through unchanged, because sys.exit(r.returncode) is the identity.
C — bash received the same 2 across two boundaries. A status survives when every hop forwards it, which sounds obvious and is the subject of a whole chapter because it so often does not.
D — four programs, three boundaries.
11. Exercises
Exercise 1 — your interpreters. Record the versions of python3, tclsh and bash on your own machine.
Exercise 2 — name the chain. Write out your flow's chain of programs from the command a human types to the tool's own script.
Exercise 3 — count the boundaries. Report how many your chain has.
Exercise 4 — do your outer and inner languages ever meet? Trace whether bash and Tcl exchange anything directly in your flow.
Exercise 5 — run the four-hop chain. Reproduce Section 3 with your own interpreters and confirm the status arrives.
Exercise 6 — the status translation. Say what your Python layer does with a tool's returncode, and whether it is a decision or an accident.
Exercise 7 — the four channels. For one boundary in your flow, list what actually crosses it.
Exercise 8 — what cannot cross. Name one thing in your flow that lives inside a tool and cannot leave it.
Exercise 9 — the ownership question. Apply "does it need the tool's own data structures?" to five tasks in your flow.
Exercise 10 — a task on the wrong side. Find one and say which side it should be on.
Exercise 11 — the narrow channel. Explain why a design decision in this module must reduce to strings, bytes and one integer.
Exercise 12 — your division. Decide which of Section 6's four divisions your project most resembles.
Exercise 13 — defend it. Write the two-sentence justification for your project's division.
Exercise 14 — when Tcl-only is right. Describe a flow in your project that would not benefit from Python.
Exercise 15 — where bash stops being enough. Find the point in your flow where data work begins.
Exercise 16 — the cost of Python owning everything. Count the generated files one trivial tool command costs you.
Exercise 17 — consider Make. Say what dependency tracking and restart would buy your flow, and what they would cost.
Exercise 18 — testability as the criterion. For three pieces of work in your flow, say how each would be exercised in isolation.
Exercise 19 — the untestable piece. Find work that cannot be tested without a tool licence, and say whether it needs to be there.
Exercise 20 — the hour-five decision. Find a decision your flow makes late that it could make before launching.
Exercise 21 — a layer that adds nothing. Look for a wrapper script that only passes arguments through.
Exercise 22 — boundaries as risk. For each boundary in your flow, say what would happen if it dropped a status.
Exercise 23 — what Make gives by default. Explain what a hand-written chain must re-earn at every hop.
Exercise 24 — the module map. For each of 32.3 to 32.7, name the boundary in your own flow that it describes.
12. Summary
- All three interpreters exist here — python3 3.13.5, tclsh 8.5.9, bash 3.2.57 — so this module's handoffs are executed rather than illustrated.
tclshstands in for a tool's embedded interpreter. The language is the same; what is missing is the design database, which is Chapter 32.5's subject.- The annotation is a chain of four programs with three boundaries, and bash and Tcl never exchange anything directly — Python sits between them.
- A failure that began in Tcl reached bash as a non-zero status, with Python translating 2 into 1 along the way. That translation is a decision, not a default.
- Four things cross a boundary and one cannot: arguments, environment, files and exit status; the tool's design database does not.
- Every channel is narrow — strings, bytes and one integer between 0 and 255 — so every design decision here is about what can be reduced to those.
- One question decides ownership: does the work need the tool's own data structures? Three of six typical tasks do, and no amount of Python can do them from outside.
- Four real divisions are all defensible — Tcl-only, bash-owned, Python-owned, and Make-owned — and the canonical purpose is right that the default is a default.
- Tcl-only is right when the flow never leaves one tool, because adding Python adds a boundary for no benefit.
- Python owning everything costs a generated file and a parse per tool command, which is where Chapter 32.5's line sits.
- Make owning the ordering brings a dependency graph and restart, which no amount of Python gets for free.
- Every misplacement is work in a layer that cannot test it — a sharper criterion than "use the right language", and a checkable one.
- The boundary count is a cost. A wrapper that only forwards arguments adds a place to drop a status and no capability.
- A process cannot set up its own environment, because the environment is what it inherits — which is the one job bash cannot delegate.
- Three of six typical tasks can only happen in the shell: putting a tool on
PATH, setting the licence variable, and loading a module's libraries. - Choosing the tool version is a flow decision and applying it is an environment change — the first belongs in Python, the second cannot.
- A healthy
run.shis three lines of setup and anexec, and the day it grows acasestatement a flow decision has migrated into the layer that cannot test it. execon the last line removes three drop points, because Python's status becomes the script's directly with no$?to forward.
The next chapter takes the first boundary — the one every flow has — and shows what the annotation's quotation marks are actually doing.
Where this fits
Part of the Python curriculum.
