Skip to content
VLSI Mentor

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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 stderr

The 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
if not files_f.exists():
    sys.exit(f"filelist not found: {files_f}")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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                        : True

sys.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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 first

The gap between the first and last rows is not theoretical. Here are five inputs that fall into it.

4. Five Inputs That Pass exists()

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
=== 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 missing

Path("") 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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()))
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
=== 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 : 0

A 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
=== 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 target

exists() 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 directions

Four 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  ''           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 it

Eight 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
  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.12

Both 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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)
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
--- 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                     : 2

One 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.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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?")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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")
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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 handle

The 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

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
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("") is Path(".") and exists, so an empty --filelist "$UNSET" is accepted.
  • A directory passes exists(), then raises IsADirectoryError wherever the read happens.
  • An empty file passes every existence check and compiles nothing.
  • exists() is not readable — mode 000 gives True and then PermissionError.
  • 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 use os.path.lexists to tell them apart.
  • ~ is never expanded by pathlib, 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 try block 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.