Functions, modules and exceptions

def, imports, __main__, with, try/except and reading a traceback.

Intermediate30 min · lesson 3 of 16
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (7 files, 2 KB): ps-functions.tar.gz. Unpack it with tar -xzf ps-functions.tar.gz, which creates ps-functions/. SHA-256: 5f0813be7aa639be48c6172a4e8e4c2a1b163007745d211fd308e492db972697

This lesson turns loose statements into a small tool you can trust. You will write functions with default and keyword arguments, split code into a module you import, give a script a proper entry point with command-line arguments and an exit status, and handle errors with try/except. You will also learn to read a Python 3.14 traceback, including a chained one, which is the fastest way to find out why automation stopped. The example is an inventory checker: it reads host:port targets from a file and reports the lines that are wrong. Last, you will define your own types with classes and dataclasses, which every later lesson uses. The lab works in ~/ps-functions; unpack the lesson files in your home directory, or create the files as they appear.

Functions: def, return, defaults and keyword arguments

A Bash function receives $1, $2 and returns only a status; data comes back through standard output. A Python function names its parameters, returns any value, and signals failure by raising an exception. Here is the module the rest of the lesson uses:

hostport.py
"""Parse host[:port] targets from inventory files."""
def parse_port(text: str) -> int:
"""Return text as a TCP port number, or raise ValueError."""
try:
port = int(text)
except ValueError as err:
raise ValueError(f"not a port number: {text!r}") from err
if not 1 <= port <= 65535:
raise ValueError(f"port out of range: {port}")
return port
def parse_target(line: str, default_port: int = 22) -> tuple[str, int]:
"""Split 'host:port' or 'host' into a (host, port) pair."""
host, sep, port_text = line.strip().partition(":")
if not host:
raise ValueError("empty host name")
port = parse_port(port_text) if sep else default_port
return host, port
if __name__ == "__main__":
print(parse_target("db01:5432"))
print(parse_target("bastion"))
print(parse_target("bastion", default_port=2222))

def defines a function, and the indented block is its body. The string on the first line of the body is a docstring: documentation that tools such as help() show. return hands a value back to the caller. raise ValueError(...) stops the function and reports a problem instead of returning a wrong value (the try/except inside parse_port is explained further down). parse_target returns a tuple, so the caller can unpack it with host, port = parse_target(line).

default_port: int = 22 gives the parameter a default, so a caller may leave it out. A caller can also pass it by name, parse_target("bastion", default_port=2222), which keeps calls readable when a function has several options. line.strip().partition(":") removes surrounding whitespace, then splits at the first colon into three parts: before, the colon itself (empty if there was none) and after.

deploy@web01:~/ps-functions · Ubuntu 26.04 LTS
$ python3 hostport.py
('db01', 5432) ('bastion', 22) ('bastion', 2222)

The annotations, text: str and -> int, are type hints. They document what the function expects and returns, and editors and checkers such as mypy use them. Python itself does not enforce them at run time:

deploy@web01:~/ps-functions · Ubuntu 26.04 LTS
$ python3 -c 'from hostport import parse_port; print(parse_port(8443.9))'
8443

The hint says str, the call passed the float 8443.9, and int() quietly dropped the fraction. Hints tell readers what is intended; when input comes from outside, the function still has to check it, which is why parse_port tests the range itself. Hints with square brackets say what a collection holds: tuple[str, int] is a pair of a str and an int, list[str] a list of strings, dict[int, int] a dict from int to int.

Modules, imports and __main__

Every .py file is a module. import hostport finds hostport.py (the directory of the script being run, or the current directory for python3 -c, comes first on the search path), runs it from top to bottom once, and gives you its names as hostport.parse_target. from hostport import parse_target imports one name directly. This is Bash's source lib.sh, with one difference: the module keeps its own namespace, so its variables cannot overwrite yours.

deploy@web01:~/ps-functions · Ubuntu 26.04 LTS
$ python3 -c 'import hostport; print(hostport.parse_target("db01:5432"))'
('db01', 5432)

Importing did not print the three demo tuples. When Python runs a file directly it sets the module's __name__ to "__main__"; when the file is imported, __name__ is the module name. if __name__ == "__main__": therefore runs its block only for python3 hostport.py. Put the code that should act (print, parse arguments, exit) under that guard, and keep definitions above it, so any file can be imported and tested without side effects.

When something fails: reading a traceback

A first script that uses the module, written the way most first scripts are:

targets.txt
# inventory: host[:port], default port 22
web01:443
db01:5432
bastion
db02:ssh
cache01:99999
show_targets.py
import sys
from hostport import parse_target
with open(sys.argv[1], encoding="utf-8") as inventory:
for line in inventory:
if line.strip() and not line.startswith("#"):
host, port = parse_target(line)
print(f"{host:<10} {port:>5}")

