With v0.18 the easytask/ package is understandable: it has docstrings, type annotations and a README. But it is still fragile. If Marta opens tasks.json in an editor and leaves one comma too many, the program dies with a traceback before showing the menu. If Luis types "three" when asked for the days, it dies just the same. And whatever was in memory is lost. This lesson finally delivers on the promise the course has been carrying since Type conversion and validation and since Saving data to files: catching errors with try/except instead of praying they never happen. You will learn to tell the three kinds of error apart, to read a traceback without fear, to decide what to catch and what to let bubble up, to raise your own exceptions such as InvalidTask, to record what happens with logging instead of print, and to debug with breakpoints instead of guessing. By the end, EasyTask will stop breaking.
Contents
- The three kinds of error
- Reading a traceback
- Catalogue of common exceptions
try/except: catching what should be caughtelseandfinally: the complete flowraiseand your own exceptions- The error policy: EAFP versus LBYL
loggingversusprint- Debugging properly: method, VS Code and
pdb - EasyTask v0.19: the program that does not break
- Common mistakes and tips
- Exercises
- Conclusion
- The three kinds of error
Not all failures are alike, and each one is hunted down in a different way; telling them apart is the first step to avoid wasting time looking where there is nothing. The third one is the dangerous one: the first two shout at you, but a logic error stays quiet, prints "progress: 0.6 %" and nobody notices until Marta asks why the poster task has been at 0.6 % for three weeks. Against that one no try/except will help: you need the debugging method from section 9 and the tests from Automated testing.
| Kind | When it shows up | Who detects it | How it is fixed |
|---|---|---|---|
| Syntax | Before anything runs | Python, while reading the file | By correcting what you wrote |
| Runtime (exception) | During execution | The interpreter, on reaching the line | With validation or try/except |
| Logic | Never: the program "works" | Only you, looking at the result | By debugging and testing |
print("Marta's tasks" # 1. SyntaxError: NOTHING in the file runs
days = int("three") # 2. ValueError: the syntax is fine, the data is not
def percentage(done, days): # 3. Logic error: nobody complains...
return done / days # ...but it should be done / days * 100
- Reading a traceback
When an exception is raised, Python prints a traceback: the map of how the failure was reached. It is read in a very specific way: bottom-up to find out what happened, and top-down to find out why.
Traceback (most recent call last):
File "/home/marta/projects/easytask/__main__.py", line 34, in main
agenda = storage.load()
File "/home/marta/projects/easytask/storage.py", line 41, in load
data = json.load(f)
File "/usr/lib/python3.12/json/__init__.py", line 293, in load
return loads(f.read(), ...)
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 8 column 3That block contains all the information you need:
most recent call last: the calls are in order — the first at the top, the one that blew up at the bottom; that is the call stack. And the last line says what happened: the type (JSONDecodeError) and the message (a comma is missing on line 8 of the data file).- The guilty line in your code is the last one that belongs to your project, here
storage.py, line 41. Everything below is the standard library:jsonis not failing, it is just where it shows up. - The real origin is usually further up:
load()works fine; the problem is the file it was given. Walk up the stack asking yourself "who passed it this data?"; in 90 % of cases the bug is there.
- Catalogue of common exceptions
These are the ones you will meet over and over; recognising them at a glance saves an enormous amount of time:
| Exception | Typical cause |
|---|---|
ValueError |
The type is right but the value is not: int("three") |
TypeError |
Operation between incompatible types: "5" + 5 |
KeyError |
Key that does not exist in a dictionary: task["date"] |
IndexError |
Index out of range: tasks[10] with 3 tasks |
FileNotFoundError |
The file does not exist or the path is wrong |
ZeroDivisionError |
Division or modulo by zero |
AttributeError |
The object has no such attribute or method: None.title |
json.JSONDecodeError |
The JSON file is corrupted or empty |
try/except: catching what should be caught
try/except: catching what should be caughtThe syntax is simple: the try block holds the code that may fail; the except, what to do if it does.
entry = input("Estimated days: ")
try:
days = int(entry) # this line can raise ValueError
except ValueError:
print("That is not a whole number. Using 1 day by default.")
days = 1 # and the program carries onWithout the try, a "three" brought the program down. With it, the user gets an understandable message and the application stays alive. That said, there are two ways of writing it badly:
except: # BAD: catches everything, even the user's Ctrl+C
days = 1
except Exception: # ALMOST AS BAD: hides errors you were not expecting
days = 1A bare except: catches even a KeyboardInterrupt (the Ctrl+C the user presses to get out) and a SystemExit, so your program becomes impossible to interrupt. except Exception does not go that far, but it still covers up programming mistakes — a misspelled name, an AttributeError — and turns an obvious failure into odd behaviour that is impossible to diagnose. The rule is: catch the specific exception you know can happen and know how to handle; everything else must rise and make noise. When there are several possibilities you chain except clauses or group them in a tuple, and with as you capture the object to read its message:
try:
with open(path, encoding="utf-8") as f:
agenda = Agenda.from_dict(json.load(f))
except FileNotFoundError:
agenda = Agenda() # first run
except (json.JSONDecodeError, KeyError) as error: # two cases, one block
print(f"The file is damaged ({error}). An empty agenda will be used.")
agenda = Agenda()The except clauses are checked in order and only the first one that matches runs: that is why specific exceptions always go before general ones.
else and finally: the complete flow
else and finally: the complete flowThe try block accepts two more clauses that round off the structure. else runs only if there was no exception, which lets you leave nothing but the risky line inside the try; finally runs always, whether it failed or not, even if the try does a return, and it is the place for cleanup.
def load_json(path):
"""Return the file's data, or None if it could not be read."""
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as error:
print(f"Could not read {path}: {error}")
return None
else:
return data # only if everything went well
finally:
print("End of the load attempt.") # whatever happensflowchart TD
A[Enter the try] --> B{Is there an exception?}
B -- No --> C[Run else] --> G[Run finally]
B -- Yes --> D{Does any except match?}
D -- Yes --> E[Run that except] --> G
D -- No --> F[The exception rises to the caller] --> G
G --> H[The program continues or the error propagates]
Look at the right-hand branch: if no except matches, the finally runs all the same and afterwards the exception carries on rising. That is the key to the design: finally guarantees cleanup without swallowing the error. For files, that is already solved by something you have used since 05-05: with closes the file automatically and makes finally unnecessary in that case.
raise and your own exceptions
raise and your own exceptionsSo far the exceptions came from Python. You can also raise them yourself, and it is the right thing to do when your function receives something it cannot work with: failing early and with a clear message is better than returning a made-up value.
def create_task(title, days):
"""Create a task, validating the input data."""
if not title.strip():
raise ValueError("The title cannot be empty.")
if days <= 0:
raise ValueError(f"Days must be positive, and {days} arrived.")
return Task(title, days)
try:
task = create_task("", 3)
except ValueError:
print("Warning: invalid data.")
raise # a bare 'raise' re-raises the same exceptionA bare raise inside an except re-raises the original exception with its traceback intact, which is what you want when you only need to note something in passing. And when you turn a low-level error into a more meaningful one, chain them with raise ... from: raise InvalidTask("unknown priority") from error keeps the cause in the traceback. Defining your own exception is surprisingly simple, an empty class that inherits from another exception:
class InvalidTask(ValueError):
"""A task's data does not meet the Alba Studio rules."""
class InvalidPriority(InvalidTask): # they can be chained in a hierarchy
"""The priority is neither 'high', 'medium' nor 'low'."""Why inherit from ValueError and not from Exception? Because that way old code keeps working: whoever was catching ValueError to validate input will keep catching yours too. Remember as well that every exception descends from Exception, and that this hierarchy rules: except ValueError does not catch a KeyError, but except Exception catches them all, including the ones you were not expecting. Your own hierarchy gives you precision when catching (except InvalidTask catches any task-data problem, including InvalidPriority, without touching a ValueError from somewhere else), domain messages instead of messages about text conversions, and a single place that documents the business rules. The convention is to group them in the domain module — in EasyTask, model.py — with recognisable names (Invalid..., ...Error).
- The error policy: EAFP versus LBYL
Every application needs a policy: what gets caught and what is let through. The rule is easy to remember: catch an error only if you can do something useful with it — ask for the data again, use a default value, warn the user. If you cannot, let it rise: a visible failure is infinitely better than a silent one. For validating there are also two styles, and Python has a preference:
if path.exists(): # LBYL (Look Before You Leap), as in 05-05
with open(path, encoding="utf-8") as f:
data = json.load(f)
else:
data = []
try: # EAFP (Easier to Ask Forgiveness than Permission)
with open(path, encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError: # the style Python prefers
data = []| Style | Advantage | Drawback |
|---|---|---|
| LBYL | Reads like an ordinary condition | Leaves a gap: the file can be deleted between the check and the open |
| EAFP | No gap: either it opens or you catch it | Requires knowing the specific exception |
Python prefers EAFP, and with files it is objectively safer: between the exists() and the open() there is an instant in which another program can delete or move the file. That Path.exists() check from 05-05 was correct for what we knew then; this is the robust version we promised.
logging versus print
logging versus printWhen something fails, the temptation is to sprinkle the code with print("got here"). It works as a stopgap, but in a real program it is a poor system: you have to delete them afterwards (and one always gets forgotten), they get mixed up with the legitimate output and they leave no record of anything. The standard library's logging module solves all three problems.
| Level | When to use it | Example in EasyTask |
|---|---|---|
DEBUG |
Internal detail useful while coding | "Loaded 12 tasks from tasks.json" |
INFO |
Normal events | "Task created: Book fair poster" |
WARNING |
Something odd, but you can carry on | "The file does not exist; empty agenda" |
ERROR / CRITICAL |
An operation failed / you cannot continue | "Could not save the JSON" |
import logging
logging.basicConfig(
filename="easytask.log", # without this, it goes to the screen
level=logging.INFO, # INFO and above are recorded
format="%(asctime)s %(levelname)s %(message)s",
)
try:
save(agenda)
except OSError:
logging.exception("Save failed") # includes the full tracebackThree details make the difference. level decides what gets recorded: raising it to WARNING silences the detailed messages without touching a line of code. filename sends everything to a file, so tomorrow you can see what happened without having been there. And logging.exception(), used inside an except, writes the message and the whole traceback: it is the right way to record a failure you have caught and do not want to lose.
- Debugging properly: method, VS Code and
pdb
pdbDebugging is not staring at the code until it confesses. It is a four-step method:
- Reproduce: find the exact sequence that triggers the failure, every time. A bug you cannot reproduce is a bug you cannot fix.
- Isolate: reduce the case to the minimum. Does it fail with one task or do you need twenty? With any priority?
- Form a concrete hypothesis ("I think
daysarrives as text from the JSON") and check it: one thing at a time; if it is false, discard it and form another.
That last step is what the debugger is for. In VS Code you click to the left of the line number to set a breakpoint (a red circle), start with F5 and the program stops just before running that line; there you can inspect the variables in the side panel, see the call stack and step with F10 (runs the whole line), F11 (steps into the function) and F5 (continues). Without a graphical editor, write breakpoint() on the line you care about and run: a (Pdb) console opens at that point.
| Command | What it does |
|---|---|
l (list) |
Shows the code around the current line |
n (next) |
Runs the line and moves to the next one |
s (step) |
Steps inside the function being called |
c (continue) |
Carries on to the next breakpoint() |
p expression |
Prints the value of a variable or expression |
w (where) / q (quit) |
Shows the call stack / leaves the debugger |
And one last tool, in two lines: assert condition, "message" raises an AssertionError if the condition is false. It is for checking internal assumptions while developing, but not for validating user data, because Python can disable assert with the -O option. Its natural home is tests, and that is where we pick it up again in 08-04.
- EasyTask v0.19: the program that does not break
First, storage.py survives a JSON file that does not exist or is corrupted:
"""Persistence for EasyTask: JSON and CSV export."""
JSON_PATH = Path("tasks.json")
def load_tasks(path: Path = JSON_PATH) -> Agenda:
"""Return the saved agenda; an empty agenda if it could not be read."""
try:
with open(path, encoding="utf-8") as f:
return Agenda.from_dict(json.load(f))
except FileNotFoundError:
logging.info("%s does not exist; starting with an empty agenda.", path)
except json.JSONDecodeError as error:
logging.error("The file %s is damaged: %s", path, error)
path.replace(path.with_suffix(".json.bak")) # nothing is lost: set aside
return Agenda()Note the design decision: faced with a corrupted file nothing is deleted, it is set aside under another name and the program carries on, so the user does not lose their data. Second, the model validates and raises its own exception, and the interface catches it to ask again:
class Task:
def __init__(self, title: str, assignee: str, priority: str = "medium",
days: int = 1) -> None:
if priority.strip().lower() not in PRIORITIES:
raise InvalidTask(f"Invalid priority: {priority!r}")
try:
self.days = int(days)
except (ValueError, TypeError) as error:
raise InvalidTask(f"Invalid days: {days!r}") from error
def ask_priority() -> str: # in easytask/interface.py
"""Ask for a priority until it is valid."""
while True:
try:
return Task("temporary", "marta", input("Priority: ")).priority
except InvalidTask as error:
print(f" {error} Try again.")And in __main__.py, the logging setup and a final safety net so that no unexpected failure takes the data with it:
def main() -> None:
"""Run EasyTask, recording events in easytask.log."""
logging.basicConfig(filename="easytask.log", level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
agenda = storage.load_tasks()
try:
main_loop(agenda)
except KeyboardInterrupt: # the user pressed Ctrl+C
print("\nExiting...")
finally:
storage.save(agenda) # whatever happens, it is savedEasyTask v0.19: a corrupted JSON no longer brings the program down, a made-up priority is rejected with a domain message, a days value given as text is converted or clearly rejected, a Ctrl+C saves before exiting and everything is recorded in easytask.log.
Common Mistakes and Tips
- Using
except:orexcept Exceptionout of convenience, or catching and doing nothing (except ValueError: pass). They hide programming mistakes and create invisible failures: catch the specific exception, and if you cannot do anything useful with it, let it rise. - Putting the general
exceptbefore the specific ones, or stuffing half the program inside thetry. They are checked in order, so the first match wins; and thetryshould hold only the line that can fail, with the rest in theelse. - Skimming the traceback, or debugging with
printin a real program. The last line says what happened and the last line of your code says where; and for everything else there islogging, which filters by level, goes to a file and does not need deleting afterwards. - Tip: write the message with its reader in mind. "Invalid priority: 'urgent'. Use high, medium or low" is worth a thousand times more than "validation error".
Exercises
Exercise 1: Reading a traceback
Explain what happened, which line of your own code the problem is on and how you would fix it:
Traceback (most recent call last):
File "reports.py", line 20, in <module>
print(average_days(tasks))
File "reports.py", line 15, in average_days
return sum(t["days"] for t in tasks) / len(tasks)
ZeroDivisionError: division by zeroExercise 2: From fragile to robust
Rewrite this function so that it survives a missing file, a badly formed line and a non-numeric value, recording each problem with logging and returning whatever could be read:
def read_hours(path):
hours = []
with open(path, encoding="utf-8") as f:
for line in f:
name, value = line.split(",")
hours.append((name, int(value)))
return hoursExercise 3: Your own exception
Define an InvalidQuote exception for Alba Studio and a function validate_quote(amount, client) that raises it if the amount is not a number, if it is negative or if it goes over 50,000 € (the limit that requires Marta's approval). Then write the try/except that uses it, showing a different message in each case.
Solutions
Solution 1.
The last line says what: a division by zero. The last line of your own code says where: reports.py, line 15, in average_days. Since sum(...) does not divide, the zero can only be len(tasks): the task list is empty. The real bug, however, is not on line 15 but in whoever called from line 20 with an empty list; the function has merely revealed it.
def average_days(tasks: list[dict]) -> float:
"""Return the average of the estimated days; 0.0 if there are no tasks."""
if not tasks: # LBYL: an expected, legitimate case
return 0.0
return sum(t["days"] for t in tasks) / len(tasks)If an empty list were an impossible case — a programming mistake in the caller — the right thing would be the opposite: do not catch it and let the ZeroDivisionError make noise.
Solution 2.
def read_hours(path: str) -> list[tuple[str, int]]:
"""Return the hours read from the file, skipping the invalid lines."""
hours: list[tuple[str, int]] = []
try:
lines = Path(path).read_text(encoding="utf-8").splitlines()
except FileNotFoundError:
logging.error("The hours file does not exist: %s", path)
return hours # empty list: the program carries on
for number, line in enumerate(lines, start=1):
try:
name, value = line.strip().split(",")
hours.append((name.strip().lower(), int(value)))
except ValueError as error: # covers both the split and the int
logging.warning("Line %d ignored: %s", number, error)
return hoursTwo important ideas. The inner try wraps only the two lines that can fail, and it catches ValueError because both a split that does not return two parts and an int("eight") raise that same exception. And the failure of one line does not abort the whole file: it is recorded with its number and the loop carries on. That is the difference between a fragile program and a robust one: it keeps doing everything it actually can do.
Solution 3.
APPROVAL_LIMIT = 50000 # from here up, Marta approves it
class InvalidQuote(ValueError):
"""A quote amount does not meet the Alba Studio rules."""
def validate_quote(amount, client: str) -> float:
"""Return the validated amount or raise InvalidQuote."""
try:
amount = float(amount)
except (TypeError, ValueError) as error:
raise InvalidQuote(f"Non-numeric amount: {amount!r}") from error
if amount < 0:
raise InvalidQuote(f"Negative amount for {client}: {amount}")
if amount > APPROVAL_LIMIT:
raise InvalidQuote(f"{amount} EUR is over the limit; Marta approves it.")
return amount
try:
total = validate_quote("12500", "Sole Bakery")
except InvalidQuote as error:
print(f"Quote rejected: {error}")
else: # only if there was no exception
print(f"Quote accepted: {total:.2f} EUR")Note the from error on the first conversion: it keeps the original cause in the traceback, so whoever debugs will know that behind InvalidQuote there was a ValueError from float(). The three messages are different and they say what to do, which is the mark of a good error. And the else makes it clear that the success line runs only if there was no exception.
Conclusion
Errors come in three kinds: syntax errors stop anything from running, runtime errors raise exceptions, and logic errors give no warning at all and are only caught by debugging and testing. When an exception is raised, the traceback tells you everything: the last line says what happened, the last line of your own code says where, and the real origin is usually further up the stack. Recognising the usual catalogue — ValueError, TypeError, KeyError, IndexError, FileNotFoundError, ZeroDivisionError, AttributeError, json.JSONDecodeError — saves half the work. With try/except you catch the specific exception you know how to handle, never a bare except: nor except Exception, placing the specific cases before the general ones, grouping several in a tuple and capturing the object with as error; else runs what only makes sense if nothing failed and finally guarantees cleanup whatever happens, even when the exception carries on rising. With raise you throw errors deliberately to fail early and with a clear message, a bare raise re-raises without losing the traceback and raise ... from keeps the cause. Your own exception such as InvalidTask, inherited from ValueError, brings precision when catching and domain messages. The policy boils down to one sentence: catch only what you can resolve and let the rest rise, preferring EAFP to LBYL when files are involved. To know what is going on, logging replaces print with its five levels, its output to a file and its logging.exception(). And for logic errors, the method — reproduce, isolate, form a hypothesis, check — together with VS Code's breakpoints and breakpoint()/pdb.
EasyTask is now v0.19: a corrupted tasks.json is set aside as a copy instead of bringing the program down, a made-up priority is rejected with InvalidTask and the interface asks again, a days value that arrives as text is converted or explained, a Ctrl+C saves before exiting and everything is noted in easytask.log. The program holds up. But look at what you have just done: you have changed storage.py, model.py, interface.py and __main__.py all at once, and there is no way back if any of this has made something else worse. There is no copy of the previous state, no record of what was touched or why. That ends in Version control: Git, the project's history, and the peace of mind of being able to experiment knowing that nothing is lost.
Fundamentals of Programming
Module 1: Introduction to Programming
- What is programming?
- History of programming
- Programming languages
- Development environments
- From problem to algorithm
Module 2: Core Concepts
- Variables and data types
- Operators and expressions
- Input and output
- Type conversion and data validation
Module 3: Control Structures
Module 4: Functions and Procedures
- Defining and using functions
- Parameters and return values
- Variable scope
- Breaking a program down into functions
- Functions as values: lambda and higher order
Module 5: Data Structures
- Lists and arrays
- Strings
- Dictionaries and sets
- Tuples and nested structures
- Saving data to files: text, CSV and JSON
Module 6: Basic Algorithms
Module 7: Objects and Code Organisation
- From data to objects: classes and instances
- Attributes, methods and the constructor
- Collections of objects
- Modules, packages and imports
Module 8: Good Practices and Tools
- Documentation and comments
- Debugging and error handling
- Version control
- Automated testing
- Style, readability and refactoring
