Functions, Errors, and Files
The question: where did the value or failure come from?
Section titled “The question: where did the value or failure come from?”Suppose a program reads a list of numbers but fails before printing the total. Did it fail to open the file, convert a line, or calculate the result? If those jobs have named boundaries, we can follow the failure back to the operation responsible. We’ll start with a function small enough to trace by hand, then use the same idea in a file reader.
Functions give behavior a boundary
Section titled “Functions give behavior a boundary”Suppose several parts of a program need to remove surrounding spaces from a name and make its letters lowercase. Copying the same operations into each place works until we change one copy and forget another. A function gives that job one definition, with a name we can call whenever we need it. It also gives us a smaller question to test: what does this one input become?
Save this complete example as normalize.py and run it with python3 normalize.py. Each named file in this section is separate; do not append one example to another.
def normalize_name(name): cleaned = name.strip().lower() return cleaned
normalized_name = normalize_name(" Ada ")print(normalized_name)name is a parameter. The supplied string is an argument, and return sends exactly one value back to the caller. That boundary hands us a nicely focused question (what output or failure does this particular input produce?).
def defines the function; the indented body records what it will do when called. Defining it doesn’t immediately clean a name. Execution reaches normalize_name(" Ada ") later, and that call supplies the value for the parameter name. The caller is the code making that request.
The dots in name.strip().lower() select operations belonging to the string, called methods. strip() produces text without surrounding whitespace, then lower() produces a lowercase version of that result. Read the chain in order: " Ada " becomes "Ada", then "ada". Neither method edits the original string, because strings are immutable. cleaned refers to the resulting value.
return cleaned ends this call and supplies that value to the expression that called it. The assignment outside the function then binds normalized_name to "ada", and the following print displays it. Returning and printing are separate operations: a returned value can be used in another calculation without ever appearing on the terminal. If a function reaches the end without returning a value explicitly, Python returns None, which represents the absence of a useful value here.
A call pauses the caller until the function returns:
top-level code → normalize_name(" Ada ") → strip → lower → return "ada" ← normalized_name receives "ada" ←───────┘A local name stays with its call
Section titled “A local name stays with its call”A name assigned inside a function has local scope: the caller cannot use that name to reach the function’s local value. Save this separate complete file as scope.py and run it in a fresh process with python3 scope.py:
def double(value): result = value * 2 return result
print(double(6))print(result) # NameErrorThe first print produces 12. The second raises NameError, because this file never defines a top-level result. Returning the value doesn’t export the local name. I’d keep this data flow explicit: the caller supplies an input and receives a result it can inspect without searching for changes to global state.
The inner call finishes before the outer print can run: double(6) computes twelve and returns it, then print receives twelve. The next line asks a different question. It looks up a name called result outside the function, where no such assignment occurred. The value crossed the function boundary; the local name did not.
That separation lets different functions use ordinary names such as result without overwriting one another’s local assignments. Each call has its own local bindings. When reading a name, ask which function or top-level code it belongs to before assuming that another assignment with the same spelling changed it.
Read the last error first
Section titled “Read the last error first”In normalize.py, change only the call to normalize_name(12) and run that file again. Python reports a failure it can’t handle by raising an exception, which is an object naming what went wrong. Raising it stops the current line, and the exception then travels back up the chain of calls until some code catches it, or until it reaches the top and the program stops. The NameError you just saw was one. The traceback shows you that chain of calls, and then the exception type and message at the bottom. Start at that final line and work your way upward to the first frame in your own code. A traceback is a path leading to the failure, and not a ceremonial wall of red text to be scrolled past.
For the changed normalize_name(12) call, the argument exists, but an integer doesn’t provide the string method strip. The final exception therefore identifies an AttributeError. The nearby frame points to the expression attempting that method, and the caller’s frame shows how twelve arrived there. A frame here is the execution context of one call, including its location in the source. It isn’t another failure: the frames connect one failure to the calls leading to it.
Handle a failure you can explain
Section titled “Handle a failure you can explain”Save the next small example as conversion-error.py and run it with python3 conversion-error.py. It handles the failed integer conversion from the previous lesson:
try: number = int("not-a-number")except ValueError as error: print("invalid integer:", error)Avoid a bare except:, since it turns every unrelated bug into the same vague message and throws away useful information on the way.
The try body contains the operation we’re attempting. If int raises ValueError, Python stops that body and looks for a matching except. The handler binds the exception to error so the diagnostic can include its message. Once this handler finishes, execution can continue after the whole statement. It doesn’t retry the conversion or conjure a valid number out of the failed text.
That is a recovery decision, not a requirement to catch every error. A handler for invalid number text cannot also explain a missing file or an unavailable method. Leaving an unexpected exception visible gives us its type and call path instead of a message that pretends we already know what happened.
Catch an exception only at a point where the program can add useful context or actually recover. This version reports which text failed, and then lets the caller decide on a status:
def parse_integer(text): try: return int(text) except ValueError as error: raise ValueError(f"not an integer: {text!r}") from errorThe original exception stays in the traceback as the cause (that’s what from error is doing). Catching TypeError, OSError, and every programming mistake in the same place would erase distinctions worth keeping.
This snippet defines a helper; it doesn’t call it. Save it separately as parse-integer.py and append print(parse_integer("12")) to test a successful return, then change only the argument to "twelve". The failure now includes the text the helper was asked to parse. raise sends an exception outward instead of returning a normal value, and from error connects the new explanation with the original cause.
Inside the formatted string, !r requests the same kind of representation as repr(text), making quotes and whitespace easier to distinguish. Restore the successful call afterward. The complete file-summary example below uses this same pattern to add a filename and line number.
Use Python’s errors and exceptions tutorial to look up the traceback shape and narrow-handler examples when two failures look similar.
Files are external input
Section titled “Files are external input”Create a practice directory, move into it, and make an input file with one integer per line:
mkdir -p ~/underdunn-course/exercises/functions-filescd ~/underdunn-course/exercises/functions-filesprintf '10\n20\n30\n' > numbers.txtSave this script in the same directory as loader.py:
A filename identifies stored input, but we still need an object through which Python can read it. open requests that access. In the example, "r" selects reading, and encoding="utf-8" tells Python how to decode stored bytes into text. The resulting file object is named handle inside the with block.
Opening a file also creates a cleanup obligation. We want it closed when this work ends, including when an exception interrupts the loop. with arranges that cleanup as execution leaves its block. It doesn’t suppress conversion errors or make the file’s contents trustworthy.
def load_numbers(path): numbers = [] with open(path, "r", encoding="utf-8") as handle: for line_number, line in enumerate(handle, start=1): text = line.strip() if text: numbers.append(int(text)) return numbers
print(load_numbers("numbers.txt"))Run python3 loader.py; the clean input should produce [10, 20, 30]. Follow the first line through the body. Iterating over a text file supplies one line at a time, normally including its newline. enumerate(handle, start=1) pairs each line with a number starting at one, and the two names in the for header receive that pair. The number tracks the input file, not the Python source file.
strip() removes surrounding whitespace, so a blank line becomes an empty string. if text skips that empty result. For a nonempty line, int(text) performs conversion before numbers.append(...) adds the resulting integer to the list. append adds one new element to the existing list, which began empty before the loop. Finally, the dedented return numbers runs after all lines have been processed and the file has been closed.
The with statement closes the file even if conversion fails partway through. Add an invalid line, then inspect the traceback. It identifies the conversion in the Python source, but it doesn’t yet report which input-file line failed. Improve the function by catching ValueError, reporting line_number, and re-raising with raise. Reset the input with printf '10\n20\n30\n' > numbers.txt before continuing.
Paths depend on the working directory
Section titled “Paths depend on the working directory”open("numbers.txt") uses a relative path, and Python resolves that path from the process’s current working directory at the time it runs, rather than from the directory the script itself happens to live in.
pwdpython3 loader.pycd ..python3 functions-files/loader.pyThat second run will probably raise FileNotFoundError, even though the script’s still sitting right there where you left it. Add the following import and print near the top of loader.py, before the final call, to display the working directory while you’re investigating:
from pathlib import Path
print(Path.cwd())When input should live beside the script, derive that path explicitly:
SCRIPT_DIR = Path(__file__).resolve().parentinput_path = SCRIPT_DIR / "numbers.txt"That’s a policy decision rather than a universal rule. Command-line tools often accept paths relative to wherever the user invoked them, and both behaviors are defensible depending on what you’re building.
To try the script-relative policy, place those two assignments before the final call in loader.py, then change that call to print(load_numbers(input_path)). __file__ identifies the script, resolve() obtains its resolved path, and .parent selects the containing directory. For a Path object, / "numbers.txt" builds a child path; it isn’t numeric division.
Repeat the two shell invocations. They now select the file beside the script even when you launch it from its parent directory. Check both the working directory and chosen input path, then restore the original loader.py before continuing. The pathlib documentation describes these path operations when you need to adapt the policy.
Standard input is another input source
Section titled “Standard input is another input source”To process piped input, we need a source of lines that is already open. Python provides that stream as sys.stdin. Save this complete example as stdin_demo.py in the practice directory:
import sys
for line in sys.stdin: print(line.strip().lower())Run it as printf ' Alpha \nBETA\n' | python3 stdin_demo.py, and you should get two normalized lines. This loop reads lines the same way whether they arrive from the terminal or a pipe. It doesn’t need separate parsing code for each source.
The shell connects printf to Python’s standard input, as it did for the Linux pipelines. The loop receives a line, strips its surrounding whitespace, converts its letters to lowercase, and prints the result. When printf finishes and its end of the pipe closes, the loop reaches end-of-input and stops. If you run the script without a pipe, it waits for terminal input instead. Enter a line to observe it, then press Ctrl+D on an empty line to end input.
Complete example: deterministic file summary
Section titled “Complete example: deterministic file summary”Return to ~/underdunn-course/exercises/functions-files with cd, then save the following complete file as summarize.py. It replaces the exploratory snippets; no code from loader.py needs to be appended.
import sys
def load_numbers(path): numbers = [] with open(path, "r", encoding="utf-8") as handle: for line_number, line in enumerate(handle, start=1): text = line.strip() if not text or text.startswith("#"): continue try: numbers.append(int(text)) except ValueError as error: raise ValueError(f"{path}:{line_number}: invalid integer {text!r}") from error return numbers
def main(argv): if len(argv) != 2: print(f"usage: {argv[0]} FILE", file=sys.stderr) return 2 try: numbers = load_numbers(argv[1]) except FileNotFoundError: print(f"file not found: {argv[1]}", file=sys.stderr) return 3 except PermissionError: print(f"permission denied: {argv[1]}", file=sys.stderr) return 4 except ValueError as error: print(error, file=sys.stderr) return 5
print(f"count={len(numbers)} total={sum(numbers)}") return 0
raise SystemExit(main(sys.argv))Create the input with printf '3\n# ignored\n5\n\n7\n' > numbers.txt, and expect count=3 total=15 with status 0. Deterministic output means the same file and the same program always produce the same ordered result, with no timestamps, random values, or directory-order assumptions sneaking into the report.
Follow the successful path
Section titled “Follow the successful path”Start reading this program at its last line. Python has already defined the two functions, but neither definition processed a file. main(sys.argv) now receives the argument list, checks that it contains the script name and one path, then calls load_numbers with that path. Here main is a name we chose for the top-level job; Python doesn’t automatically call a function because it has that name.
Inside the loader, if not text or text.startswith("#") skips empty lines and comment lines. not text is true for an empty string. or accepts either condition and only evaluates the second when the first is false. startswith checks the beginning of the stripped text. The # in the input file isn’t a Python comment being executed; it’s a character our program has chosen to interpret as a reason to skip that record.
After the loader returns its list, len(numbers) counts the accepted integers and sum(numbers) adds them. Both values are formatted into the summary. return 0 then supplies an integer to the caller of main. The outer SystemExit uses that integer as the process’s exit status, connecting the Python return value to the status you inspect with $? in the shell.
Follow a failure across the same calls
Section titled “Follow a failure across the same calls”If opening the file raises FileNotFoundError, no list comes back from the loader. Control leaves that call and reaches the matching handler in main, which prints a diagnostic to sys.stderr and returns three. The successful summary is below that return, so it cannot run on this path.
An invalid numeric line takes another route. The loader catches the conversion’s ValueError, adds the path and input line number, and raises a new ValueError with the original cause attached. main catches that type, prints the added context, and returns five. A traceback is useful during diagnosis, while this command-line interface chooses a concise diagnostic for failures it expects. The underlying error didn’t become a valid record just because we handled it.
Modification exercise: choose the input source
Section titled “Modification exercise: choose the input source”Spend 10 to 20 minutes allowing - as a path meaning standard input. Refactor the line parsing into a function accepting any iterable of text lines, and then test both routes:
python3 summarize.py numbers.txtprintf '10\n20\n' | python3 summarize.py -Keep the successful output identical in shape between the two. Then add a test proving that an invalid line arriving from stdin still reports its line number correctly and still returns status 5.
Common failures and recovery
Section titled “Common failures and recovery”FileNotFoundError: recordpwd, the exact path, andls -lof its parent directory. Then decide whether the program wants a caller-relative path or a script-relative one.PermissionError: inspectls -land check the current user withid. Don’t reach reflexively forsudo, and instead repair the intended file permissions or use a file you actually own.- A broad handler prints “something failed”: narrow the
tryblock down to the operation you expect to fail, and catch that specific exception. Let unexpected programming errors keep their tracebacks. - Output changes between runs: sort unordered data before printing it, and strip out timestamps or environment-dependent paths unless those are the thing you’re observing.
Check your result
Section titled “Check your result”Compare a valid input with the missing-file case. The valid input should return a result; a handled input failure should produce its diagnostic and failure status. An unexpected programming error should still show its traceback so you can find the cause. Check the relevant handler if those paths look the same.
What this lets us investigate
Section titled “What this lets us investigate”You can now isolate behavior, preserve the path leading to a failure, and treat file contents as input that hasn’t earned any trust yet. The Python Behavior Lab puts all three together.
Check your understanding
Section titled “Check your understanding”Knowledge check
Question 1 of 2
Practice complete
You've checked the key ideas. Revisit any explanation you need, then continue when it makes sense.