AdvancedPython · 19 min · 18 challenges

Python Foundations: Advanced

Overnight at Spotify, a job turns a day of listening events into the counts that feed the Wrapped recap. The export runs to gigabytes, 1 line per play. A malformed line at 3 a.m. must not stop the job, the file must never be read whole into memory, and every open handle has to close. This lesson is the Python that makes that job survive the night.

Handling Errors With try and except

Daily Life
Interviews

What an uncaught error does

The traceback lesson showed what happens when a line fails: Python prints the traceback and the program stops on that line. Every line after it never runs. For a script that parses 1 field typed at the keyboard, stopping is fine. For a nightly job on the 40,000th line of a 2 million line export, stopping throws away 39,999 lines of finished work because 1 line held 12x where a number belonged.

raw = "12x"
count = int(raw)
print("parsed", count)

The last line of its traceback reads ValueError: invalid literal for int() with base 10: '12x'. ValueError is the exception's type, and the type is what an except clause matches on.

try and except

A try block holds the lines that might fail. An except block names an exception type and holds the lines to run when that type is raised inside the try. When the failing line raises, Python abandons the rest of the try block, runs the matching except block, and continues with the first line after it as though nothing had gone wrong.

raw = "12x"
try:
count = int(raw)
print("parsed", count)
except ValueError:
print("could not parse", raw)
print("program continues")
>>>Output
could not parse 12x
program continues

print("parsed", count) never ran: the jump happened at int(raw), 1 line above it, and count was never assigned. Change raw to "12" and the output flips to parsed 12 followed by program continues, with the except block skipped entirely. The except block runs only when the try is abandoned, and the jump undoes nothing. Every line above the failing one has already run, and whatever those lines assigned or wrote stays done, which is why a try should hold only the lines that can fail. Below, the third line of the try fails after the first 2 have printed and assigned.

try:
print("step 1 done")
total = 5
total = total + int("x")
print("step 4 done")
except ValueError:
print("handler sees total =", total)
>>>Output
step 1 done
handler sees total = 5

Keeping the error message

Writing except ValueError as err stores the exception object in err. Printing it gives the same message the traceback would have shown, so a log can record what went wrong while the program keeps going. Watch count in the run below: it keeps the 0 assigned before the try, because the assignment inside the try never completed.

1raw = "12x"
2count = 0
3try:
4 count = int(raw)
5 print("parsed", count)
6except ValueError as err:
7 print("skipped:", err)
8print("count is now", count)
raw=
count=
err=
output0 lines
raw starts at '12x'
1/8

The failing line jumps straight to the except block; count keeps its earlier value.

Matching the right type

An except clause catches only the type it names. except ValueError does nothing for a ZeroDivisionError, which then travels on as if no try were there and stops the program. Pick the type from the last line of the traceback you are trying to survive.

LineException it raises
`int("12x")``ValueError`
`10 / 0``ZeroDivisionError`
`1 + "a"``TypeError`
`print(totl)``NameError`
`open("missing.txt")``FileNotFoundError`

Several except clauses can follow 1 try, each for a different type; Python checks them top to bottom and runs the first that matches. Writing except: with no type catches everything, including the NameError from a typo, so the bug hides behind a skipped message for weeks while the output comes out wrong. Name the type.

TIP
The last line of a traceback names the type to put after except. Copy it exactly as printed, capitals included: ZeroDivisionError, FileNotFoundError.

finally runs no matter what

A finally block after the except clauses runs whether the try succeeded, was rescued by an except, or raised something nothing caught. It is where cleanup goes: closing a file, releasing a connection, writing a final log line.

try:
value = 10 / 0
print("value is", value)
except ZeroDivisionError:
print("division failed")
finally:
print("cleanup runs either way")
print("still running")
>>>Output
division failed
cleanup runs either way
still running
✓Do
  • Name the exception type you expect, taken from the traceback
  • Keep the try block to the lines that can fail
  • Print or log err so every skipped record is visible
✗Don't
  • Write a bare except: that swallows typos and real bugs alike
  • Wrap a whole program in a single try
  • Catch an error and continue with no message at all

Practice

Multiple Choice

What does this program print before it ends?

try:
result = 10 / 0
except ValueError:
print("bad value")
print("done")

A handler can do more than print. When a field is missing or malformed, the except block can assign a fallback value, so the lines after the try always find the variable set. Below, an empty duration field becomes 0 seconds instead of stopping the job, and the program carries on with a usable number whichever block ran.

raw = ""
try:
seconds = int(raw)
except ValueError:
seconds = 0
print("seconds is", seconds)

