Skip to content
Courses

Functions, Errors, and Files

Programming FoundationsLesson 4 of 890 minutesLessonBuilds on: Decisions and RepetitionLab next: Python Behavior Lab

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.

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 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) # NameError

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

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.

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 error

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

Arguments enter a function; a return value or exception crosses back to the caller
CallerArgumentvalue crosses in
Function boundaryParameters + local scopelocal names stay inside
Return valuenormal result
Exceptionfailure path

Use Python’s errors and exceptions tutorial to look up the traceback shape and narrow-handler examples when two failures look similar.

Create a practice directory, move into it, and make an input file with one integer per line:

Terminal window
mkdir -p ~/underdunn-course/exercises/functions-files
cd ~/underdunn-course/exercises/functions-files
printf '10\n20\n30\n' > numbers.txt

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

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.

Terminal window
pwd
python3 loader.py
cd ..
python3 functions-files/loader.py

That 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().parent
input_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.

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.

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.

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:

Terminal window
python3 summarize.py numbers.txt
printf '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.

  • FileNotFoundError: record pwd, the exact path, and ls -l of its parent directory. Then decide whether the program wants a caller-relative path or a script-relative one.
  • PermissionError: inspect ls -l and check the current user with id. Don’t reach reflexively for sudo, and instead repair the intended file permissions or use a file you actually own.
  • A broad handler prints “something failed”: narrow the try block 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.

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.

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.

Knowledge check

Question 1 of 2

scope.py defines top-level result = 99, while double assigns its own result = 12 and returns it. What do print(double(6)) and print(result) print?