sys.argv is the list of command-line arguments: sys.argv[0] is the script (Bash's $0), sys.argv[1] the first argument ($1). with open(...) as inventory: opens the file and guarantees it is closed when the block ends, even if an error escapes from inside it. encoding="utf-8" says how to turn the file's bytes into text, instead of depending on the locale. Now run it wrong, then with the inventory:

deploy@web01:~/ps-functions · Ubuntu 26.04 LTS
$ python3 show_targets.py
Traceback (most recent call last): File "/home/deploy/ps-functions/show_targets.py", line 5, in <module> with open(sys.argv[1], encoding="utf-8") as inventory: ~~~~~~~~^^^ IndexError: list index out of range
$ python3 show_targets.py targets.txt
web01 443 db01 5432 bastion 22 Traceback (most recent call last): File "/home/deploy/ps-functions/hostport.py", line 7, in parse_port port = int(text) ValueError: invalid literal for int() with base 10: 'ssh' The above exception was the direct cause of the following exception: Traceback (most recent call last): File "/home/deploy/ps-functions/show_targets.py", line 8, in <module> host, port = parse_target(line) ~~~~~~~~~~~~^^^^^^ File "/home/deploy/ps-functions/hostport.py", line 20, in parse_target port = parse_port(port_text) if sep else default_port ~~~~~~~~~~^^^^^^^^^^^ File "/home/deploy/ps-functions/hostport.py", line 9, in parse_port raise ValueError(f"not a port number: {text!r}") from err ValueError: not a port number: 'ssh'

Without an argument, sys.argv holds only the script name, and sys.argv[1] raises IndexError. The carets mark sys.argv[1], so you know which index was out of range. Bash without set -u would have expanded an empty $1 and carried on.

The second run printed three good targets, then met db02:ssh and produced a chained traceback, two tracebacks joined by a sentence. Read the bottom one first. Its last line, ValueError: not a port number: 'ssh', is the message parse_port wrote for people. Above it are the frames, oldest at the top: show_targets.py line 8 called parse_target, which called parse_port, which raised at line 9. The top traceback is the cause: int("ssh") failed at line 7 with Python's own message.

The joining sentence tells you how the two relate. "The above exception was the direct cause of the following exception" comes from raise ... from err: parse_port caught the low-level error and replaced it with a clearer one, keeping the original attached. If you see "During handling of the above exception, another exception occurred" instead, a new error was raised inside an except block without from, and more often than not that second error is a bug of its own. Nothing in show_targets.py caught the ValueError, so it travelled all the way up, Python printed the traceback and exited with status 1.

Handling the failures you expect

A bad inventory line is an expected problem, not a crash. The version you would actually run catches it, reports it and keeps going:

check_targets.py
import sys
from hostport import parse_target
def check_file(path: str) -> int:
"""Print every valid target in path and return the number of bad lines."""
bad = 0
with open(path, encoding="utf-8") as inventory:
for number, line in enumerate(inventory, start=1):
if not line.strip() or line.startswith("#"):
continue
try:
host, port = parse_target(line)
except ValueError as err:
print(f"{path}:{number}: {err}", file=sys.stderr)
bad += 1
else:
print(f"{host:<10} {port:>5}")
return bad
def main(argv: list[str]) -> int:
if len(argv) != 1:
print("usage: check_targets.py FILE", file=sys.stderr)
return 2
try:
bad = check_file(argv[0])
except OSError as err:
print(f"check_targets: {err}", file=sys.stderr)
return 2
finally:
print(f"check_targets: finished {argv[0]}", file=sys.stderr)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
Where each error is handled in check_targets.py
1parse_port raises ValueError
bad or out-of-range port
2parse_target does not catch it
the exception passes up to its caller
3check_file catches ValueError
prints file:line and the message, counts it, goes on
4main catches OSError
missing or unreadable file: message, return 2
5sys.exit(main(...))
the return value becomes the exit status
deploy@web01:~/ps-functions · Ubuntu 26.04 LTS
$ python3 check_targets.py targets.txt; echo "exit status $?"
web01 443 db01 5432 bastion 22 targets.txt:5: not a port number: 'ssh' targets.txt:6: port out of range: 99999 check_targets: finished targets.txt exit status 1
$ python3 check_targets.py targets.txt 2>/dev/null
web01 443 db01 5432 bastion 22
$ python3 check_targets.py missing.txt; echo "exit status $?"
check_targets: [Errno 2] No such file or directory: 'missing.txt' check_targets: finished missing.txt exit status 2
$ python3 check_targets.py; echo "exit status $?"
usage: check_targets.py FILE exit status 2

try runs its block; if a ValueError is raised inside it, the except ValueError as err block runs, with the exception in err, and the loop continues. The else block runs only when nothing was raised, which keeps the success path out of the try, so a bug in the print line cannot be mistaken for a bad port. enumerate(inventory, start=1) numbers the lines, which is how the messages can say targets.txt:5.

Look at where the output goes. Error messages use print(..., file=sys.stderr) and the valid targets go to standard output, so 2>/dev/null leaves only the data, just as the Bash course taught for scripts. The exit statuses form a small contract: 0 when every line is valid, 1 when some are not, 2 for a usage error or a file that cannot be read. main() returns the number and sys.exit() turns it into the process exit status, the value $? shows. The argparse lesson formalises this contract.

A missing file raises FileNotFoundError, one kind of OSError, and main reports it in one line instead of a traceback. The finally block runs whichever way the try ends, even after the except block's return 2: that is why "finished missing.txt" still appears. Use finally for work that must happen on every path, such as releasing something or writing a final log line. The usage error returns before the try starts, so no "finished" line appears there.

Catch only what you expect

It is tempting to wrap code in except Exception: so the script never crashes. This short program has a typo:

broad_except.py
from hostport import parse_port
try:
port = parse_prot("443")
except Exception:
print("skipping bad port value")
deploy@web01:~/ps-functions · Ubuntu 26.04 LTS
$ python3 broad_except.py
skipping bad port value
$ sed "s/except Exception:/except ValueError:/" broad_except.py > narrow_except.py python3 narrow_except.py
Traceback (most recent call last): File "/home/deploy/ps-functions/narrow_except.py", line 4, in <module> port = parse_prot("443") ^^^^^^^^^^ NameError: name 'parse_prot' is not defined. Did you mean: 'parse_port'?

With except Exception, the NameError from the misspelled parse_prot was caught and reported as bad input. Nothing was wrong with the input; the program was broken, and the message sent you looking in the wrong place. Catch the exception you expect, here ValueError, and let anything else crash: the traceback then shows the real bug, and Python even suggests the name you meant. A crash with a traceback is a clear failure. An error handler that reports the wrong cause leaves the bug in place and points you at the input instead.

Your own types: classes, exceptions and dataclasses

So far every value had a type Python provides. From the errors lesson on, the course defines its own: an exception class for each kind of failure, test servers built on a standard library class, a filter for log messages. A class is a type you define. Calling it creates an object of that type, an instance, which carries its own data (attributes) and its own functions (methods). This module defines three classes, each in a form later lessons use:

inventory_types.py
"""Types for the inventory tools: a counter, an error and a record."""
from dataclasses import dataclass
class PortCounter:
"""Count how many targets use each port."""
def __init__(self) -> None:
self.counts: dict[int, int] = {} # an attribute: data stored on this object
def add(self, port: int) -> None:
self.counts[port] = self.counts.get(port, 0) + 1
def total(self) -> int:
return sum(self.counts.values())
class InventoryError(Exception):
"""A line in an inventory file that cannot be used."""
def __init__(self, path: str, number: int, reason: str) -> None:
super().__init__(f"{path}:{number}: {reason}") # Exception keeps the message
self.number = number
@dataclass(frozen=True)
class Target:
host: str
port: int = 22
note: str | None = None # a str, or None when there is no note

class PortCounter: starts a definition, and each def inside it is a method. __init__ runs when you call PortCounter(), and its job is to set up the new instance's attributes. self is the instance a method was called on: Python passes it as the first argument, so counter.add(443) runs add with self bound to counter and port to 443. self.counts is an attribute, a variable stored on the object, so every counter keeps its own dict. Names with two underscores on each side, such as __init__, are methods Python calls by itself at a fixed moment.

class InventoryError(Exception): defines a subclass. An InventoryError is an Exception with everything an Exception does, so except Exception would catch it too. Its __init__ builds a message and hands it to the parent class's __init__ with super().__init__(...); that stores the text str(err) returns and a traceback prints. self.number adds an attribute for code that needs the line number. A subclass can also replace a parent's method by defining one with the same name; the HTTP test servers in later lessons do exactly that with the standard library's request handler.

Target is a record: a host, a port and an optional note. Writing its __init__, a readable printed form and == by hand is repetitive, so the standard dataclasses module generates them from the annotated names. The @ line above the class is a decorator: Python builds the class, passes it to dataclass(frozen=True), and binds the name Target to what that call returns, the same class with the generated methods added. Decorators on functions work the same way, and the testing lesson uses them. frozen=True makes instances read-only. In note: str | None = None the | means "or", not a pipe: a str, or None when there is no note.

types_demo.py
from inventory_types import InventoryError, PortCounter, Target
web = Target("web01", 443)
print(web)
print(web == Target("web01", 443), web.port, Target("bastion"))
counter = PortCounter()
for target in [web, Target("bastion"), Target("web02", 443, note="behind the proxy")]:
counter.add(target.port)
print(counter.counts, counter.total())
try:
raise InventoryError("targets.txt", 5, "not a port number: 'ssh'")
except InventoryError as err:
print("caught:", err, "| line", err.number, "| an Exception:", isinstance(err, Exception))
web.port = 8443 # frozen=True: a Target cannot be changed after it is made
deploy@web01:~/ps-functions · Ubuntu 26.04 LTS
$ python3 types_demo.py
Target(host='web01', port=443, note=None) True 443 Target(host='bastion', port=22, note=None) {443: 2, 22: 1} 3 caught: targets.txt:5: not a port number: 'ssh' | line 5 | an Exception: True Traceback (most recent call last): File "/home/deploy/ps-functions/types_demo.py", line 17, in <module> web.port = 8443 # frozen=True: a Target cannot be changed after it is made ^^^^^^^^ File "<string>", line 17, in __setattr__ dataclasses.FrozenInstanceError: cannot assign to field 'port'

print(web) used the generated form, == compared the fields, and Target("bastion") got the default port. The counter kept its dict across three add() calls. The except block received the exception instance as err, with the message from super().__init__() and the extra attribute. The last line tried to change a frozen record and raised FrozenInstanceError; the frame File "<string>" is the __setattr__ method that dataclass generated. A frozen record is safe to pass around: no function it goes through can change the host a check runs against.

Classes also explain with. It works with any context manager: an object whose __enter__ method runs when the block starts and whose __exit__ method runs when the block ends, even when an exception escapes from it. A file closes itself in __exit__. Later lessons use with to wait for a process, to close a network session and to check in a test that a call raised an exception.

Try this

Write ports_in_use.py in ~/ps-functions. It imports parse_target, takes one file name from the command line, and prints how many targets use each port, sorted by port, as f"{port:>5} {count}". Put the work in a function that returns the counts and the number of bad lines; bad lines go to stderr with their line number. Exit 0 when every line is valid, 1 when some are not, and 2 for a missing argument or an unreadable file. Break it first: write the loop without the try, run it on targets.txt and read the chained traceback (which value stopped it, and which function raised the error you would show a user?). Then fix and verify: on targets.txt it reports lines 5 and 6 on stderr, prints ports 22, 443 and 5432 with a count of 1 each, and exits 1; on a file that does not exist it prints one error line, not a traceback, and exits 2.

Takeaway

Put each piece of logic in a function that returns a value or raises a specific exception, keep side effects under if __name__ == "__main__":, and catch only the exceptions you can explain to the user. Read tracebacks from the bottom, and turn the outcome into an exit status the next program can act on. Give each failure a caller must tell apart its own Exception subclass, and hold records in a frozen dataclass.

Quick check
01show_targets.py printed two tracebacks joined by "The above exception was the direct cause of the following exception". What does that sentence tell you?
Incorrect — Both describe one failure: the bad port ssh. The upper one is the low-level cause, not a separate bug with its own fix.
Incorrect — Printing a traceback does not raise new errors. The lower traceback carries the message meant for people; it is the one to read first.
Correct — parse_port turned int()'s error into "not a port number" and attached the original. The bottom line is the message, the top shows where it started.
Incorrect — Both tracebacks point at your files, hostport.py line 7 above and the call chain below. Neither can be skipped as Python internals.
02A teammate changes check_targets.py to catch except Exception: around check_file() and report "unreadable file", exit 2. After a later edit introduces a typo in a variable name, every run prints "unreadable file". What is the real problem?
Incorrect — KeyboardInterrupt derives from BaseException, not Exception, so Ctrl-C still stops it. That is not what made every run fail.
Correct — A broad handler turns a programming error into a misleading message. Catching OSError would have let the NameError crash with a traceback that names the typo.
Incorrect — finally runs on every path, whatever the except clause catches. The lesson showed it running even after return 2.
Incorrect — How broad an except clause is does not change the speed of the loop. The failure comes from what the handler catches.
03You import hostport from a new script, and every run first prints three tuples before your script does anything. What is wrong in hostport.py?
Incorrect — Annotations are stored as information and never call the function. The prints come from code that runs at import.
Incorrect — A module runs once per process, on the first import. The tuples appear because that single run executes the demo lines.
Incorrect — How you start your own script does not change how hostport is imported. The module's top-level code runs either way.
Correct — Top-level code runs on import. Under the guard it runs only when the file is executed directly, so importers get the functions without side effects.

Related