Reading and Writing Files

Daily Life
Interviews

Opening a file

open() takes a path and a mode and returns a file object: a handle with methods for reading and writing. The file's bytes stay on disk; the object remembers a position in them, which starts at 0. When the work is done, close() releases the handle, and anything still buffered in memory is written out.

f = open("plays.txt", "r")
text = f.read()
f.close()
print(text)
ModeWhat open() does
`"r"`Read; raises FileNotFoundError when the path does not exist
`"w"`Write; creates the file, or empties it when it already exists
`"a"`Append; creates the file, or writes after its existing content
`"x"`Create; raises FileExistsError when the path already exists

The runnable examples below use io.StringIO, which the io module provides: a file that lives in memory instead of on disk. Its read and readline methods work like a real file's, as do write and close, so every line transfers to a real open() call unchanged. The string handed to it is the file's content, and \n inside that string is the newline character, the point where a text editor would start a new line.

Reading moves a position

import io
f = io.StringIO("alpha\nbeta\ngamma\n")
print(repr(f.readline()))
print(repr(f.readline()))
print(repr(f.read()))
print(repr(f.read()))
>>>Output
'alpha\n'
'beta\n'
'gamma\n'
''

repr shows each string as Python would write it, so the \n ending each line is visible. The position advanced with every call: 2 readline calls consumed 2 lines, read() returned everything from the position to the end, and a second read() found nothing left and returned the empty string ''. A file never rewinds on its own.

One line at a time

read() loads the whole file into 1 string, so an export of 6 gigabytes costs 6 gigabytes of memory before any work starts. A file object can be looped over instead: for line in f: runs its indented block once per line, holding only the current line. for is a loop, which a later lesson covers in full; here it does 1 job, visiting each line in order from the first to the last.

import io
f = io.StringIO("alpha\nbeta\ngamma\n")
for line in f:
print(line, end="")
f.close()
print(f.closed)
>>>Output
alpha
beta
gamma
True

Each line already ends with \n, and print adds a newline of its own, so end="" tells print to add nothing and the output keeps single spacing. After close(), the attribute f.closed reports True, and any further read on f raises ValueError: I/O operation on closed file.

Writing

Mode "w" empties the file the moment open() is called, before any write(): a program that opens counts.csv with "w" and crashes on the very next line has already destroyed the old counts. write() puts a string at the position and returns the number of characters written, which the interactive shell echoes as a bare number such as 5. It adds no newline, so each written line should end in \n, or the next write continues on the same line. getvalue() belongs to StringIO alone: it returns everything written, the content a disk file would hold after close().

import io
out = io.StringIO()
out.write("track_id,plays\n")
out.write("t1,3\n")
out.write("t2,7\n")
print(out.getvalue(), end="")
>>>Output
track_id,plays
t1,3
t2,7

Writing the result back safely

A job that opens its own output with "w" and crashes halfway leaves a half-written file, and the previous good version is already gone. The safe order is to write to a temporary name, close it, then rename it over the real name with os.replace. The rename is a single operation, so anything reading counts.csv sees either the old complete file or the new complete file, never a half of either.

import os
out = open("counts.tmp", "w")
out.write("track_id,plays\n")
out.write("t1,3\n")
out.close()
os.replace("counts.tmp", "counts.csv")
What close() does
  • ▸Flushes the write buffer: written text sits in memory until the buffer fills, flush() is called, or close() runs
  • ▸Releases the handle: the operating system caps how many files 1 process may hold open at once
  • ▸Marks the object closed; any later read or write raises ValueError instead of touching the file

Opening a path that does not exist in mode "r" raises FileNotFoundError, a case for the previous section: a job on its first ever run can catch it and start from an empty result instead of dying.

try:
f = open("counts.csv", "r")
previous = f.read()
f.close()
except FileNotFoundError:
print("no previous counts, starting empty")
previous = ""

Practice

Multiple Choice

What does this program print?

import io
f = io.StringIO("a\nb\n")
first = f.readline()
rest = f.read()
again = f.read()
print(repr(first), repr(rest), repr(again))

Double spacing is what a line-by-line read prints when end="" is left out. Each line arrives from the file with its own \n at the end, and print writes a second \n after it, so every line is followed by an empty one. Here is the 3-line file "alpha\nbeta\ngamma\n" traced through both calls:

repr(line)print(line) writesprint(line, end="") writes
`'alpha\n'`alpha, newline, newlinealpha, newline
`'beta\n'`beta, newline, newlinebeta, newline
`'gamma\n'`gamma, newline, newlinegamma, newline

