Python Part II · Module 33 · Testing and Debugging Automation
Validating Inputs and Failing Fast
A script given a bad path has two ways to behave. It can fail on line four with a message naming the path, or it can get eleven functions deep and raise something about a NoneType, in a log you will read tomorrow. The first is a few lines of work at the top of the program, and the canonical idiom for it is two lines long. Those two lines get the hard part right — the exit code and the stream — and get the easy-looking part wrong, because the question they ask is not the question the script needs answered.
Intermediate21 min readPythonPart IIAutomationValidationpathlib
Part II · Module 33 · Chapter 33.11 · Validating Inputs and Failing Fast
1. Where the Failure Lands
STYLES = [
("no validation", "line 94, inside a parser",
"TypeError: expected str, got None", "no"),
("no validation", "line 211, in the reporter",
"KeyError: 'status'", "no"),
("validate at the top", "line 4, before any work",
"filelist not found: rtl/files.f", "yes"),
]
print(f"{'style':<22} {'where it fails':<28} {'what it says':<36} names the input")
for style, where, says, names in STYLES:
print(f"{style:<22} {where:<28} {says:<36} {names}")
print()
print("the cost of the first two:", "a debugging session")
print("the cost of the third :", "four lines at the top of main()")
print("and 33.8's contract says :", "exit non-zero, message on stderr")style where it fails what it says names the input
no validation line 94, inside a parser TypeError: expected str, got None no
no validation line 211, in the reporter KeyError: 'status' no
validate at the top line 4, before any work filelist not found: rtl/files.f yes
the cost of the first two: a debugging session
the cost of the third : four lines at the top of main()
and 33.8's contract says : exit non-zero, message on stderrThe two bad rows share a property worth naming: the error message does not contain the information you need. KeyError: 'status' is a true statement about a dictionary two hundred lines from the mistake, and it is silent about the fact that someone typed files.f where the flow wanted rtl/files.f.
Validating at the top is not about being careful in general. It is about the error message mentioning the thing that was wrong.
2. The Annotation, and What It Gets Right
if not files_f.exists():
sys.exit(f"filelist not found: {files_f}")import subprocess
import sys
from pathlib import Path
Path("check.py").write_text(
'import sys\n'
'from pathlib import Path\n'
'\n'
'print("starting", flush=True)\n'
'files_f = Path("rtl/files.f")\n'
'if not files_f.exists():\n'
' sys.exit(f"filelist not found: {files_f}")\n'
'print("never reached")\n', encoding="utf-8")
r = subprocess.run([sys.executable, "check.py"], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
print("returncode :", r.returncode)
print("stdout :", repr(r.stdout))
print("stderr :", repr(r.stderr))
print("stderr line count :", len(r.stderr.strip().splitlines()))
print("traceback printed :", "Traceback" in r.stderr)
print()
print("33.8's deliberate-failure signature was:", "rc 1, no Traceback")
print("this matches it :",
r.returncode == 1 and "Traceback" not in r.stderr)returncode : 1
stdout : 'starting\n'
stderr : 'filelist not found: rtl/files.f\n'
stderr line count : 1
traceback printed : False
33.8's deliberate-failure signature was: rc 1, no Traceback
this matches it : Truesys.exit with a string does three correct things at once, and most people who write it do not know all three. It prints the string to stderr — not stdout, so a pipeline's data stays clean. It exits with code 1. And it prints no traceback, because SystemExit is handled rather than propagated.
That is precisely the signature Chapter 33.8 Section 3 identified as a deliberate failure, distinguishable from a crash. Two lines, and the command-line contract is satisfied.
Note also what sys.exit is not: it is not exit(), which exists only because the site module adds it for interactive use and may be absent in a stripped environment; and it is not os._exit, which skips cleanup entirely — the distinction Chapter 33.13 depends on.
So the shape is right. The predicate is the problem.
3. exists() Answers a Different Question
QUESTIONS = [
("exists()", "is there a directory entry here, after following links?"),
("is_file()", "is it a regular file?"),
("os.access(p, R_OK)", "does the OS think I may read it?"),
("open(p).read(1)", "can I actually read a byte of it right now?"),
]
print(f"{'what you write':<22} what you are actually asking")
for call, asks in QUESTIONS:
print(f"{call:<22} {asks}")
print()
print("what the script needs to know:", "can I use this as a filelist?")
print("which of the four asks that :", "the last one, approximately")
print("which one the annotation uses:", "the first")what you write what you are actually asking
exists() is there a directory entry here, after following links?
is_file() is it a regular file?
os.access(p, R_OK) does the OS think I may read it?
open(p).read(1) can I actually read a byte of it right now?
what the script needs to know: can I use this as a filelist?
which of the four asks that : the last one, approximately
which one the annotation uses: the firstThe gap between the first and last rows is not theoretical. Here are five inputs that fall into it.
4. Five Inputs That Pass exists()
import os
from pathlib import Path
print("=== 1. the empty string")
for s in ["", ".", "rtl/files.f", "~/files.f"]:
p = Path(s)
print(f" Path({s!r:<14}) -> {str(p)!r:<14} exists={p.exists()}")
print(" so --filelist '' passes the annotation's check:", Path("").exists())
print(" and ~ is never expanded, so a shell-valid path reads as missing")=== 1. the empty string
Path('' ) -> '.' exists=True
Path('.' ) -> '.' exists=True
Path('rtl/files.f' ) -> 'rtl/files.f' exists=False
Path('~/files.f' ) -> '~/files.f' exists=False
so --filelist '' passes the annotation's check: True
and ~ is never expanded, so a shell-valid path reads as missingPath("") is Path("."), and the current directory exists, so an empty argument is accepted as a filelist. An unset shell variable expanding to nothing — --filelist "$FILELIST" with FILELIST never set — produces exactly that, which is Chapter 24.5's unset-variable hazard arriving through a different door.
The fourth row is the mirror image: ~/files.f does not exist as far as pathlib is concerned, because tilde expansion is the shell's job and nobody did it. A path a user can paste into their terminal and cat successfully is rejected as missing. Path(s).expanduser() is the fix, and it has to be applied deliberately.
import os
from pathlib import Path
print("=== 2. a directory")
os.makedirs("adir", exist_ok=True)
d = Path("adir")
print(f" exists={d.exists()} is_file={d.is_file()} is_dir={d.is_dir()}")
print(" the annotation's check passes:", d.exists())
try:
d.read_text(encoding="utf-8")
except Exception as exc:
print(" then read_text raises :", type(exc).__name__)
print()
print("=== 3. an empty file")
Path("empty.f").write_text("", encoding="utf-8")
e = Path("empty.f")
print(f" exists={e.exists()} is_file={e.is_file()} size={e.stat().st_size}")
print(" every existence check passes:", e.exists() and e.is_file())
print(" files the flow will compile :", len(e.read_text(encoding='utf-8').split()))=== 2. a directory
exists=True is_file=False is_dir=True
the annotation's check passes: True
then read_text raises : IsADirectoryError
=== 3. an empty file
exists=True is_file=True size=0
every existence check passes: True
files the flow will compile : 0A directory passes exists(), and the failure arrives later as IsADirectoryError from inside whatever function first tries to read it — which is Section 1's second row, in the flesh. --filelist rtl/ is an easy thing to type.
An empty file passes everything, including is_file(), and then the flow compiles zero files. Chapter 24.4's point about a gate that says PASS on a zero-byte report is the same observation: present and usable are different properties, and only one of them is cheap to check.
import os
from pathlib import Path
print("=== 4. a file you may not read")
Path("locked.f").write_text("rtl/top.v\n", encoding="utf-8")
os.chmod("locked.f", 0)
p = Path("locked.f")
print(f" exists={p.exists()} is_file={p.is_file()}")
print(" os.access(R_OK) :", os.access("locked.f", os.R_OK))
try:
p.read_text(encoding="utf-8")
print(" read succeeded : True")
except Exception as exc:
print(" read raises :", type(exc).__name__)
os.chmod("locked.f", 0o644) # restore, or cleanup gets awkward
print()
print("=== 5. a broken symlink — the reverse failure")
if os.path.lexists("link.f"):
os.remove("link.f")
os.symlink("does_not_exist.f", "link.f")
b = Path("link.f")
print(" exists() :", b.exists())
print(" is_symlink() :", b.is_symlink())
print(" os.path.lexists :", os.path.lexists("link.f"))
print(" readlink :", os.readlink("link.f"))
print()
print(" so the message 'filelist not found: link.f' is:", "misleading")
print(" what is actually wrong :", "its target")=== 4. a file you may not read
exists=True is_file=True
os.access(R_OK) : False
read raises : PermissionError
=== 5. a broken symlink — the reverse failure
exists() : False
is_symlink() : True
os.path.lexists : True
readlink : does_not_exist.f
so the message 'filelist not found: link.f' is: misleading
what is actually wrong : its targetexists() is not readable. A file with mode 0 exists, is a regular file, and raises PermissionError on the first read. In a shared flow directory with per-user permissions this is routine rather than exotic.
And case 5 runs the other way. A broken symlink reads as absent — exists() follows the link and reports on the target — so the annotation fires and prints filelist not found: link.f, about a path that is sitting right there in the directory listing. The engineer reading that message will run ls, see the file, and conclude the script is broken. The honest message names the target, and os.path.lexists is how you tell the two cases apart.
CASES = [
("Path('') from an unset var", "True", "it is Path('.')", "reject empty first"),
("a directory", "True", "IsADirectoryError later", "is_file()"),
("an empty file", "True", "compiles nothing", "check the size"),
("mode 000", "True", "PermissionError later", "try reading a byte"),
("a broken symlink", "False", "a wrong message", "os.path.lexists"),
("~/files.f", "False", "a wrong message", "expanduser()"),
]
print(f"{'input':<28} {'exists()':<9} {'what goes wrong':<26} what detects it")
for inp, ex, wrong, fix in CASES:
print(f"{inp:<28} {ex:<9} {wrong:<26} {fix}")
wrong_pass = sum(1 for _, ex, _, _ in CASES if ex == "True")
wrong_fail = sum(1 for _, ex, _, _ in CASES if ex == "False")
print()
print("unusable inputs that PASS the check:", wrong_pass)
print("usable inputs that FAIL the check :", wrong_fail)
print("so exists() errs :", "in both directions")input exists() what goes wrong what detects it
Path('') from an unset var True it is Path('.') reject empty first
a directory True IsADirectoryError later is_file()
an empty file True compiles nothing check the size
mode 000 True PermissionError later try reading a byte
a broken symlink False a wrong message os.path.lexists
~/files.f False a wrong message expanduser()
unusable inputs that PASS the check: 4
usable inputs that FAIL the check : 2
so exists() errs : in both directionsFour false accepts and two false rejects. That last row of the table is the point: exists() is not a weak check that lets things through, it is the wrong check, and it is wrong in both directions.
5. Ask the Question You Actually Have
import os
from pathlib import Path
def check_filelist(raw):
"""Return (ok, message). The message is written for the operator.
The predicate is chosen to match the USE: the flow is going to read
this file and expect compilable paths in it, so the check reads it.
"""
if not raw or not raw.strip():
return False, "no filelist given (--filelist was empty)"
p = Path(raw).expanduser()
if p.is_symlink() and not p.exists():
return False, f"filelist is a broken symlink: {p} -> {os.readlink(p)}"
if not os.path.lexists(p):
return False, f"filelist not found: {p}"
if p.is_dir():
return False, f"filelist is a directory, not a file: {p}"
if not p.is_file():
return False, f"filelist is not a regular file: {p}"
try:
text = p.read_text(encoding="utf-8")
except PermissionError:
return False, f"filelist is not readable: {p}"
except UnicodeDecodeError:
return False, f"filelist is not utf-8 text: {p}"
if not text.strip():
return False, f"filelist is empty: {p}"
return True, f"filelist ok: {p} ({len(text.split())} entries)"
# build every case from §4
os.makedirs("adir", exist_ok=True)
Path("empty.f").write_text("", encoding="utf-8")
Path("good.f").write_text("rtl/top.v\nrtl/alu.v\n", encoding="utf-8")
Path("locked.f").write_text("x\n", encoding="utf-8")
os.chmod("locked.f", 0)
Path("binary.f").write_bytes(b"\xff\xfe\x00rtl\n")
if os.path.lexists("link.f"):
os.remove("link.f")
os.symlink("does_not_exist.f", "link.f")
for raw in ["", " ", "adir", "empty.f", "locked.f", "binary.f",
"link.f", "nosuch.f", "good.f"]:
ok, msg = check_filelist(raw)
print(f" {raw!r:<12} {str(ok):<6} {msg}")
os.chmod("locked.f", 0o644)
print()
print("distinct messages:", 8)
print("each one names :", "the path and what is wrong with it") '' False no filelist given (--filelist was empty)
' ' False no filelist given (--filelist was empty)
'adir' False filelist is a directory, not a file: adir
'empty.f' False filelist is empty: empty.f
'locked.f' False filelist is not readable: locked.f
'binary.f' False filelist is not utf-8 text: binary.f
'link.f' False filelist is a broken symlink: link.f -> does_not_exist.f
'nosuch.f' False filelist not found: nosuch.f
'good.f' True filelist ok: good.f (2 entries)
distinct messages: 8
each one names : the path and what is wrong with itEight distinct messages, each naming the path and the actual problem. The broken-symlink line even prints the target, which is the one piece of information that turns a baffling message into an obvious one.
Two structural choices in that function are worth copying. It returns (ok, message) rather than calling sys.exit itself — Chapter 33.2's seam again, and the reason Section 7 can collect several results before deciding anything. And the order of checks runs from most specific to least: the symlink test comes before the not-found test, because a broken link would otherwise be reported as missing.
6. Validate the Values, Not Only the Paths
def check_seed(raw):
try:
n = int(raw)
except (TypeError, ValueError):
return False, f"seed must be an integer, got {raw!r}"
if not 0 <= n < 2 ** 32:
return False, f"seed out of range for a 32-bit simulator: {n}"
return True, f"seed = {n}"
def check_count(raw):
try:
n = int(raw)
except (TypeError, ValueError):
return False, f"count must be an integer, got {raw!r}"
if n < 1:
return False, f"count must be at least 1, got {n}"
if n > 10_000:
return False, f"count {n} is above the 10000 sanity bound"
return True, f"count = {n}"
for raw in ["4121", "0", "-1", "4294967296", "0x10", "", "1e3"]:
ok, msg = check_seed(raw)
print(f" seed {raw!r:<14} {str(ok):<6} {msg}")
print()
for raw in ["50", "0", "-3", "100000", "ten"]:
ok, msg = check_count(raw)
print(f" count {raw!r:<14} {str(ok):<6} {msg}")
print()
print("note '0x10' :", "int() with no base rejects it — 4.9 §5")
print("note '1e3' :", "int() rejects it; float('1e3') would not")
print("the bound :", "a sanity bound, not a limit — §7 of 33.12") seed '4121' True seed = 4121
seed '0' True seed = 0
seed '-1' False seed out of range for a 32-bit simulator: -1
seed '4294967296' False seed out of range for a 32-bit simulator: 4294967296
seed '0x10' False seed must be an integer, got '0x10'
seed '' False seed must be an integer, got ''
seed '1e3' False seed must be an integer, got '1e3'
count '50' True count = 50
count '0' False count must be at least 1, got 0
count '-3' False count must be at least 1, got -3
count '100000' False count 100000 is above the 10000 sanity bound
count 'ten' False count must be an integer, got 'ten'
note '0x10' : int() with no base rejects it — 4.9 §5
note '1e3' : int() rejects it; float('1e3') would not
the bound : a sanity bound, not a limit — §7 of 33.12Both functions put the rejected value into the message, with !r so an empty string and a string of spaces are distinguishable. A message reading seed must be an integer without saying what it got is a message that makes you go and find the command line.
The count > 10_000 check is a different kind of rule from the others, and the difference matters. It is not a correctness bound — ten thousand runs is a legal thing to want — it is a typo bound, catching --count 1000O and a stray paste. A check like that should say so in its message, which is why it reads "above the 10000 sanity bound" rather than "invalid".
7. Collect Every Failure, Then Decide
import os
from pathlib import Path
Path("good.f").write_text("rtl/top.v\n", encoding="utf-8")
def check_filelist(raw):
if not raw or not raw.strip():
return False, "no filelist given"
p = Path(raw).expanduser()
if not p.is_file():
return False, f"filelist not usable: {p}"
return True, f"filelist ok: {p}"
def check_seed(raw):
try:
n = int(raw)
except (TypeError, ValueError):
return False, f"seed must be an integer, got {raw!r}"
return (True, f"seed = {n}") if 0 <= n < 2 ** 32 else \
(False, f"seed out of range: {n}")
def check_outdir(raw):
p = Path(raw).expanduser()
if p.exists() and not p.is_dir():
return False, f"outdir exists and is not a directory: {p}"
return True, f"outdir = {p}"
def check_count(raw):
try:
n = int(raw)
except (TypeError, ValueError):
return False, f"count must be an integer, got {raw!r}"
return (True, f"count = {n}") if n >= 1 else \
(False, f"count must be at least 1, got {n}")
ARGS = {"filelist": "", "seed": "0x10", "outdir": "good.f", "count": "0"}
CHECKS = [("filelist", check_filelist), ("seed", check_seed),
("outdir", check_outdir), ("count", check_count)]
print("--- fail on the first problem")
for name, fn in CHECKS:
ok, msg = fn(ARGS[name])
if not ok:
print(" error:", msg)
print(" exit : 2")
break
print(" problems learned this run:", 1)
print(" runs needed to learn all :", 4)
print()
print("--- collect them all")
results = [(name, *fn(ARGS[name])) for name, fn in CHECKS]
bad = [(n, m) for n, ok, m in results if not ok]
for n, m in bad:
print(f" error [{n}]: {m}")
print(" problems learned this run:", len(bad))
print(" runs needed to learn all :", 1)
print(" exit :", 2)--- fail on the first problem
error: no filelist given
exit : 2
problems learned this run: 1
runs needed to learn all : 4
--- collect them all
error [filelist]: no filelist given
error [seed]: seed must be an integer, got '0x10'
error [outdir]: outdir exists and is not a directory: good.f
error [count]: count must be at least 1, got 0
problems learned this run: 4
runs needed to learn all : 1
exit : 2One run instead of four. Fail-fast is about failing before doing work, not about failing at the first check — and the two get conflated constantly. Running all four validators costs microseconds and tells the operator everything that is wrong with their command line in one go.
That is only possible because each validator returns its verdict rather than exiting. A validator that calls sys.exit itself cannot be one of four, which is the practical reason for the seam in Section 5.
import sys
def validate_all(results):
"""Report everything, then exit once. Returns the arguments on success."""
bad = [(name, msg) for name, ok, msg in results if not ok]
if bad:
for name, msg in bad:
print(f"error [{name}]: {msg}", file=sys.stderr)
print(f"{len(bad)} invalid argument(s); nothing was run",
file=sys.stderr)
return None
return {name: msg for name, _, msg in results}
BAD = [("filelist", False, "no filelist given"),
("seed", False, "seed must be an integer, got '0x10'"),
("outdir", True, "outdir = results"),
("count", False, "count must be at least 1, got 0")]
GOOD = [("filelist", True, "filelist ok: rtl/files.f"),
("seed", True, "seed = 4121"),
("outdir", True, "outdir = results"),
("count", True, "count = 50")]
print("with bad arguments, validate_all returns:", validate_all(BAD))
print()
print("with good arguments:")
for k, v in (validate_all(GOOD) or {}).items():
print(f" {k:<10} {v}")
print()
print("the last line of the error report says:", "'nothing was run'")
print("which answers the operator's question :", "did it start?")error [filelist]: no filelist given
error [seed]: seed must be an integer, got '0x10'
error [count]: count must be at least 1, got 0
3 invalid argument(s); nothing was run
with bad arguments, validate_all returns: None
with good arguments:
filelist filelist ok: rtl/files.f
seed seed = 4121
outdir outdir = results
count count = 50
the last line of the error report says: 'nothing was run'
which answers the operator's question : did it start?The errors go to stderr and the summary line reports a count — and then nothing was run, which is the sentence that saves someone from going to look for partial results. Chapter 33.13 is about the cases where that sentence would be a lie.
8. What Validation Cannot Do
import os
from pathlib import Path
Path("files.f").write_text("rtl/top.v\n", encoding="utf-8")
p = Path("files.f")
print("at the moment of the check:")
print(" is_file():", p.is_file())
# between the check and the use, the world moves
os.remove("files.f")
print("at the moment of the use:")
try:
p.read_text(encoding="utf-8")
except FileNotFoundError as exc:
print(" read raises:", type(exc).__name__)
print()
print("the check was :", "correct when it ran")
print("the window it leaves :", "check-to-use, always non-zero")
print("so the use must still :", "handle the error")
print()
print("what validation is for :", "the operator's typo")
print("what it is not for :", "a race against another process")
print("the reliable form :", "try to open it and use the handle")at the moment of the check:
is_file(): True
at the moment of the use:
read raises: FileNotFoundError
the check was : correct when it ran
the window it leaves : check-to-use, always non-zero
so the use must still : handle the error
what validation is for : the operator's typo
what it is not for : a race against another process
the reliable form : try to open it and use the handleThe check was right and the use still failed. A validator reports on the world at the instant it ran, and the program uses the world a moment later — so pre-validation narrows the window and never closes it. Chapter 25.4's try-and-handle argument is still required underneath.
This is worth being clear about rather than quietly hoping, because the two purposes pull in different directions. Validation exists so that a mistyped argument produces a good message instead of a traceback, and it is excellent at that. It is not a concurrency mechanism, and a flow where another job deletes your filelist mid-run needs the try block, not a longer pre-flight.
Which is the distinction the next chapter builds on: a pre-flight check is a cheap, early, fallible read of the environment whose whole value is that it runs before an eight-hour sweep rather than during it.
9. Predict, Run, Observe
import os
import subprocess
import sys
from pathlib import Path
Path("t.py").write_text(
'import sys\n'
'from pathlib import Path\n'
'p = Path(sys.argv[1])\n'
'if not p.exists():\n'
' sys.exit(f"not found: {p}")\n'
'print("accepted")\n', encoding="utf-8")
os.makedirs("adir", exist_ok=True)
Path("empty.f").write_text("", encoding="utf-8")
def probe(arg):
r = subprocess.run([sys.executable, "t.py", arg], stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
return r.returncode, r.stdout.strip() or r.stderr.strip()
# A
print("A:", probe(""))
# B
print("B:", probe("adir"))
# C
print("C:", probe("empty.f"))
# D
print("D:", probe("nosuch.f"))The verified output:
A: (0, 'accepted')
B: (0, 'accepted')
C: (0, 'accepted')
D: (1, 'not found: nosuch.f')A — the empty string is accepted, because Path("") is Path("."). An unset variable gets through.
B — a directory is accepted.
C — a zero-byte file is accepted.
D — and the one case the check was written for works correctly: exit 1, message on stderr, path named.
Three of four accepts are wrong, and the one rejection is right. That is the ratio Section 4 measured, and it is the reason to choose a predicate that matches the use.
10. Exercises
Exercise 1 — where it lands. Find a script of yours that fails deep, and write down what its error message does not say.
Exercise 2 — the message names the input. Rewrite that failure as a top-of-main check.
Exercise 3 — three things at once. Confirm for yourself that sys.exit("msg") exits 1, writes to stderr, and prints no traceback.
Exercise 4 — the contract. Say which of Chapter 33.8's exit codes a validation failure should use in your flow, and why.
Exercise 5 — four questions. For one input, write down which of the four predicates in Section 3 matches how you use it.
Exercise 6 — the empty string. Confirm Path("").exists() and explain how an unset shell variable produces it.
Exercise 7 — tilde. Check whether your scripts accept ~/... paths, and add expanduser() where they should.
Exercise 8 — a directory. Pass a directory where your script wants a file and record where it fails.
Exercise 9 — zero bytes. Pass an empty filelist and say what the flow does.
Exercise 10 — unreadable. chmod 000 a file and confirm exists() still returns True.
Exercise 11 — the broken symlink. Create one, run the annotation's check, and explain why the message misleads.
Exercise 12 — both directions. Explain in two sentences why exists() is wrong rather than merely weak.
Exercise 13 — read a byte. Write check_filelist for your own flow with a predicate that matches the use.
Exercise 14 — most specific first. Explain why the symlink test must precede the not-found test.
Exercise 15 — return, do not exit. Explain what collecting all failures requires of each validator.
Exercise 16 — proportionate. Pick an argument in your flow that needs no pre-check and justify it.
Exercise 17 — the rejected value. Add !r to a validation message and say what it now distinguishes.
Exercise 18 — a range. Write a seed validator with your simulator's real range.
Exercise 19 — a typo bound. Add a sanity bound to a count and word the message so it reads as a typo bound.
Exercise 20 — 0x10. Explain why int("0x10") fails and when you would want int(raw, 0) instead.
Exercise 21 — four runs or one. Give four bad arguments to one of your scripts and count the runs needed to learn everything.
Exercise 22 — collect them. Build validate_all and report every problem in one pass.
Exercise 23 — nothing was run. Add that sentence and say which question it answers.
Exercise 24 — the window. Reproduce the check-then-delete measurement.
Exercise 25 — still handle it. Add the try block that Section 8 says is still required.
Exercise 26 — what it is for. State in one sentence what validation is for and what it is not.
11. Summary
- Validate at the top so the error message names the input.
KeyError: 'status'two hundred lines later is true and useless. sys.exit("message")does three right things: stderr, exit code 1, and no traceback — Chapter 33.8's deliberate-failure signature in two lines.- The annotation's shape is right and its predicate is not.
exists()asks whether a directory entry resolves; the script needs to know whether it can read a filelist. Path("")isPath(".")and exists, so an empty--filelist "$UNSET"is accepted.- A directory passes
exists(), then raisesIsADirectoryErrorwherever the read happens. - An empty file passes every existence check and compiles nothing.
exists()is not readable — mode000givesTrueand thenPermissionError.- A broken symlink reads as absent, so "not found" is the wrong message about a file you can see in
ls. Name the target, and useos.path.lexiststo tell them apart. ~is never expanded bypathlib, so a path that works in the shell reads as missing.- Four false accepts and two false rejects —
exists()errs in both directions. - Choose the predicate that matches the use, order the checks most-specific first, and put the path and the actual problem in every message.
- Put the rejected value in the message with
!r, so an empty string and a space are distinguishable. - A sanity bound is a typo bound, not a correctness limit, and its message should say so.
- Fail before doing work, not at the first check. Four validators cost microseconds and save three runs; that needs each one to return rather than exit.
- Say "nothing was run", because that is the operator's next question.
- Validation narrows the check-to-use window and never closes it. It is for the operator's typo, not for a race — the
tryblock is still required.
The next chapter takes this from one argument to the whole environment, before a sweep that will run for hours.
Where this fits
Part of the Python curriculum.
