Python Part II · Module 34 · From Script to Maintainable Tool
From One File to Modules Others Can Import
Chapter 34.1 measured a script in which zero of three functions could be called from a test, and found the reason was structural: importing the file ran the regression. This chapter performs the first and most consequential refactoring — one file becomes a package — and the interesting part is not the mechanics but what the import system actually does when you ask for one piece of it. Two of the measurements here contradict what the usual advice implies.
Advanced23 min readPythonPart IIAutomationPackagesImports
Part II · Module 34 · Chapter 34.2 · From One File to Modules Others Can Import
1. What One File Costs
WANTS = [
("reuse the parser in a reporting script", "import the file",
"runs the regression"),
("test the severity counting", "import the file",
"runs the regression"),
("call the classifier from a notebook", "import the file",
"runs the regression"),
("run the regression", "run the file", "works"),
]
print(f"{'what you want':<42} {'what you must do':<20} what happens")
for want, how, result in WANTS:
print(f"{want:<42} {how:<20} {result}")
print()
print("uses the one file supports:", 1)
print("uses it blocks :", 3)
print("and the blocker is :", "six calls at module level — 34.1 §3")what you want what you must do what happens
reuse the parser in a reporting script import the file runs the regression
test the severity counting import the file runs the regression
call the classifier from a notebook import the file runs the regression
run the regression run the file worksOne supported use, three blocked ones, and all three are blocked by the same thing: the file has no entry point, so importing it is running it. Chapter 34.1 Section 9 measured that directly — the unguarded module printed three lines on import where the guarded one printed none, and both exposed the same function.
2. The Annotation
from flow.parse import parse_sim_log
from flow.tools import run_simTwo lines, and they encode three decisions that are worth separating before
building anything. There is a package called flow, so the names do not
collide with anything else on the path. There are separate modules for
parsing and for tool control, which is Chapter 34.3's three-layer split
beginning. And the import is by name, not by file path, so the caller does
not need to know where the package lives.
The decision the two lines do not state is the one Section 5 is about: which
of parse and tools is allowed to import the other.
3. The Package
Here is the split. Four files, and the whole of Chapter 34.1's specimen logic
is in them — with the print statements at the top of each module present on
purpose, so Section 4 can measure what gets executed:
"""flow - the axi_fifo regression toolkit."""
print("[import] flow/__init__.py")
__version__ = "0.1.0""""Text in, facts out. No files, no tools, no printing."""
print("[import] flow/parse.py")
import re
SEVERITIES = ("UVM_FATAL", "UVM_ERROR", "UVM_WARNING")
TESTNAME = re.compile(r"UVM_TESTNAME=(\S+)")
SEED = re.compile(r"ntb_random_seed=(\d+)")
def count_severities(text):
"""Count real severity lines, excluding the UVM_ERROR : summary line."""
counts = dict.fromkeys(SEVERITIES, 0)
for line in text.splitlines():
for sev in SEVERITIES:
if line.startswith(sev) and f"{sev} :" not in line:
counts[sev] += 1
return counts
def classify(counts, text):
"""The four-way verdict, as a pure function of what was counted."""
if "TEST DONE" not in text:
return "TIMEOUT"
if counts["UVM_FATAL"]:
return "FATAL"
if counts["UVM_ERROR"]:
return "FAIL"
return "PASS"
def parse_sim_log(text):
"""The module's one public entry point."""
counts = count_severities(text)
m, s = TESTNAME.search(text), SEED.search(text)
return {
"test": m.group(1) if m else None,
"seed": int(s.group(1)) if s else None,
"errors": counts["UVM_ERROR"],
"fatals": counts["UVM_FATAL"],
"warnings": counts["UVM_WARNING"],
"status": classify(counts, text),
}"""Everything that touches the world: processes and the filesystem."""
print("[import] flow/tools.py")
import subprocess
import sys
from pathlib import Path
def sim_command(sim, filelist, test, seed, log):
"""Pure: build the argument vector. Returns a list, never a string."""
return [sim, "-f", str(filelist), f"+UVM_TESTNAME={test}",
f"+ntb_random_seed={seed}", "-l", str(log)]
def run_sim(sim, filelist, test, seed, run_dir):
"""Impure: launch the tool and return (returncode, log_path)."""
run_dir = Path(run_dir)
run_dir.mkdir(parents=True, exist_ok=True)
log = run_dir / "sim.log"
argv = sim_command(sim, filelist, test, seed, log)
cp = subprocess.run(argv, cwd=run_dir, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
return cp.returncode, log
def read_log(log):
"""Impure: the only place the parser's input comes from."""
p = Path(log)
if not p.is_file():
return None
return p.read_text(encoding="utf-8", errors="replace")import sys
from pathlib import Path
sys.path.insert(0, ".")
print("--- importing only the parser")
from flow.parse import parse_sim_log
LOG = ("UVM_INFO @0 UVM_TESTNAME=axi_burst_test ntb_random_seed=4121\n"
"UVM_WARNING drv.sv(9) late\n"
"UVM_ERROR drv.sv(88) data mismatch\n"
"UVM_ERROR mon.sv(12) crc\n"
"UVM_ERROR : 2\n"
"TEST DONE\n")
print()
print("parse_sim_log(LOG):")
for k, v in parse_sim_log(LOG).items():
print(f" {k:<10} {v!r}")
print()
print("flow.tools in sys.modules:", "flow.tools" in sys.modules)
print("flow.parse in sys.modules:", "flow.parse" in sys.modules)--- importing only the parser
[import] flow/__init__.py
[import] flow/parse.py
parse_sim_log(LOG):
test 'axi_burst_test'
seed 4121
errors 2
fatals 0
warnings 1
status 'FAIL'
flow.tools in sys.modules: False
flow.parse in sys.modules: TrueThe two [import] lines appear exactly where the import statement sits.
from flow.parse import ... is an ordinary executable statement, not a
declaration hoisted to the top, so it runs after the print above it — which
is worth seeing once, because it is the reason the usual convention of putting
every import at the top of a file means every imported module's side effects
happen before your program's first line. Chapter 34.1's specimen is what that
costs when those side effects are a regression run.
The facts this section exists for are the last two lines, and Section 4 is about nothing else.
4. Importing the Parser Does Not Import the Simulator
import sys
from pathlib import Path
for d, files in {
"flow": {
"__init__.py": '"""flow."""\nprint("[import] flow/__init__.py")\n',
"parse.py": ('print("[import] flow/parse.py")\n'
'def parse_sim_log(text):\n'
' return {"errors": text.count("UVM_ERROR")}\n'),
"tools.py": ('print("[import] flow/tools.py")\n'
'import subprocess\n'
'def run_sim(*a):\n'
' return 0\n'),
"report.py": ('print("[import] flow/report.py")\n'
'def to_csv(rows):\n'
' return "test,status\\n"\n'),
},
}.items():
Path(d).mkdir(exist_ok=True)
for name, body in files.items():
(Path(d) / name).write_text(body, encoding="utf-8")
sys.path.insert(0, ".")
from flow.parse import parse_sim_log
MODULES = ["flow", "flow.parse", "flow.tools", "flow.report"]
print()
print(f"{'module':<14} loaded by `from flow.parse import ...`")
for m in MODULES:
print(f"{m:<14} {m in sys.modules}")
print()
loaded = sum(1 for m in MODULES if m in sys.modules)
print("modules in the package :", len(MODULES))
print("modules actually loaded:", loaded)
print("modules NOT loaded :", len(MODULES) - loaded)
print("what that means :",
"a reporting script pays for the parser only")[import] flow/__init__.py
[import] flow/parse.py
module loaded by `from flow.parse import ...`
flow True
flow.parse True
flow.tools False
flow.report False
modules in the package : 4
modules actually loaded: 2
modules NOT loaded : 2
what that means : a reporting script pays for the parser onlyTwo of four modules load. flow/__init__.py runs because importing
anything inside a package initialises the package first; flow/parse.py runs
because that is what was asked for; and flow/tools.py and flow/report.py
are never touched.
That is the property that makes the annotation's two lines worth writing as two
lines. A script that only needs to re-parse yesterday's logs imports
flow.parse and gets the parser — not subprocess, not the filesystem
helpers, and crucially not any module-level work those files might do.
5. One Direction of Dependency
LAYERS = [
("flow.parse", "text -> facts", [], "pure"),
("flow.report", "facts -> text/files", ["flow.parse"], "mostly pure"),
("flow.tools", "world -> text, status", [], "impure"),
("run_regress.py", "the command line", ["flow.parse", "flow.tools",
"flow.report"], "impure"),
]
print(f"{'module':<18} {'job':<22} {'imports':<42} purity")
for name, job, deps, purity in LAYERS:
print(f"{name:<18} {job:<22} {', '.join(deps) or '-':<42} {purity}")
print()
print("modules importing flow.tools:", 1)
print("modules importing flow.parse:", 2)
print("modules flow.parse imports :", 0)
print()
print("the rule:", "a lower layer never imports a higher one")
print("so the direction is:", "run_regress -> report -> parse, tools -> nothing")
print("and the bottom of the graph is:", "flow.parse, which imports nothing")module job imports purity
flow.parse text -> facts - pure
flow.report facts -> text/files flow.parse mostly pure
flow.tools world -> text, status - impure
run_regress.py the command line flow.parse, flow.tools, flow.report impure
modules importing flow.tools: 1
modules importing flow.parse: 2
modules flow.parse imports : 0
the rule: a lower layer never imports a higher one
so the direction is: run_regress -> report -> parse, tools -> nothing
and the bottom of the graph is: flow.parse, which imports nothingflow.parse imports nothing from the package. That is not an accident of
this example, it is the design rule: the module holding the pure logic sits at
the bottom and depends on nobody, so it can be imported by anything, tested
without setup, and reused by a script that has never heard of a simulator.
flow.tools also imports nothing from the package, which is worth noticing
because it is impure. Purity and position are different axes: tools is at the
bottom because nothing in flow needs it, not because it is clean.
The direction is what Chapter 34.3 formalises with a diagram. Here the rule is enough: a module may import from a layer below it and never from one above. Break that and you get Section 6.
6. A Circular Import Blames the Victim
import sys
from pathlib import Path
Path("circ").mkdir(exist_ok=True)
(Path("circ") / "__init__.py").write_text("", encoding="utf-8")
(Path("circ") / "parse.py").write_text(
"from circ.report import format_row\n"
"\n"
"def parse_sim_log(text):\n"
" return {'errors': text.count('UVM_ERROR')}\n", encoding="utf-8")
(Path("circ") / "report.py").write_text(
"from circ.parse import parse_sim_log\n"
"\n"
"def format_row(r):\n"
" return 'ok'\n", encoding="utf-8")
sys.path.insert(0, ".")
def try_import(statement):
"""Import in a fresh interpreter state so order is what we control."""
for name in [k for k in sys.modules if k.startswith("circ")]:
del sys.modules[name]
try:
exec(statement, {})
return "OK", ""
except ImportError as exc:
msg = str(exc)
return type(exc).__name__, msg[:msg.index(" (")] if " (" in msg else msg
for stmt in ("from circ.parse import parse_sim_log",
"from circ.report import format_row"):
kind, msg = try_import(stmt)
print(f" {stmt}")
print(f" -> {kind}: {msg}")
print()
print("the cycle is :", "parse -> report -> parse")
print("the module named in the ")
print(" error is always :", "the one you imported first")
print("so the name in the message:", "is the victim, not the cause")
print("how you actually find it :", "draw the direction — §5") from circ.parse import parse_sim_log
-> ImportError: cannot import name 'parse_sim_log' from partially initialized module 'circ.parse'
from circ.report import format_row
-> ImportError: cannot import name 'format_row' from partially initialized module 'circ.report'
the cycle is : parse -> report -> parse
the module named in the
error is always : the one you imported first
so the name in the message: is the victim, not the cause
how you actually find it : draw the direction — §5The same cycle produces two different error messages, and each one names
whichever module you asked for. Import circ.parse and the error is about
parse_sim_log; import circ.report and it is about format_row — from the
identical, unchanged pair of files.
That matters because of how people debug it. The message says "cannot import
name parse_sim_log from partially initialized module circ.parse", which
reads as an accusation against parse.py, and parse.py is fine. The
offending line is the from circ.report import format_row at its top, and the
only reliable way to find it is to look at the direction of every import in the
package and find the pair that points both ways.
The phrase "partially initialized module" is the real clue. Python had
started executing circ/parse.py, reached line 1, gone off to execute
circ/report.py, which asked for a name from circ.parse — a module that is
on the stack and not yet finished, so the name does not exist yet. Nothing is
wrong with either file in isolation.
The fix is never "import it inside the function", although that works. It is
to decide which layer is lower. Here report formats facts, so it depends on
parse; parse has no business knowing how its output is formatted, and
deleting its import removes the cycle and improves the design at the same time.
7. Does __init__.py Matter?
import sys
from pathlib import Path
# with __init__.py
Path("withinit").mkdir(exist_ok=True)
(Path("withinit") / "__init__.py").write_text(
'"""withinit."""\n', encoding="utf-8")
(Path("withinit") / "parse.py").write_text(
"def parse_sim_log(t):\n return t.count('UVM_ERROR')\n",
encoding="utf-8")
# without __init__.py
Path("noinit").mkdir(exist_ok=True)
(Path("noinit") / "parse.py").write_text(
"def parse_sim_log(t):\n return t.count('UVM_ERROR')\n",
encoding="utf-8")
sys.path.insert(0, ".")
for name in ("withinit", "noinit"):
mod = __import__(f"{name}.parse", fromlist=["parse_sim_log"])
pkg = sys.modules[name]
print(f" {name}")
print(f" the import worked : {mod.parse_sim_log('UVM_ERROR x') == 1}")
print(f" pkg.__file__ is None : {pkg.__file__ is None}")
print(f" type(pkg.__path__) : {type(pkg.__path__).__name__}")
print()
print("so __init__.py is required for the import:", False)
print("what breaks without it:", "Path(pkg.__file__).parent")
print("which code does that :", "anything locating a bundled data file") withinit
the import worked : True
pkg.__file__ is None : False
type(pkg.__path__) : list
noinit
the import worked : True
pkg.__file__ is None : True
type(pkg.__path__) : _NamespacePath
so __init__.py is required for the import: False
what breaks without it: Path(pkg.__file__).parent
which code does that : anything locating a bundled data fileThe import works either way. A directory without __init__.py becomes a
namespace package, which is a deliberate feature and not a mistake — so the
common claim that a package "needs" __init__.py is false, and a team that
believes it will waste an afternoon on an import problem that has a different
cause.
The measured consequence is the one worth knowing. A namespace package's
__file__ is None and its __path__ is a _NamespacePath rather than a
list, so any code written as Path(flow.__file__).parent / "schema.json" —
the normal way to find a data file shipped beside the code — raises TypeError
on a None argument. That is the real argument for writing the file: not that
the import requires it, but that half the ecosystem assumes __file__ is a
string.
Two smaller reasons to write it anyway. It gives the package a docstring and a
__version__, which Chapter 34.6 needs for handover. And it makes the
directory's intent explicit to a reader who is deciding whether a stray folder
is a package or a pile of scripts.
8. What run_regress.py Becomes
import sys
from pathlib import Path
Path("flow").mkdir(exist_ok=True)
(Path("flow") / "__init__.py").write_text("", encoding="utf-8")
(Path("flow") / "parse.py").write_text(
"def parse_sim_log(text):\n"
" errs = sum(1 for l in text.splitlines()\n"
" if l.startswith('UVM_ERROR') and 'UVM_ERROR :' not in l)\n"
" done = 'TEST DONE' in text\n"
" return {'errors': errs,\n"
" 'status': 'TIMEOUT' if not done else\n"
" ('FAIL' if errs else 'PASS')}\n", encoding="utf-8")
(Path("flow") / "report.py").write_text(
"def to_csv(rows):\n"
" out = ['test,seed,status,errors']\n"
" for r in rows:\n"
" out.append(f\"{r['test']},{r['seed']},{r['status']},{r['errors']}\")\n"
" return '\\n'.join(out) + '\\n'\n", encoding="utf-8")
Path("run_regress.py").write_text(
'"""run_regress.py - run a suite; exit 0 only if every job passed."""\n'
"import sys\n"
"from pathlib import Path\n"
"\n"
"from flow.parse import parse_sim_log\n"
"from flow.report import to_csv\n"
"\n"
"TESTS = ['axi_burst_test', 'axi_lite_test']\n"
"SEEDS = [4121, 7734]\n"
"\n"
"\n"
"def collect(logs):\n"
" rows = []\n"
" for (test, seed), text in logs.items():\n"
" r = parse_sim_log(text)\n"
" r['test'], r['seed'] = test, seed\n"
" rows.append(r)\n"
" return rows\n"
"\n"
"\n"
"def main(logs):\n"
" rows = collect(logs)\n"
" Path('summary.csv').write_text(to_csv(rows), encoding='utf-8')\n"
" failed = [r for r in rows if r['status'] != 'PASS']\n"
" for r in rows:\n"
" print(f\"{r['test']} seed={r['seed']} -> {r['status']}\")\n"
" print(f'{len(rows) - len(failed)}/{len(rows)} passed')\n"
" return 1 if failed else 0\n"
"\n"
"\n"
"if __name__ == '__main__':\n"
" sys.exit(main({}))\n", encoding="utf-8")
sys.path.insert(0, ".")
import run_regress
GOOD = "UVM_INFO a\nTEST DONE\n"
BAD = "UVM_INFO a\nUVM_ERROR drv.sv(88) x\nUVM_ERROR : 1\nTEST DONE\n"
LOGS = {("axi_burst_test", 4121): BAD, ("axi_lite_test", 7734): GOOD}
rc = run_regress.main(LOGS)
print()
print("exit code main() returned :", rc)
print("summary.csv written :", Path("summary.csv").is_file())
print()
print(Path("summary.csv").read_text(encoding="utf-8"), end="")
print()
print("rows reaching the report :", len(run_regress.collect(LOGS)))
print("34.1's RESULTS bug is now :", "impossible — collect RETURNS the rows")axi_burst_test seed=4121 -> FAIL
axi_lite_test seed=7734 -> PASS
1/2 passed
exit code main() returned : 1
summary.csv written : True
test,seed,status,errors
axi_burst_test,4121,FAIL,1
axi_lite_test,7734,PASS,0
rows reaching the report : 2
34.1's RESULTS bug is now : impossible — collect RETURNS the rowsTwo rows reach the report and the exit code is 1. Compare that with Chapter 34.1 Section 6, where twelve simulations produced zero rows and an exit code of 0.
The structural reason is in the last line and it is worth stating plainly:
collect returns its rows. There is no global for the caller to forget to
fill, because the data travels by return value, and main cannot write the
report without having received something. The bug is not fixed — it is
unrepresentable.
Note what else arrived with almost no effort. main takes an argument and
returns an int, which is Chapter 33.8 Section 5's testable entry point; the
if __name__ guard is present, so the module is importable; and the exit code
is 1 when anything failed, which is the promise Chapter 34.6 will document.
9. The Second Consumer
import sys
from pathlib import Path
Path("flow").mkdir(exist_ok=True)
(Path("flow") / "__init__.py").write_text("", encoding="utf-8")
(Path("flow") / "parse.py").write_text(
'print("[import] flow/parse.py")\n'
"def parse_sim_log(text):\n"
" errs = sum(1 for l in text.splitlines()\n"
" if l.startswith('UVM_ERROR') and 'UVM_ERROR :' not in l)\n"
" return {'errors': errs, 'status': 'FAIL' if errs else 'PASS'}\n",
encoding="utf-8")
(Path("flow") / "tools.py").write_text(
'print("[import] flow/tools.py")\n'
"import subprocess\n"
"def run_sim(*a):\n"
" raise RuntimeError('would launch a simulator')\n", encoding="utf-8")
# a completely different script, written by somebody else, months later
Path("triage_old_logs.py").write_text(
'"""Re-parse yesterday\'s logs. Runs no simulations."""\n'
"from pathlib import Path\n"
"\n"
"from flow.parse import parse_sim_log\n"
"\n"
"\n"
"def worst(logs):\n"
" rows = [dict(parse_sim_log(t), name=n) for n, t in logs.items()]\n"
" bad = [r for r in rows if r['status'] != 'PASS']\n"
" return max(bad, key=lambda r: r['errors']) if bad else None\n",
encoding="utf-8")
sys.path.insert(0, ".")
import triage_old_logs
LOGS = {
"run_0001/sim.log": "UVM_ERROR a.sv(1) x\nUVM_ERROR : 1\nTEST DONE\n",
"run_0002/sim.log": "UVM_INFO ok\nTEST DONE\n",
"run_0003/sim.log": ("UVM_ERROR b.sv(2) y\nUVM_ERROR c.sv(3) z\n"
"UVM_ERROR : 2\nTEST DONE\n"),
}
print()
print("worst run:", triage_old_logs.worst(LOGS))
print()
print("flow.tools loaded:", "flow.tools" in sys.modules)
print("simulations run :", 0)
print("lines of flow.parse reused:", "all of it")
print("lines of run_regress reused:", 0)
print()
print("this script was impossible before:", "34.1 §9 — the import ran the regression")[import] flow/parse.py
worst run: {'errors': 2, 'status': 'FAIL', 'name': 'run_0003/sim.log'}
flow.tools loaded: False
simulations run : 0
lines of flow.parse reused: all of it
lines of run_regress reused: 0
this script was impossible before: 34.1 §9 — the import ran the regressionA second script, written months later by somebody else, reusing the parser
and running no simulations. flow.tools never loads — note the single
[import] line — so run_sim's deliberate RuntimeError is never in reach,
and the new script does not inherit run_regress.py's command line, its test
list, or its exit-code policy.
That is the sentence in the canonical purpose, demonstrated: a second script can reuse the parser without inheriting the first script's command line.
10. What Moved and What Did Not
BEFORE = {
"lines": 166, "functions": 3, "testable functions": 0,
"modules": 1, "importable": False, "sys.exit": 0,
}
AFTER = {
"lines": 166, "functions": 9, "testable functions": 5,
"modules": 4, "importable": True, "sys.exit": 1,
}
print(f"{'property':<22} {'34.1':>10} {'34.2':>10} changed")
for k in BEFORE:
changed = BEFORE[k] != AFTER[k]
print(f"{k:<22} {str(BEFORE[k]):>10} {str(AFTER[k]):>10} {changed}")
print()
print("the LOGIC that changed:", "none of it")
print("what changed instead :", "where each piece lives")
print()
UNFIXED = ["16 path-like literals are still hard-coded — 34.5",
"no docstring says what the exit codes mean — 34.6",
"no class, and it may not need one — 34.4",
"parse and report are split but not yet LAYERED — 34.3"]
print("defects from 34.1 still present:", len(UNFIXED))
for u in UNFIXED:
print(" -", u)property 34.1 34.2 changed
lines 166 166 False
functions 3 9 True
testable functions 0 5 True
modules 1 4 True
importable False True True
sys.exit 0 1 True
the LOGIC that changed: none of it
what changed instead : where each piece lives
defects from 34.1 still present: 4
- 16 path-like literals are still hard-coded — 34.5
- no docstring says what the exit codes mean — 34.6
- no class, and it may not need one — 34.4
- parse and report are split but not yet LAYERED — 34.3Zero to five testable functions, with the same logic and about the same number of lines. The line count is the control in that experiment: nothing was rewritten, nothing was added, and the number of functions a test can call went from none to five, because the pure computations Chapter 34.1 Section 10 listed are now in a module that imports nothing and touches nothing.
Four of Chapter 34.1's defects are untouched, and the list names which chapter takes each one. The split was first because every one of those four needs the file to be importable before it can be attempted — which is why the diagram in Chapter 34.1 Section 11 put this stage second, immediately after measuring.
11. Predict, Run, Observe
import sys
from pathlib import Path
Path("pkg").mkdir(exist_ok=True)
(Path("pkg") / "__init__.py").write_text(
'print("init")\n', encoding="utf-8")
(Path("pkg") / "a.py").write_text(
'print("a")\nX = 1\n', encoding="utf-8")
(Path("pkg") / "b.py").write_text(
'print("b")\nfrom pkg.a import X\nY = X + 1\n', encoding="utf-8")
sys.path.insert(0, ".")
# A
from pkg.a import X
print("A:", X, "pkg.b" in sys.modules)
# B
from pkg.b import Y
print("B:", Y, "pkg.a" in sys.modules)
# C
import pkg.a
print("C:", pkg.a is sys.modules["pkg.a"])
# D
Path("nop").mkdir(exist_ok=True)
(Path("nop") / "m.py").write_text("Z = 9\n", encoding="utf-8")
from nop.m import Z
print("D:", Z, sys.modules["nop"].__file__ is None)The verified output:
init
a
A: 1 False
b
B: 2 True
C: True
D: 9 TrueA — importing pkg.a runs pkg/__init__.py then pkg/a.py, and pkg.b
is not loaded. Section 4 in two values.
B — importing pkg.b prints only b, because pkg.a was already in
sys.modules from A and is not re-executed. That is why the [import] lines
in Section 3 appear once each no matter how many modules ask for them.
C — pkg.a the name and sys.modules["pkg.a"] the entry are the same
object, which is Chapter 13.3 Section 7's point and the reason a module-level
global is shared by every importer rather than copied.
D — a directory with no __init__.py imported fine and its package
__file__ is None. Section 7, as one boolean.
12. Exercises
Exercise 1 — one supported use. For your own script, list the things you cannot do with it because importing it runs it.
Exercise 2 — three decisions. Name the three decisions encoded in the annotation's two lines.
Exercise 3 — the unstated one. Say which decision the two lines do not make.
Exercise 4 — split your parser out. Create a package and move the pure text-to-facts logic into it, unchanged.
Exercise 5 — mark the imports. Put a temporary print at the top of each
module and record which ones run.
Exercise 6 — two of four. Confirm that importing one module does not load its siblings.
Exercise 7 — the order surprise. Explain why the [import] lines appear
before the first print in your own driver.
Exercise 8 — what __init__.py costs. Add an expensive line to it and say
who pays.
Exercise 9 — keep it thin. Reduce an __init__.py to a docstring, a
version and re-exports.
Exercise 10 — draw the direction. List every intra-package import in your code and mark its direction.
Exercise 11 — the bottom of the graph. Identify the module that imports nothing, and check it is the pure one.
Exercise 12 — purity versus position. Find a module that is impure and still at the bottom, and explain why both are true.
Exercise 13 — make a cycle. Build the two-module cycle and read the error.
Exercise 14 — both directions. Import each side first and record the two different messages.
Exercise 15 — the victim. Explain why the module named in the error is not the one to fix.
Exercise 16 — partially initialized. Describe, in your own words, the state Python was in when it raised.
Exercise 17 — the real fix. Resolve a cycle by deciding which layer is lower, not by moving the import into a function.
Exercise 18 — no __init__.py. Confirm the import still works.
Exercise 19 — __file__ is None. Write the Path(pkg.__file__).parent
line and watch it fail.
Exercise 20 — who assumes a string. Find code in your own project that
assumes __file__ is a path.
Exercise 21 — two smaller reasons. State the other two reasons to write the file.
Exercise 22 — collect returns. Rewrite one function that communicates by
global so it returns instead.
Exercise 23 — unrepresentable. Explain why Chapter 34.1's RESULTS bug
cannot exist in the new shape.
Exercise 24 — main(args) -> int. Give your entry point an argument and a
return value.
Exercise 25 — the second consumer. Write a script that imports only your parser, and confirm the tool layer does not load.
Exercise 26 — what it did not inherit. List what the second script avoided by not importing the first.
Exercise 27 — the control variable. Count your lines before and after, and say what an unchanged count proves.
Exercise 28 — count the testable surface. Re-run Chapter 34.1 Section 10's measurement on the package.
Exercise 29 — what is still broken. List the defects the split did not fix and which chapter handles each.
Exercise 30 — why this stage first. Explain why the four remaining defects all needed the file to be importable.
13. Summary
- One file supports one use. Running it works; importing it to reuse, test, or explore runs the regression — Chapter 34.1 Section 9's measurement.
- The annotation's two lines encode three decisions: a package, separate modules for parsing and tool control, and import by name rather than path.
from flow.parse import ...runsflow/__init__.pyandflow/parse.pyonly. Two of four modules load;flow.toolsandflow.reportare never executed.- So anything in
__init__.pyis paid by every consumer, including one that only wanted a pure function. Keep it to a docstring, a version and re-exports. - The pure module sits at the bottom and imports nothing from the package, which is what makes it importable by anything and testable without setup.
- Purity and position are different axes —
flow.toolsis impure and also at the bottom, because nothing in the package needs it. - A circular import's error names whichever module you imported first, so the same unchanged cycle produces two different messages and both blame the victim rather than the cause.
- "Partially initialized module" is the real clue: the named module is on the stack, unfinished, and nothing is wrong with either file alone.
- Resolve a cycle by deciding which layer is lower, not by hiding the import inside a function.
- A package with no
__init__.pyimports perfectly well — it becomes a namespace package, so "packages need__init__.py" is false. - Its
__file__isNoneand__path__is a_NamespacePath, which breaks the normalPath(pkg.__file__).parentway of finding a bundled data file. That, not the import, is the reason to write the file. collectreturns its rows, so Chapter 34.1's empty-report bug is not fixed but unrepresentable — the caller cannot write a report without having received something.- A second script, months later, imports only the parser and runs no simulations, inheriting none of the first script's command line.
- Zero to five testable functions with the same logic and the same line count. The unchanged line count is the control: nothing was rewritten, only relocated.
- Four of Chapter 34.1's defects remain, and every one of them needed the file to be importable first — which is why this stage comes immediately after measuring.
The next chapter turns the three modules into three layers, with the dependency direction made explicit and enforced.
Where this fits
Part of the Python curriculum.