Defining a Class

Daily Life
Interviews

Data that belongs together

A play count belongs to a track id. Held in t1_name and t1_plays as separate variables, the 2 values have nothing in the program tying them together, and a job tracking 500 tracks needs 1,000 variables. A class is a blueprint for an object that holds a group of variables, called attributes, together with the functions that work on them, called methods. Building an object from the blueprint is called making an instance.

The smallest useful class

class Tally:
def __init__(self, name):
self.name = name
self.plays = 0
t = Tally("t1")
print(t.name, t.plays)
print(type(t).__name__)
>>>Output
t1 0
Tally

Line by line: class Tally: opens an indented block that defines the blueprint. def gives an indented block of code a name, and the block runs each time that name is called; functions get a lesson of their own later, and inside a class each one is a method. __init__ is the method Python calls when Tally("t1") runs, with self standing for the new object being built. self.name = name stores the argument on that object, and self.plays = 0 gives every new tally a starting count. The finished object is bound to t, and t.name reads the attribute back. type(t) reports the class it was built from, and that class's __name__ attribute holds its name, Tally, the way type(3).__name__ gives int.

Methods change their own object

a.add(3) is a shorthand: Python looks up add in Tally and calls it with a as self and 3 as count. The method changes a.plays and nothing else, so b keeps its own count. Two objects built from 1 class share the methods and hold separate attributes. The run below shows both objects on the stage as their counts move.

1class Tally:
2 def __init__(self, name):
3 self.name = name
4 self.plays = 0
5
6 def add(self, count):
7 self.plays = self.plays + count
8
9a = Tally("t1")
10b = Tally("t2")
11a.add(3)
12a.add(4)
13b.add(10)
14print(a.name, a.plays)
15print(b.name, b.plays)
aTally
bTally
output0 lines
defines the class Tally
1/21

Each call to add changes the plays attribute of the object it was called on.

Returning a value

A method can hand a value back to its caller with return instead of printing it. The call then stands for that value, so it can be stored in a variable or passed straight to print. Below, share divides this tally's plays by a total it is given.

class Tally:
def __init__(self, name):
self.name = name
self.plays = 0
def add(self, count):
self.plays = self.plays + count
def share(self, total):
return self.plays / total
a = Tally("t1")
a.add(6)
print(a.share(24))
>>>Output
0.25
The self rule
  • ▸Every method's first parameter is self, and the call supplies it: a.add(3) passes a as self and 3 as count
  • ▸Leaving self out of the header raises TypeError: add() takes 1 positional argument but 2 were given
  • ▸Attributes live on self: a plain plays = 0 inside a method is a temporary name that vanishes when the method ends

Practice

Multiple Choice

What does the last line print?

class Box:
def __init__(self):
self.items = 0
def put(self, n):
self.items = self.items + n
x = Box()
y = Box()
x.put(2)
x.put(2)
y.put(5)
print(x.items, y.items)

The self. prefix is what makes a change stick. Inside a method, ticks = ticks + 1 written without it names a temporary variable that has no value yet, so the call stops with UnboundLocalError: local variable 'ticks' referenced before assignment. Leaving self out of the header fails at the call instead, with the TypeError from the rule above. Both mistakes break the same contract: a method receives its object and writes to it.

class Meter:
def __init__(self):
self.ticks = 0
def tick(self):
ticks = ticks + 1

Generators

Daily Life
Interviews

Work that waits to be asked

f.read() returns the whole file as 1 string before any work starts, so the memory bill equals the file's size. for line in f pays for 1 line at a time. A generator is how you write a function with that second behavior: it produces values 1 at a time when asked and holds only what it needs to produce the next one.

yield pauses a function

A function that contains yield is a generator function. Calling it runs none of its lines; it returns a generator object. Each call to next() on that object runs the function until the next yield, hands out the value written there, and freezes with every variable intact until the following next().

def first_three():
print("about to yield 1")
yield 1
print("about to yield 2")
yield 2
print("about to yield 3")
yield 3
g = first_three()
print("created")
print(next(g))
print(next(g))
>>>Output
created
about to yield 1
1
about to yield 2
2

created printed first, before anything inside the function ran. The first next(g) ran lines 2 and 3 and stopped at the yield, so print received the 1. The second next resumed at line 4. Line 6 has not run and will not until a third next arrives. A next after the final yield raises StopIteration to signal that the generator has nothing more. try and except handle it like any other exception.

def two():
yield "a"
yield "b"
g = two()
print(next(g))
print(next(g))
try:
print(next(g))
except StopIteration:
print("generator is exhausted")
>>>Output
a
b
generator is exhausted

Looping over a generator

A for loop calls next() for you and stops quietly at StopIteration, which makes a generator a natural fit for for. The generator below wraps a file: it reads 1 line and yields it converted, then reads the next line only when the loop asks. int() ignores surrounding whitespace, so the \n ending each line causes no error.

import io
def plays(f):
for line in f:
yield int(line)
f = io.StringIO("3\n7\n5\n")
total = 0
for n in plays(f):
total = total + n
print("running total", total)
>>>Output
running total 3
running total 10
running total 15

At no point does the program hold more than 1 line of the file. The run below stands a list of 3 lines in for the file and doubles each value on its way through. Watch n and total change: each time the generator yields, its frame steps off the stack with line and its place in the loop intact, then steps back on when the loop asks for the next value. Python discards that frame only after the last line has been read.

1def doubled(lines):
2 for line in lines:
3 yield int(line) * 2
4
5lines = ["3\n", "7\n", "5\n"]
6total = 0
7for n in doubled(lines):
8 total = total + n
9print("total", total)
total=
n=
lines
[
]
012
output0 lines
defines doubled
1/23

The generator yields 1 converted line, doubled, then waits for the loop to ask for the next.

f.read()Generator over f
Memory heldThe whole file1 line
When the work happensAll of it before the first resultAs each value is requested
Stopping earlyThe whole file was already readThe remaining lines are never read

A generator runs once

A generator remembers how far it got, and when it reaches the end it stays there. A second loop over the same generator object receives nothing and its block never runs. To go through the data again, call the generator function again for a fresh object.
def nums():
yield 1
yield 2
g = nums()
for n in g:
print("first pass", n)
for n in g:
print("second pass", n)
print("done")
>>>Output
first pass 1
first pass 2
done

Practice

Multiple Choice

In what order do the printed lines appear?

def gen():
print("start")
yield "x"
print("middle")
yield "y"
print("end")
g = gen()
a = next(g)
print(a)
b = next(g)
print(b)

One yield written inside a for loop produces as many values as the loop has passes. Each request from the outer loop resumes the generator just after the yield, finishes that pass, takes the next line and pauses at the yield again. Here is plays(f) from earlier, 1 request at a time. Its input starts with 3 and 7 and ends with 5:

RequestLine the generator readsValue yieldedtotal after
1st `next()``'3\n'`33
2nd `next()``'7\n'`710
3rd `next()``'5\n'`515
4th `next()`none left`StopIteration`, loop ends15

Context Managers

Daily Life
Interviews

The gap between open and close

The file section called close() by hand. Between open() and close() sit the lines that do the work, and when 1 of them raises, Python jumps out of the block and close() never runs. The handle then stays open until Python gets around to discarding the file object, and nothing in the language promises when that will be. A job that opens a file per customer and keeps those objects alive, or runs on an interpreter that collects them late, leaks a handle on every error until the operating system refuses to open more. The finally block from the error section fixes this, and with is a shorter way to write the same guarantee.

f = open("plays.txt", "r")
try:
text = f.read()
finally:
f.close()
with open("plays.txt", "r") as f:
text = f.read()

with calls open() and binds the result to f. After the indented block runs, it closes f, even if the block raised. The 2 programs do identical work; the second cannot forget the cleanup, because the cleanup is part of the statement.

import io
with io.StringIO("alpha\nbeta\n") as f:
for line in f:
print(line, end="")
print("inside, closed:", f.closed)
print("after, closed:", f.closed)
>>>Output
alpha
beta
inside, closed: False
after, closed: True

f is still a name after the block, and the object it points to has been closed; f.closed reports the change the moment the block ends.

Objects that know how to clean up

with works on any object that defines 2 methods. __enter__ runs at the top of the block, and whatever it returns is what as binds. __exit__ runs when the block ends, receiving 3 arguments that describe the exception when 1 was raised and are all None otherwise. A file object defines both, and its __exit__ calls close(). A class of your own can define them for any resource that needs releasing.

class Timer:
def __enter__(self):
print("enter: start the clock")
return self
def __exit__(self, exc_type, exc, tb):
print("exit: stop the clock")
with Timer() as t:
print("working")
print("done")
>>>Output
enter: start the clock
working
exit: stop the clock
done

Cleanup when the block raises

The run below puts a division by zero inside the block and a try around the whole with. Watch the order: __exit__ prints before the handler outside does.

1class Guard:
2 def __enter__(self):
3 print("enter")
4 return self
5
6 def __exit__(self, exc_type, exc, tb):
7 print("exit sees", exc_type)
8
9try:
10 with Guard():
11 print("working")
12 1 / 0
13except ZeroDivisionError:
14 print("caught outside the with")
selfGuard
output0 lines
defines the class Guard
1/14

__exit__ runs before the exception reaches the handler outside the with.

The division failed inside the block, so Python ran __exit__ first, passing the exception's class as exc_type, and only then let the ZeroDivisionError continue outward to the except wrapped around the with. Cleanup happened before the error was handled. An __exit__ that returns True tells Python the exception is dealt with, and nothing outside the with ever sees it; returning None, as this one does by having no return, lets the exception travel on.

What __exit__ receives
  • ▸exc_type: the exception class, ZeroDivisionError for example; None when the block finished normally
  • ▸exc: the exception object, whose message print() would show, or None
  • ▸tb: the traceback object, or None
  • ▸Return True to suppress the exception; return None to let it continue past the with

Two resources in one with

A copy job needs a source and a destination open together. One with can hold both, separated by a comma, and closes them in reverse order when the block ends. An error on any line inside still closes both.

with open("plays.txt", "r") as src, open("plays.tmp", "w") as dst:
for line in src:
dst.write(line)

Practice

Multiple Choice

What does this program print before it ends?

class Res:
def __enter__(self):
print("open")
return self
def __exit__(self, exc_type, exc, tb):
print("close")
with Res() as r:
print("step 1")
int("oops")
print("step 2")
print("after")

with calls __exit__ with 4 arguments every time: the object as self, then the 3 exception slots, filled with None when the block finished normally. A header that lists only self accepts 1 of them, so the call fails after the indented block has already run, with TypeError: __exit__() takes 1 positional argument but 4 were given. The traceback follows the block's own prints, and the cleanup line inside __exit__ never prints.

class Lock:
def __enter__(self):
return self
def __exit__(self):
print("released")
❯❯❯PUTTING IT ALL TOGETHER

> The nightly Spotify job that turns a day of listening events into per-track play counts: it opens the day's export, converts each line, adds the result to a tally per track, and writes the counts file that downstream reports read the next morning.

The export is opened inside with, so the handle closes even when a line in the middle raises.
A generator wraps the file and yields 1 parsed line at a time, so a gigabyte export costs the memory of 1 line.
The conversion of each line sits in a try, and except ValueError as err logs the malformed line and moves on instead of stopping the job.
Each track's name and running count live together on a Tally object, and its add method is the only code that changes the count.
The counts are written to a temporary name and renamed with os.replace, so the morning reports read a complete file or yesterday's, never a half of either.
KEY TAKEAWAYS
try holds the lines that can fail, except SomeType names what to catch, and finally runs on every way out.
A file object keeps a position: readline() and read() advance it, and for line in f visits lines without loading the file whole.
Write to a temporary name and os.replace it over the real one, so a crash never leaves a half-written output behind.
A class bundles attributes stored on self with the methods that change them; every object built from it has its own attributes.
yield turns a function into a generator that produces 1 value per next(). The generator runs once and raises StopIteration at the end.
with calls __enter__ on entry and __exit__ on every exit, so cleanup runs even when the block raises.

Recover from bad input, stream files line by line, bundle data into classes, and close every resource you open.

Category
Python
Difficulty
advanced
Duration
19 minutes
Challenges
18 hands-on challenges

Topics covered: Handling Errors With try and except, Reading and Writing Files, Defining a Class, Generators, Context Managers

Lesson Sections

  1. Handling Errors With try and except (concepts: Lambda)

    What an uncaught error does try and except Keeping the error message Matching the right type finally runs no matter what Practice

  2. Reading and Writing Files (concepts: List Comprehension)

    Opening a file Reading moves a position One line at a time Writing Writing the result back safely Practice

  3. Defining a Class (concepts: Decorators)

    Data that belongs together The smallest useful class Methods change their own object Returning a value Practice

  4. Generators (concepts: Generators)

    Work that waits to be asked yield pauses a function Looping over a generator A generator runs once A generator remembers how far it got, and when it reaches the end it stays there. A second loop over the same generator object receives nothing and its block never runs. To go through the data again, call the generator function again for a fresh object. Practice

  5. Context Managers (concepts: Context Managers)

    The gap between open and close Objects that know how to clean up Cleanup when the block raises Two resources in one with Practice