Make a day21 folder for today’s files, open it in your editor, and run each script from its terminal, as on earlier days. Nothing needs installing today: every module on this page comes with Python.
On Day 8 you wrote eight lines to count words and find the most common one. On Day 4 you worked out the rule for leap years. Both jobs are so common that Python already has them, written, tested, and fast, in its standard library: the collection of modules that comes with every Python installation (Day 13 toured math, random, datetime, os, and sys). Today you meet five more modules that replace whole loops you have been writing by hand: collections for counting and grouping, itertools for combining iterables, datetime (properly this time) for calendars and clocks, re for finding patterns in text, and argparse for reading command-line options. The habit to take away is as important as any one module: before you write a utility yourself, look for it in the standard library first.
21.1 Counting with Counter
Counting things is everywhere: votes, words, page visits, dice rolls. Day 8’s pattern works, but you write it every time, with an empty dictionary, a loop, and counts.get(item, 0) + 1. Then you write Day 5’s maximum loop to find the winner, and a second, longer loop if you want the top three. The collections module (“specialized collection types”) has a type that does all of that.
A Counter is a dictionary made for counting. Hand it any iterable (a list, a string, a generator), and it counts each different item: the items become the keys, and their counts become the values. Because it is a dictionary, everything from Day 8 still works on it: [], in, items(), len().
>>> from collections import Counter
>>> votes = Counter(["pizza", "tacos", "pizza", "sushi", "pizza", "tacos"])
>>> votes
Counter({'pizza': 3, 'tacos': 2, 'sushi': 1})
>>> votes["pizza"]
3
>>> votes["curry"]
0
>>> len(votes)
3
>>> votes.most_common(2)
[('pizza', 3), ('tacos', 2)]
>>> votes.total()
6
>>> votes["sushi"] += 1
>>> votes.update(["curry", "pizza"])
>>> votes
Counter({'pizza': 4, 'tacos': 2, 'sushi': 2, 'curry': 1})Counter([...])does the whole Day 8 loop in one call. It shows the counts from the most common down.votes["curry"]gives0for an item that was never counted, instead of aKeyError. Reading a missing key does not add it:len(votes)is still 3.most_common(n)returns thenmost common items as a list of(item, count)tuples, biggest count first. Withoutn, it returns them all.total()adds up all the counts: six votes in all.- Because a missing count reads as 0,
votes["sushi"] += 1works even for a new item, with nogetneeded.update(iterable)counts more items into the sameCounter.
When two items have the same count, most_common keeps them in the order they were first counted. So a tie always comes out the same way, run after run.
Worked example: The fruit stall's best sellers
A market stall wrote down each sale as one word. Which three fruits sold best?
from collections import Counter
sales = "apple pear apple plum pear apple kiwi plum apple pear".split()
counts = Counter(sales)
print(f"{counts.total()} sales of {len(counts)} kinds of fruit")
for fruit, count in counts.most_common(3):
print(fruit, count, "*" * count)
print("Kiwis:", counts["kiwi"], "Mangoes:", counts["mango"])10 sales of 4 kinds of fruit
apple 4 ****
pear 3 ***
plum 2 **
Kiwis: 1 Mangoes: 0- Line 3 splits the string into a list of ten words (Day 3), and line 4 counts them: apple 4, pear 3, plum 2, kiwi 1.
- Line 6:
total()is 10, andlen(counts)is 4, the number of different fruits, because aCounteris a dictionary with one key per fruit. - Line 7 loops over the three
(fruit, count)pairs frommost_common(3), unpacking each one (Day 7)."*" * countrepeats a starcounttimes (Day 3), a tiny bar chart. - Line 9: no mangoes were sold, so
counts["mango"]is 0, with no error.
Compare Day 8: an empty dictionary, a loop with get, and a separate maximum loop, which found only the single winner. Here the counting is one call, and the top three is one more.
Key idea
Counter(iterable) counts every item in one call. A missing item counts as 0, most_common(n) gives the top n as
(item, count) pairs, total() adds up all the counts, and update() counts more items.
Watch out
Counting a string when you meant to count its words. A string is an iterable of characters (Day 5), so a Counter counts letters, and spaces:
>>> from collections import Counter
>>> Counter("to be or not to be")
Counter({' ': 5, 'o': 4, 't': 3, 'b': 2, 'e': 2, 'r': 1, 'n': 1})
>>> Counter("to be or not to be".split())
Counter({'to': 2, 'be': 2, 'or': 1, 'not': 1})The first Counter says the most common “word” is a space. To count words, split the text into a list of words first, with split().
Pause and try
Question 1 · Tracing
What does this program print?
from collections import Counter
letters = Counter("mississippi")
print(letters.most_common(2))
print(letters["p"], letters["z"], len(letters))
letters.update("pop")
print(letters.most_common(1), letters.total())Check your reasoning
[('i', 4), ('s', 4)]
2 0 4
[('i', 4)] 14“mississippi” has m 1, i 4, s 4, and p 2, first seen in that order. i and s tie on 4, so most_common(2) lists i first, because it was counted first. Line 5: p is 2, z was never counted so it reads as 0, and there are 4 different letters (reading z did not add it). Line 6 counts the letters of “pop”: p goes up to 4 and a new letter, o, gets 1. Now i, s, and p all have 4, and i still comes first. The total is 11 + 3 = 14.
Reread 21.1 Counting with Counter if your output differed.
21.2 Default Values with defaultdict
Day 8’s grouping pattern has the same shape as counting: before you can append to a key’s list, you check whether the key exists and, if not, add it with an empty list. Those two lines run only the first time each key appears, yet you write them every time. A defaultdict (also from collections) is a dictionary that fills in a missing key for you, the moment you use it.
You create it with a default factory: a function that defaultdict calls, with no arguments, to make the starting value for each new key. Day 10 showed that a function is a value you can pass around without calling it. The built-in types work as factories, because calling one with no arguments makes an “empty” value:
>>> from collections import defaultdict
>>> int()
0
>>> list()
[]
>>> minutes = defaultdict(int)
>>> minutes["reading"] += 30
>>> minutes["math"] += 45
>>> minutes["reading"] += 20
>>> minutes
defaultdict(<class 'int'>, {'reading': 50, 'math': 45})
>>> dict(minutes)
{'reading': 50, 'math': 45}int()gives0andlist()gives[]. Sodefaultdict(int)starts each new key at 0, anddefaultdict(list)starts each new key with an empty list. Pass the function itself,intorlist, with no parentheses.minutes["reading"] += 30reads a key that does not exist yet. Instead of raisingKeyError, thedefaultdictcallsint(), stores the 0, and then adds 30.- Printing it shows the factory,
<class 'int'>, and then the ordinary dictionary.dict(minutes)turns it into a plain dictionary when you want plainer output.
Worked example: Names by first letter
Here is Day 8’s grouping job with a defaultdict(list). The variable annotation (Day 19) says what the dictionary will hold, since it starts empty:
from collections import defaultdict
names = ["Ben", "Ana", "Bea", "Cal", "Abe", "Bo"]
by_letter: defaultdict[str, list[str]] = defaultdict(list)
for name in names:
by_letter[name[0]].append(name)
for letter, group in by_letter.items():
print(letter, group)B ['Ben', 'Bea', 'Bo']
A ['Ana', 'Abe']
C ['Cal']name |
name[0] |
What line 7 does |
|---|---|---|
| Ben | B |
B is new: make [], then append Ben |
| Ana | A |
A is new: make [], then append Ana |
| Bea | B |
B exists: append Bea |
| Cal | C |
C is new: make [], then append Cal |
| Abe | A |
A exists: append Abe |
| Bo | B |
B exists: append Bo |
The if key not in groups: lines from Day 8 are gone. Line 7 does both jobs: by_letter[name[0]] is either the existing list or a brand-new empty one, and .append(name) adds to it. The groups appear in the order their keys were first used.
Counter and defaultdict(int) can both count. Use Counter when you want counts and the extras (most_common, total); use defaultdict(int) or defaultdict(float) when you are adding up amounts by category, as in Day 8’s spending totals.
Key idea
defaultdict(factory) calls the factory to make a starting value whenever a missing key is used: defaultdict(list)
for grouping (groups[key].append(item)), defaultdict(int) for counting or totals. Pass the function, not a call.
Watch out
Looking up a key adds it. With a defaultdict, merely reading a missing key creates it:
from collections import defaultdict
by_letter: defaultdict[str, list[str]] = defaultdict(list)
for name in ["Ben", "Ana", "Bea"]:
by_letter[name[0]].append(name)
for letter in "ABZ":
if by_letter[letter]:
print(letter, by_letter[letter])
else:
print(letter, "nobody")
print(len(by_letter), "letters:", list(by_letter))A ['Ana']
B ['Ben', 'Bea']
Z nobody
3 letters: ['B', 'A', 'Z']The check on line 8 asked about Z, so Z now exists with an empty list, and the dictionary claims 3 letters where only 2 have names. To ask about a key without creating it, use letter in by_letter (Day 8’s in checks keys) or by_letter.get(letter, []), which never adds anything.
Pause and try
Question 2 · Tracing
What does this program print?
from collections import defaultdict
purchases = [("food", 12.5), ("games", 20.0), ("food", 8.25), ("books", 9.99)]
spending: defaultdict[str, float] = defaultdict(float)
for category, amount in purchases:
spending[category] += amount
print(spending["food"], spending["toys"])
print(len(spending), sorted(spending))Check your reasoning
20.75 0.0
4 ['books', 'food', 'games', 'toys']float() gives 0.0, so each new category starts at 0.0. food gets 12.5 + 8.25 = 20.75; games gets 20.0 and books 9.99. Line 8 reads spending["toys"], which does not exist, so it is created with 0.0 and printed. That lookup added a fourth key, so len is 4, and sorted lists the four keys alphabetically.
Reread 21.2 Default Values with defaultdict if your output differed.
21.3 Records with namedtuple
Day 7 stored a student as a tuple, ("Ana", 10, 92). That is compact and cannot change by accident, but code like student[2] makes the reader stop and count: is position 2 the grade or the score? A dictionary has names, but nothing stops a typo creating a new key, and Day 17’s @dataclass is a whole class. For a small, fixed record, there is something in between.
namedtuple (from collections) makes a new tuple type whose positions also have names. You call it with the type’s name and a list of field names, and it gives back a class (Day 16), which you name in CapWords:
>>> from collections import namedtuple
>>> Student = namedtuple("Student", ["name", "grade", "score"])
>>> ana = Student("Ana", 10, 92)
>>> ana
Student(name='Ana', grade=10, score=92)
>>> ana.score
92
>>> ana[0]
'Ana'
>>> name, grade, score = ana
>>> grade
10
>>> ana == ("Ana", 10, 92)
True
>>> ana._replace(score=95)
Student(name='Ana', grade=10, score=95)
>>> ana._asdict()
{'name': 'Ana', 'grade': 10, 'score': 92}Student("Ana", 10, 92)makes one record, like calling any class. Its printed form names every field.ana.scorereads a field by name. It is still a tuple, soana[0], unpacking (Day 7), and==with a plain tuple all still work.- Like any tuple, it cannot change.
_replace(field=value)makes a new record with one field changed._asdict()gives the fields as a dictionary. The leading underscore is only there so these method names cannot clash with your field names.
Worked example: Ranking a class
from collections import namedtuple
Student = namedtuple("Student", ["name", "grade", "score"])
students = [
Student("Ana", 10, 92),
Student("Ben", 11, 78),
Student(name="Chloe", grade=10, score=85),
]
best = max(students, key=lambda student: student.score)
print(f"Top score: {best.name} with {best.score}")
for student in sorted(students, key=lambda student: student.score):
print(student.name, student.score)
tenth = [student.name for student in students if student.grade == 10]
print("Grade 10:", tenth)Top score: Ana with 92
Ben 78
Chloe 85
Ana 92
Grade 10: ['Ana', 'Chloe']Line 8 shows that fields can also be passed by keyword (Day 10). max and sorted with key= (Day 10) read student.score, which says exactly what is being compared; with plain tuples it would be student[2]. The comprehension on line 17 (Day 11) keeps the names of the students whose grade is 10.
| You want | Use |
|---|---|
| A few values together, used once, close by | A plain tuple (Day 7) |
| A small, fixed record that never changes | A namedtuple |
| A record that changes, or needs methods or rules | A @dataclass or a class (Day 17) |
| Keys you do not know in advance | A dictionary (Day 8) |
namedtuple fields have no type hints. If you want hints, the typing module (Day 19) offers a class form, written like a data class:
from typing import NamedTuple
class Student(NamedTuple):
name: str
grade: int
score: int
ana = Student("Ana", 10, 92)
print(ana, ana.score)Student(name='Ana', grade=10, score=92) 92
Key idea
namedtuple("Name", ["field", ...]) makes a tuple type whose items can be read by name (record.field) as well as by
position. Records cannot change; _replace() makes a changed copy.
Watch out
Trying to change a field. A named tuple is still a tuple:
from collections import namedtuple
Student = namedtuple("Student", ["name", "grade", "score"])
ana = Student("Ana", 10, 92)
ana.score = 95
print(ana)AttributeError: can't set attributeMake a new record instead: ana = ana._replace(score=95). If the record really needs to change often, it is a better fit for a data class. Older versions of Python may word the message differently.
Pause and try
Question 3 · Multiple choice
Point = namedtuple("Point", ["x", "y"]) and p = Point(3, 4). Which statement is false?
p.x + p[1]is7p == (3, 4)isTruep.x = 5changesptoPoint(x=5, y=4)p._replace(y=0)givesPoint(x=3, y=0)and leavespunchanged
Check your reasoning
C is false. A named tuple cannot be changed, so p.x = 5 raises AttributeError.
- A is true:
p.xis 3 by name,p[1]is 4 by position, and 3 + 4 = 7. - B is true: a named tuple is a tuple, so it equals a plain tuple with the same items in the same order.
- D is true:
_replacebuilds a new record and does not touch the old one.
Reread 21.3 Records with namedtuple if you chose differently.
21.4 Queues with deque
Day 6 kept a deli queue in a list, serving each customer with pop(0). That works, but it gets slow as the list grows. A list stores its items in one row of numbered slots. Removing slot 0 means moving every other item one place to the left so that the numbering still starts at 0: with 100,000 people waiting, one pop(0) moves 99,999 items. Adding at the front with insert(0, item) does the same, the other way. Only the end of a list is cheap to change.
A deque (from collections, pronounced “deck”, short for “double-ended queue”) is built so that both ends are cheap. Adding or removing at either end takes the same short time, however long it is.
>>> from collections import deque
>>> line = deque(["Ana", "Ben"])
>>> line.append("Chloe")
>>> line.appendleft("Dev")
>>> line
deque(['Dev', 'Ana', 'Ben', 'Chloe'])
>>> line.popleft()
'Dev'
>>> line.pop()
'Chloe'
>>> line
deque(['Ana', 'Ben'])
>>> line[0]
'Ana'
>>> len(line)
2| Job | List | deque |
|---|---|---|
| Add at the back | append(x) |
append(x) |
| Remove from the back | pop() |
pop() |
| Add at the front | insert(0, x), slow |
appendleft(x) |
| Remove from the front | pop(0), slow |
popleft() |
| Read an item in the middle | items[500], fast |
items[500], slower |
len, in, for loops, and indexing work on a deque as on a list. A deque gives up fast access to the middle in return for fast ends, so use it for queues, and a list when you mostly read by position.
Worked example: A help desk queue
A queue is first in, first out: people join at the back and are served from the front. The loop runs while anyone is waiting, because an empty deque is falsy (Day 4):
from collections import deque
waiting = deque(["Ana", "Ben", "Chloe"])
served = 0
while waiting:
customer = waiting.popleft()
served += 1
print(f"Serving {customer}; {len(waiting)} still waiting")
if customer == "Ana":
waiting.append("Dev")
print("Served", served, "customers")Serving Ana; 2 still waiting
Serving Ben; 2 still waiting
Serving Chloe; 1 still waiting
Serving Dev; 0 still waiting
Served 4 customersAna is served first and leaves two waiting. Then, on line 11, Dev joins at the back, so after Ben is served there are still two waiting: Chloe and Dev. Dev, who arrived last, is served last.
A deque can also have a maxlen, a largest size. When a full deque gets a new item at one end, it drops an item from the other end to make room. That makes “keep only the last few” a one-liner:
from collections import deque
recent: deque[str] = deque(maxlen=3)
for page in ["home", "news", "sport", "weather", "news"]:
recent.append(page)
print(list(recent))['home']
['home', 'news']
['home', 'news', 'sport']
['news', 'sport', 'weather']
['sport', 'weather', 'news']
Once three pages are stored, each append on the right pushes the oldest page out on the left. news appears twice in the history because a deque keeps repeats, like a list. (Printing list(recent) keeps the output short; printing the deque itself would also show maxlen=3.)
Key idea
A deque adds and removes quickly at both ends: append and pop at the back, appendleft and popleft at the
front. Use it for queues instead of pop(0). With maxlen, a full deque drops an item from the opposite end.
Watch out
Serving from an empty queue. This loop serves three times, but only two people are waiting:
from collections import deque
waiting = deque(["Ana", "Ben"])
for _ in range(3):
print("Serving", waiting.popleft())Serving Ana
Serving Ben
Traceback (most recent call last):
...
IndexError: pop from an empty dequeLoop while waiting: instead, or check if waiting: before each popleft(), as you would before pop(0) on a list.
Pause and try
Question 4 · Tracing
What does this program print?
from collections import deque
orders: deque[int] = deque(maxlen=3)
for number in [101, 102, 103, 104]:
orders.append(number)
orders.appendleft(100)
print(orders)
print(orders.popleft(), orders.pop(), len(orders))Check your reasoning
deque([100, 102, 103], maxlen=3)
100 103 1| Step | orders afterward |
Why |
|---|---|---|
| append 101 to 103 | [101, 102, 103] |
Room for three |
| append 104 | [102, 103, 104] |
Full: adding on the right drops 101 |
| appendleft 100 | [100, 102, 103] |
Full: adding on the left drops 104 |
Printing a deque with a maxlen shows it. Then popleft() removes 100 from the front, pop() removes 103 from the back, and one item, 102, is left.
Reread 21.4 Queues with deque if your output differed.
21.5 Chaining and Choosing: chain, combinations, permutations
The itertools module (“tools for iterators”) holds ready-made building blocks for loops. Day 18 used its islice to take the first few items of an endless generator. Three more answer questions that would otherwise need nested loops:
chain(a, b, ...)gives every item ofa, then every item ofb, and so on, as one stream. The iterables can be of different kinds: a list, then a tuple, then a generator.combinations(items, r)gives every way to chooseritems, where order does not matter: choosing A and B is the same choice as B and A, so only one of them appears.permutations(items, r)gives every way to arrangeritems, where order matters: A then B is different from B then A. Leave outrto arrange all the items.
Each result is a tuple, and no item is used twice in one result. Like Day 18’s generators, these functions are lazy: they return an iterator that makes each result when the loop asks for it. To look at all the results at once, wrap the call in list().
>>> from itertools import chain, combinations, permutations
>>> morning = ["Ana", "Ben"]
>>> afternoon = ("Chloe", "Dev")
>>> list(chain(morning, afternoon))
['Ana', 'Ben', 'Chloe', 'Dev']
>>> list(combinations("ABC", 2))
[('A', 'B'), ('A', 'C'), ('B', 'C')]
>>> list(permutations("ABC", 2))
[('A', 'B'), ('A', 'C'), ('B', 'A'), ('B', 'C'), ('C', 'A'), ('C', 'B')]
>>> list(permutations("AB"))
[('A', 'B'), ('B', 'A')]chain saves building a joined list with + just to loop over it, and it works where + cannot, such as a list followed by a tuple. Three letters give three pairs to choose but six pairs to arrange, because each choice can be arranged two ways.
Worked example: Fixtures for a league
Four teams each play every other team once. Then the league decides that each pair plays twice, once at each team’s home ground.
from itertools import combinations, permutations
teams = ["Lions", "Owls", "Foxes", "Bears"]
print("League: each pair plays once")
for number, pair in enumerate(combinations(teams, 2), start=1):
print(f"{number}. {pair[0]} v {pair[1]}")
home_and_away = list(permutations(teams, 2))
print("Home and away:", len(home_and_away), "matches")
print("First three:", home_and_away[:3])League: each pair plays once
1. Lions v Owls
2. Lions v Foxes
3. Lions v Bears
4. Owls v Foxes
5. Owls v Bears
6. Foxes v Bears
Home and away: 12 matches
First three: [('Lions', 'Owls'), ('Lions', 'Foxes'), ('Lions', 'Bears')]- Lines 6 and 7:
combinations(teams, 2)gives each pair once, in the order the teams appear, andenumerate(..., start=1)(Day 5) numbers them. Lions v Owls appears, and Owls v Lions does not. - Line 9: in home-and-away fixtures, Lions at home to Owls is a different match from Owls at home to Lions, so order matters:
permutations.list()collects the results so thatlencan count them and a slice (Day 6) can show the first three.
The counts follow a rule. With n items, there are n × (n − 1) ordered pairs: 4 × 3 = 12. Each unordered pair is counted twice in that, so there are n × (n − 1) / 2 combinations: 12 / 2 = 6.
The numbers grow fast: permutations of 10 items has 3,628,800 results. Because the iterator is lazy, looping over it uses almost no memory, but list() would store every result at once. Loop over big results; only list() small ones.
Key idea
chain joins iterables end to end. combinations(items, r) chooses r items where order does not matter;
permutations(items, r) arranges them, so order matters. All three return lazy iterators; use list() to see small
results.
Watch out
Using up the iterator before the loop. Counting the matches first leaves nothing to loop over:
from itertools import combinations
matches = combinations(["Lions", "Owls", "Foxes"], 2)
print("Matches:", len(list(matches)))
for team_a, team_b in matches:
print(team_a, "v", team_b)
print("Done")Matches: 3
Donelist(matches) on line 4 runs the iterator to its end (Day 18), so the for loop on line 5 finds nothing, and no match is printed. Store the list once, matches = list(combinations(...)), and use that list for both jobs.
Pause and try
Question 5 · Tracing
What does this program print?
from itertools import chain, combinations, permutations
toppings = ["ham", "egg", "kale", "corn"]
print(len(list(combinations(toppings, 3))))
print(list(combinations(toppings, 3))[0])
print(len(list(permutations(toppings, 2))))
print(list(chain("ab", [1, 2])))Check your reasoning
4
('ham', 'egg', 'kale')
12
['a', 'b', 1, 2]Choosing 3 of 4 toppings is the same as choosing which 1 to leave out, so there are 4 combinations. The first follows the list’s order: the first three items. Arranging 2 of 4 gives 4 × 3 = 12, because order matters. chain gives the characters of "ab" and then the items of the list, as one list of four.
Reread 21.5 Chaining and Choosing: chain, combinations, permutations if your output differed.
21.6 Grouping Runs with groupby
How long was the longest rainy spell? Which days in a row had the same weather? These questions are about runs: items next to each other that share something. groupby from itertools splits an iterable into runs. For each run it gives a pair: the shared value, called the key, and an iterator over the items in that run.
from itertools import groupby
weather = ["sun", "sun", "rain", "rain", "rain", "sun", "snow"]
for kind, days in groupby(weather):
print(kind, len(list(days)))sun 2
rain 3
sun 1
snow 1
sun appears twice in the output, because there are two separate sunny runs. groupby only ever compares an item with the one just before it, so it never looks back to join the two. Each run’s items come as an iterator, so line 5 uses list(days) to collect them before counting them with len.
To group by something other than the item itself, pass key=, a function, as with sorted (Day 10). And to make each group hold every matching item, sort by the same key first, so that matching items sit next to each other:
Worked example: Names by first letter, again
from itertools import groupby
names = ["Ben", "Ana", "Bea", "Cal", "Abe", "Bo"]
names.sort()
print(names)
for letter, group in groupby(names, key=lambda name: name[0]):
print(letter, list(group))['Abe', 'Ana', 'Bea', 'Ben', 'Bo', 'Cal']
A ['Abe', 'Ana']
B ['Bea', 'Ben', 'Bo']
C ['Cal']After sorting, all the A names are together, then the B names, then C. The key function takes each name’s first letter, so each run is one letter. Compare 21.2’s defaultdict version of this job:
| Tool | Needs sorted input? | Gives | Best for |
|---|---|---|---|
defaultdict(list) |
No | A dictionary of complete groups | Grouping data in any order |
groupby |
Yes, for full groups | One run at a time, in order | Runs, and data already in order |
groupby shines when the data is already in order, such as log lines in time order (see Putting it together), or when the runs themselves are the point.
Key idea
groupby(items, key=...) gives (key, run) pairs for each run of neighboring items with the same key. Sort by the
same key first if you want one group per key. Each run is an iterator; use list() to keep its items.
Watch out
Forgetting to sort first. Without line 4’s sort(), the same program prints:
from itertools import groupby
names = ["Ben", "Ana", "Bea", "Cal", "Abe", "Bo"]
for letter, group in groupby(names, key=lambda name: name[0]):
print(letter, list(group))B ['Ben']
A ['Ana']
B ['Bea']
C ['Cal']
A ['Abe']
B ['Bo']No two neighbors share a first letter, so every name is a run of one. There is no error, just wrong groups. Sort by the same key first, or use a defaultdict(list).
Pause and try
Question 6 · Tracing
What does this program print?
from itertools import groupby
code = "".join(f"{char}{len(list(run))}" for char, run in groupby("aaabccaa"))
print(code)
longest = max(len(list(run)) for char, run in groupby("xxyyyyxz"))
print(longest)Check your reasoning
a3b1c2a2
4The runs of "aaabccaa" are aaa, b, cc, and aa. For each run, the f-string writes the character and the run’s length, and join (Day 3) glues the pieces together: a3, b1, c2, a2. The final aa is a separate run from the first aaa, because a run is only neighbors. In "xxyyyyxz", the runs have lengths 2, 4, 1, and 1, and max (Day 11, with a generator expression) picks 4.
Reread 21.6 Grouping Runs with groupby if your output differed.
21.7 Dates, Times, and Differences
Day 13 met the datetime module’s three main types: date for a calendar day, datetime for a day and a time, and timedelta for a length of time. Add a timedelta to a date and you get a new date; subtract two dates and you get a timedelta. Real programs are built on exactly that arithmetic: due dates, deadlines, shift lengths, ages.
Worked example: Library due dates and fines
A library lends books for 21 days and fines 25 cents for each day late:
from datetime import date, timedelta
LOAN_DAYS = 21
FINE_PER_DAY = 0.25
def due_date(borrowed: date) -> date:
"""Return the day a book borrowed on the given day is due back."""
return borrowed + timedelta(days=LOAN_DAYS)
def fine(borrowed: date, returned: date) -> float:
"""Return the late fine in dollars, or 0.0 if the book is on time."""
days_late = (returned - due_date(borrowed)).days
return max(0, days_late) * FINE_PER_DAY
borrowed = date(2026, 2, 10)
print("Due back:", due_date(borrowed))
print(f"Returned 1 March: ${fine(borrowed, date(2026, 3, 1)):.2f}")
print(f"Returned 10 March: ${fine(borrowed, date(2026, 3, 10)):.2f}")Due back: 2026-03-03
Returned 1 March: $0.00
Returned 10 March: $1.75due_dateadds 21 days. 10 February plus 21 days: 18 days to the end of February (2026 is not a leap year, so it has 28 days), then 3 more, giving 3 March. Thetimedeltacounted February’s length for you.finesubtracts the due date from the return date. Returned 1 March, that is 2 days before the due date, so.daysis -2;max(0, -2)(Day 6) turns an early return into 0 days late. Returned 10 March, it is 7 days late: 7 × 0.25 = 1.75.LOAN_DAYSandFINE_PER_DAYare constants (Day 19), so a rule change is a one-line edit.
Notice that neither function asks the computer what day it is. A real library program would call fine(borrowed, date.today()), with date.today() from Day 13. Passing the date in as a parameter, instead of calling date.today() inside the function, keeps the function repeatable: it gives the same answer for the same arguments on any day, so the examples on this page print the same output every time, and a test (Day 20) can check a fine for any return date without waiting for that date to come. The same goes for datetime.now().
Dates and datetimes can also be compared with <, >, and ==: earlier is smaller. And a datetime can be split into its date() and its time():
from datetime import datetime
start = datetime(2026, 3, 6, 22, 30)
end = datetime(2026, 3, 7, 6, 15)
shift = end - start
print(shift)
print(shift.total_seconds() / 3600, "hours")
print(end > start, end.date(), end.time())7:45:00
7.75 hours
True 2026-03-07 06:15:00
A night shift from 10:30 p.m. to 6:15 a.m. the next morning is 7 hours 45 minutes; subtracting the datetimes handles the midnight in between. Printing a timedelta shows hours, minutes, and seconds. total_seconds() gives the whole length in seconds, as a float, so dividing by 3600 (seconds in an hour) gives hours: 27,900 / 3600 = 7.75.
Key idea
Date arithmetic is addition and subtraction: date + timedelta is a date, and date - date is a timedelta. Use
.days for whole days and total_seconds() for an exact length. Pass “today” into functions as a parameter so they
stay repeatable and testable.
Watch out
Reading .seconds as the whole length. A timedelta stores days and seconds separately:
>>> from datetime import timedelta
>>> trip = timedelta(days=1, hours=2)
>>> trip
datetime.timedelta(days=1, seconds=7200)
>>> trip.seconds
7200
>>> trip.total_seconds()
93600.0
>>> trip.total_seconds() / 3600
26.0A trip of 1 day and 2 hours has .seconds of 7200, only the 2 hours left over after the whole day. total_seconds() gives all 93,600 seconds (86,400 for the day plus 7200), which is 26 hours. Use total_seconds() whenever you want the full length in seconds, minutes, or hours.
Pause and try
Question 7 · Tracing
What does this program print? 2028 is a leap year.
from datetime import date, datetime, timedelta
print(date(2028, 2, 27) + timedelta(days=3))
print(datetime(2026, 5, 1, 23, 50) + timedelta(minutes=25))
print((date(2026, 12, 25) - date(2026, 12, 1)).days)Check your reasoning
2028-03-01
2026-05-02 00:15:00
2427 February 2028 plus 3 days: 28 February, 29 February (a leap year), then 1 March. 23:50 plus 25 minutes passes midnight, so the date moves on to 2 May, at 00:15. A datetime prints with seconds. From 1 to 25 December is 24 days: subtraction counts the gap, not both ends.
Reread 21.7 Dates, Times, and Differences if your output differed.
21.8 Formatting and Parsing Dates
People write dates in many ways: 11/05/2026, May 11, 2026, 2026-05-11. A program needs two conversions. Formatting turns a date into text in the layout you choose. Parsing does the reverse: it reads text and builds a date from it.
Day 13 introduced strftime (“string from time”) for formatting. Its codes, each starting with %, stand for parts of a date or time:
| Code | Means | Example |
|---|---|---|
%Y |
Year, four digits | 2026 |
%m |
Month as a number, 01–12 | 05 |
%d |
Day of the month, 01–31 | 11 |
%B |
Month name | May |
%b |
Short month name | Mar |
%A |
Weekday name | Monday |
%a |
Short weekday name | Mon |
%H |
Hour, 00–23 | 08 |
%I |
Hour, 01–12 | 08 |
%p |
AM or PM | AM |
%M |
Minute, 00–59 | 30 |
%S |
Second, 00–59 | 00 |
>>> from datetime import date, datetime
>>> exam = datetime(2026, 5, 11, 8, 0)
>>> exam.strftime("%A %d %B %Y")
'Monday 11 May 2026'
>>> exam.strftime("%a %b %d, %I:%M %p")
'Mon May 11, 08:00 AM'
>>> f"Exam day: {exam:%d/%m/%Y}"
'Exam day: 11/05/2026'
>>> exam.isoformat()
'2026-05-11T08:00:00'
>>> date.fromisoformat("2026-05-11")
datetime.date(2026, 5, 11)
>>> datetime.strptime("11/05/2026 08:00", "%d/%m/%Y %H:%M")
datetime.datetime(2026, 5, 11, 8, 0)strftimekeeps every character that is not a code, such as spaces, commas, and slashes, so you build the layout around the codes.- An f-string can take the same codes after a colon,
{exam:%d/%m/%Y}, just like the:.2fformat specifier from Day 3. isoformat()gives the international standard layout, ISO 8601: year, month, day, from biggest to smallest, with aTbefore any time. It is the safest layout for files, because it means the same thing in every country, and ISO dates sort correctly as plain text.date.fromisoformat(text)parses an ISO date.datetime.strptime(text, format)(“string parse time”) parses any layout, given the codes that describe it, and returns adatetime.
Worked example: Reading US-style dates
Sign-up forms from the United States write dates as month/day/year. This function parses them, and gives back None for text that is not such a date, so its return hint is date | None (Day 19):
from datetime import date, datetime
def read_us_date(text: str) -> date | None:
"""Return the month/day/year date in text, or None if there is none."""
try:
return datetime.strptime(text, "%m/%d/%Y").date()
except ValueError:
return None
for text in ["3/14/2026", "12/1/2026", "14/3/2026", "2/30/2026"]:
when = read_us_date(text)
if when is None:
print(text, "is not a month/day/year date")
else:
print(text, "is", when.isoformat(), f"({when:%A})")3/14/2026 is 2026-03-14 (Saturday)
12/1/2026 is 2026-12-01 (Tuesday)
14/3/2026 is not a month/day/year date
2/30/2026 is not a month/day/year date- Line 7 parses with
"%m/%d/%Y"and then calls.date()to keep only the day, since the text has no time.strptimeaccepts3as well as03for%m. 14/3/2026fails because there is no month 14, and2/30/2026fails because February has no 30th. In both casesstrptimeraisesValueError, and lines 8 and 9 catch it (Day 15) and returnNone.- Line 17 formats with
{when:%A}to show the weekday name.
Without the try, a bad date stops the program with a message that shows both the text and the format:
from datetime import datetime
when = datetime.strptime("14/3/2026", "%m/%d/%Y")
print(when)ValueError: time data '14/3/2026' does not match format '%m/%d/%Y'
Key idea
Format a date with strftime(codes) or f"{when:codes}"; parse text with datetime.strptime(text, codes) or
date.fromisoformat(text). Parsing raises ValueError for text that does not fit, so catch it when the text comes
from a person.
Watch out
Mixing up %m and %M. Lower-case %m is the month; capital %M is the minute. This mistake does not always raise an error:
from datetime import datetime
when = datetime.strptime("03/14/2026", "%M/%d/%Y")
print(when)2026-01-14 00:03:00The 03 was read as 3 minutes. With no month in the format, strptime used January. The result looks like a real date, so a test with a known answer (Day 20) is the only way to catch it. Check the table: dates use %m, times use %M.
Pause and try
Question 8 · Multiple choice
Which format turns the text "14 Mar 2026 09:30" into a datetime with datetime.strptime?
"%d %b %Y %H:%M""%d %B %Y %H:%M""%d %m %Y %H:%M""%d %b %Y %H:%m"
Check your reasoning
A. %d reads 14, %b the short month name Mar, %Y 2026, and %H:%M the hour and minutes, with the spaces and colon matching the text exactly.
- B is wrong:
%Bwants the full month name,March, soMarraisesValueError. - C is wrong:
%mwants a month number, such as03, not a name. - D is wrong:
%mat the end means a month, not minutes, so the format cannot match andstrptimeraisesValueError.
Reread 21.8 Formatting and Parsing Dates if you chose differently.
21.9 Regular Expressions and re.search
Day 3’s string methods find exact text: note.find("555-0142") finds that one phone number. But what if you want any phone number, any order code, or any email address? You know what they look like (three digits, a dash, four digits) but not what they say. A regular expression, or regex, is a pattern that describes what text looks like. The re module searches text for patterns.
A pattern is a string in which most characters match themselves, and a few have special meanings:
| Piece | Matches | Example | Matches in text |
|---|---|---|---|
| a letter, digit | Itself | cat |
cat |
. |
Any one character except a line break | c.t |
cat, cut, c9t |
\d |
One digit, 0–9 | \d\d |
42 |
\w |
One “word” character: a letter, a digit, or _ |
\w\w\w |
a_2 |
\s |
One whitespace character: a space, a tab, a line break | a\sb |
a b |
[...] |
One of the characters listed, or a range such as a-z |
gr[ae]y |
grey, gray |
[^...] |
One character that is not listed | [^0-9] |
x, - |
+ |
One or more of the piece before it | \d+ |
7, 2026 |
* |
Zero or more of the piece before it | ab* |
a, abbb |
? |
Zero or one of the piece before it (optional) | colou?r |
color, colour |
{3}, {1,2} |
Exactly 3, or from 1 to 2, of the piece before it | \d{4} |
2026 |
^, $ |
The start and the end of the text | ^Hi |
Hi only at the start |
\b |
A word boundary: the edge between a word and a non-word | \bcat\b |
cat, not catalog |
\., \$, … |
A backslash makes a special character plain | \$\d+ |
$20 |
(...) |
A group: part of the match you want back on its own | (\d+)-(\d+) |
555-0142 |
A|B |
A or B |
GET|POST |
GET, POST |
So \d{3}-\d{4} means “three digits, a dash, four digits”, which matches 555-0142 and any other number of that shape.
Patterns use many backslashes, and Day 3 taught that a backslash in a string starts an escape character: "\n" is a line break, and "\b" is a “backspace” character, not \b. To keep every backslash exactly as typed, write patterns as raw strings: put an r before the opening quote. A raw string treats a backslash as an ordinary character.
>>> len("\b"), len(r"\b")
(1, 2)
>>> print(r"\d{3}-\d{4}")
\d{3}-\d{4}re.search(pattern, text) looks for the first place in text that matches. It returns a match object describing it, or None if nothing matches:
>>> import re
>>> note = "Call Ana at 555-0142 before Friday."
>>> match = re.search(r"\d{3}-\d{4}", note)
>>> match
<re.Match object; span=(12, 20), match='555-0142'>
>>> match.group()
'555-0142'
>>> print(re.search(r"\d{3}-\d{4}", "Call Ana on Friday."))
NoneThe match object shows its span, where the match starts and stops (as slice positions, Day 3), and the text that matched. group() gives the matched text. Searching a note with no phone number gives None.
Worked example: Finding order codes
A shop’s order codes are two capital letters, a dash, and four digits, such as AB-1234. Groups pull out the two parts:
import re
messages = [
"Hi, my order AB-1234 has not arrived.",
"Order number zz-9999, thanks",
"Where is CD-0042? It was due Monday.",
]
for message in messages:
match = re.search(r"([A-Z]{2})-(\d{4})", message)
if match:
store = match.group(1)
number = int(match.group(2))
print(match.group(), "is store", store, "order", number)
else:
print("No order code in:", message)AB-1234 is store AB order 1234
No order code in: Order number zz-9999, thanks
CD-0042 is store CD order 42- In
r"([A-Z]{2})-(\d{4})",[A-Z]{2}is two capital letters and\d{4}four digits. The parentheses make them groups 1 and 2. match.group()(orgroup(0)) is the whole match;group(1)andgroup(2)are the parts. Groups are always strings, so line 12 converts the number withint():"0042"becomes 42.- The second message has
zz-9999.[A-Z]means capital letters only, so lower-casezzdoes not match,searchreturnsNone, and theelseruns. searchfinds a match anywhere in the text, such as in the middle of a sentence.
Key idea
A regular expression describes what text looks like. Write it as a raw string, r"...". re.search(pattern, text)
returns a match object for the first match, or None; group() is the matched text, and group(1) is the first
group in parentheses.
Watch out
Calling group() when there was no match. re.search returns Match | None, and None has no methods:
import re
message = "Order number zz-9999, thanks"
code = re.search(r"[A-Z]{2}-\d{4}", message).group()
print(code)AttributeError: 'NoneType' object has no attribute 'group'The pattern wants capital letters, so it does not match zz-9999, and line 4 calls .group() on None. Treat a search result like any other X | None value (Day 19): check if match: before using it, as order_codes.py does.
Pause and try
Question 9 · Multiple choice
Which pattern finds a time such as 9:05 or 14:30 in the middle of a sentence?
r"\d:\d\d"r"\d{1,2}:\d{2}"r"\d+.\d+"r"^\d{2}:\d{2}$"
Check your reasoning
B. One or two digits for the hour, a colon, and exactly two digits for the minutes.
- A is wrong:
\dis exactly one digit, so in14:30it matches only4:30, missing the 1. - C is wrong:
.matches any character, so it also matches3.5or12a45. To match a real dot you would write\., but a time needs a colon anyway. - D is wrong:
^and$mean the start and end of the text, so it matches only if the whole text is a time. It also needs exactly two hour digits, so it misses9:05.
Reread 21.9 Regular Expressions and re.search if you chose differently.
21.10 Finding and Replacing: findall and sub
search stops at the first match. Two more functions handle every match at once:
re.findall(pattern, text)returns a list of every match, as strings. If the pattern has one group, the list holds just that group’s text from each match. (With several groups, it holds a tuple of groups for each match.)re.sub(pattern, replacement, text)(“substitute”) returns a new string in which every match is replaced. Strings are immutable (Day 3), so the original text is unchanged.
Worked example: Adding up payments in a note
import re
note = "Paid $12.50 on 3 March, $8.75 on 5 March and $20.00 on 9 March."
print(re.findall(r"\$\d+\.\d{2}", note))
amounts = re.findall(r"\$(\d+\.\d{2})", note)
print(amounts)
print(f"Total: ${sum(float(amount) for amount in amounts):.2f}")['$12.50', '$8.75', '$20.00']
['12.50', '8.75', '20.00']
Total: $41.25\$is a real dollar sign (a plain$would mean “end of text”),\d+one or more digits,\.a real dot, and\d{2}the cents. The day numbers 3, 5, and 9 have no$in front, so they are not matched.- Line 6 puts the amount in a group, so
findallreturns only the numbers, without the$, ready forfloat(). - Line 8 adds them with
sumand a generator expression (Day 11): 12.50 + 8.75 + 20.00 = 41.25.
sub cleans and rewrites text. In the replacement, \1, \2, and so on put back what group 1, group 2, and so on matched, so a raw string is needed there too. And an optional flags=re.IGNORECASE argument makes a pattern ignore the difference between capital and lower-case letters:
>>> import re
>>> re.sub(r"\s+", " ", "too many spaces")
'too many spaces'
>>> re.sub(r"\d{4} ", "#### ", "Card 4111 2222 3333 4444")
'Card #### #### #### 4444'
>>> re.sub(r"(\d{2})/(\d{2})/(\d{4})", r"\3-\2-\1", "Due 14/03/2026")
'Due 2026-03-14'
>>> re.findall(r"cat", "Cat, cat, CAT, catalog", flags=re.IGNORECASE)
['Cat', 'cat', 'CAT', 'cat']
>>> re.findall(r"\bcat\b", "Cat, cat, CAT, catalog", flags=re.IGNORECASE)
['Cat', 'cat', 'CAT']\s+matches any run of whitespace, so every run becomes one space.\d{4}(four digits and a space) matches the first three blocks of the card number, but not the last, which has no space after it.- The date pattern has three groups, and
r"\3-\2-\1"writes them back in the opposite order, turning a day/month/year date into an ISO date. - With
IGNORECASE,catmatchesCatandCAT, and also thecatinsidecatalog. Adding\bon each side keeps only whole words.
Key idea
re.findall returns a list of every match, or of each match’s group when the pattern has one group. re.sub replaces
every match, and \1 in the replacement puts back group 1. flags=re.IGNORECASE ignores case.
Watch out
+ and * take as much as they can. They are greedy: they match the longest text that still lets the pattern succeed. To find quoted words:
>>> import re
>>> line = 'She said "hello" and then "goodbye".'
>>> re.findall(r'".+"', line)
['"hello" and then "goodbye"']
>>> re.findall(r'"[^"]+"', line)
['"hello"', '"goodbye"']".+" starts at the first quote, and .+ runs all the way to the last quote, swallowing the text in between. "[^"]+" says “a quote, then one or more characters that are not quotes, then a quote”, so each match must stop at the next quote. (The patterns contain double quotes, so these strings use single quotes, which PEP 8 allows when it avoids backslashes.)
Pause and try
Question 10 · Tracing
What does this program print?
import re
post = "Loving #Python and #regex! Day #21 of 25. #"
tags = re.findall(r"#(\w+)", post)
print(tags)
print(re.sub(r"#\w+", "[tag]", post))Check your reasoning
['Python', 'regex', '21']
Loving [tag] and [tag]! Day [tag] of 25. ##(\w+) needs a # followed by at least one word character, and the group keeps only the word, so findall gives the three words without their #. \w includes digits, so #21 counts. The last # has nothing after it, so it does not match. sub has no group in its pattern; it replaces each whole match, # included, with [tag], and leaves the lone # alone.
Reread 21.10 Finding and Replacing: findall and sub if your output differed.
21.11 Command-Line Arguments with argparse
Day 13 read command-line arguments from sys.argv: python greet_args.py Ana put "Ana" at sys.argv[1]. That is fine for one argument. But real commands take options in any order, such as --times 2 or --shout, convert numbers, fill in defaults, explain how to use them, and print a clear message when someone types them wrongly. Doing all that by hand with sys.argv would take dozens of lines. The argparse module does it for you: you describe the arguments, and it reads sys.argv for you.
Worked example: A greeting command
import argparse
parser = argparse.ArgumentParser(description="Greet someone by name.")
parser.add_argument("name", help="who to greet")
parser.add_argument("-t", "--times", type=int, default=1, help="how often")
parser.add_argument("--shout", action="store_true", help="greet in capitals")
args = parser.parse_args()
greeting = f"Hello, {args.name}!"
if args.shout:
greeting = greeting.upper()
for _ in range(args.times):
print(greeting)Run with python greet.py Ana --times 2 --shout:
HELLO, ANA!
HELLO, ANA!- Line 3 makes an
ArgumentParser, the object that reads the arguments. Itsdescriptionappears in the help. - Line 4 adds a positional argument,
name: one the user must give, identified by its position, not by a label. - Line 5 adds an option, an argument that starts with dashes and is optional. It has a short name,
-t, and a long one,--times.type=intconverts the text to an int (every argument arrives as a string, as withinput()), anddefault=1is used when the option is left out. - Line 6:
action="store_true"makes--shouta flag, an option with no value after it. It isTrueif the user types it andFalseif not. - Line 7:
parse_args()readssys.argv, checks it, and returns an object with one attribute per argument:args.name,args.times,args.shout. The attribute names come from the long names, without the dashes.
Options can come in any order, before or after the name: python greet.py --shout Ben -t 2 prints HELLO, BEN! twice, and python greet.py Ana uses both defaults and prints Hello, Ana! once.
You also get a help message for free. Run python greet.py --help (or -h):
usage: greet.py [-h] [-t TIMES] [--shout] name
Greet someone by name.
positional arguments:
name who to greet
options:
-h, --help show this help message and exit
-t, --times TIMES how often
--shout greet in capitals
The first line is the usage summary: square brackets mark what is optional, and name is required. Each help= text from lines 4 to 6 appears beside its argument. This is Python 3.14’s layout; older versions may differ a little, for example saying optional arguments: instead of options:.
When the arguments are wrong, argparse prints the usage line and an error, and stops the program before your code runs. Run with no name at all:
usage: greet.py [-h] [-t TIMES] [--shout] name
greet.py: error: the following arguments are required: name
Typing -t two stops it the same way, with a message that 'two' is not a valid int. Compare Day 13’s greet_args.py, which had to check len(sys.argv) itself.
parse_args() can also take a list of strings in place of the real command line, as in parser.parse_args(["Ana", "--times", "3"]). That is how you try a parser out, or test it (Day 20), without typing commands.
Key idea
argparse reads command-line arguments: add_argument("name") for a required positional argument,
add_argument("--option", type=int, default=...) for an option, and action="store_true" for a flag. parse_args()
returns an object with one attribute per argument, and --help and error messages come free.
Watch out
Dashes in an option’s name. An option called --max-price becomes the attribute max_price, with an underscore, because a name cannot contain a dash. Writing the dash reads as a subtraction:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--max-price", type=float, default=50.0)
args = parser.parse_args(["--max-price", "20"])
print(args)
print(args.max-price)Namespace(max_price=20.0)
AttributeError: 'Namespace' object has no attribute 'max'The first line shows what parse_args returned: a Namespace object, holding max_price. args.max-price means args.max minus price, and there is no attribute max. Write args.max_price.
Pause and try
Question 11 · Multiple choice
Using greet.py, which command prints HELLO, BEN! exactly twice?
python greet.py --name Ben --shout -t 2python greet.py Ben shout 2python greet.py -t 2 --shout Benpython greet.py Ben -t 2
Check your reasoning
C. Options can come in any order: -t 2 sets times to 2, --shout sets shout to True, and Ben fills the positional name.
- A is wrong: there is no
--nameoption.nameis positional, so it takes no label, andargparsestops with an error about the unrecognized argument. - B is wrong: without dashes,
shoutand2are just extra positional words, and the parser has room for only one, so it stops with an error. - D is wrong: it prints
Hello, Ben!twice, without capitals, because--shoutwas left out and a flag defaults toFalse.
Reread 21.11 Command-Line Arguments with argparse if you chose differently.
21.12 Look Before You Write
Every module today replaced code you could have written yourself, and every one of them has been written, tested by thousands of programmers, and fixed when someone found an edge case. Code you do not write is code you do not have to test or debug. So before you write a utility, spend two minutes looking for it:
- Read the list of modules in the Python Standard Library documentation. Even skimming the names teaches you what exists.
- Search the web for “python standard library” and the job, such as “python standard library median”.
- In the REPL,
help(module)shows a module’s documentation, anddir(module)lists its names (Day 13).
Some small finds that replace earlier days’ practice:
>>> import calendar
>>> calendar.isleap(2024), calendar.isleap(2100)
(True, False)
>>> import statistics
>>> scores = [72, 88, 95, 88]
>>> statistics.mean(scores), statistics.median(scores), statistics.mode(scores)
(85.75, 88.0, 88)
>>> import string
>>> string.ascii_lowercase
'abcdefghijklmnopqrstuvwxyz'calendar.isleap is Day 4’s leap year rule, including the rule that 2100 is not a leap year. statistics has the averages: the mean adds the scores and divides (343 / 4 = 85.75), the median is the middle value of the sorted scores (halfway between 88 and 88), and the mode is the most common value. string has ready-made strings of letters, digits, and punctuation.
| When you need to | Look at |
|---|---|
| Count things, or group them into a dictionary | collections: Counter, defaultdict |
| A queue, or “the last N items” | collections: deque |
| Pairs, arrangements, joined or grouped loops | itertools |
| Dates, times, and differences | datetime, calendar |
| Averages and spread | statistics |
| Find or change patterns in text | re |
| Command-line arguments | argparse |
| Files, CSV, and JSON | pathlib, csv, json (Day 14) |
Writing a utility yourself is still worth doing once, to learn how it works, as you did on Days 4 and 8. In a real program, reach for the library.
Key idea
Before writing a utility, look for it in the standard library: skim the module list, search for the job, or use
help(). Library code is already tested, so you have less code to test and debug.
Pause and try
Question 12 · Multiple choice
A program reads a sensor every minute and must always know the average of the last 60 readings. Which tools fit best?
- A list with
insert(0, reading)andpop(), and a loop to add up the readings - A
deque(maxlen=60)andstatistics.mean - A
Counterof the readings andmost_common(60) combinations(readings, 60)
Check your reasoning
B. append on a deque with maxlen=60 keeps exactly the last 60 readings, dropping the oldest automatically, and statistics.mean averages them.
- A is wrong: it works, but
insert(0, ...)is the slow end of a list, and the adding-up loop is code the library already has. - C is wrong: a
Countercounts how often each value appears, and forgets the order, so it cannot tell which readings are the most recent. - D is wrong:
combinationsmakes every possible group of 60 readings, which is a huge number, and none of them is “the last 60”.
Reread 21.4 Queues with deque and 21.12 Look Before You Write if you chose differently.
Putting it together
A web server writes one line per visit: the date and time, the kind of request, the page, and a status code (200 means it worked; 400 or more means an error). This command summarizes a log file. It uses one tool from each of today’s modules:
"""Summarize a web server's visit log."""
import argparse
import re
from collections import Counter, deque, namedtuple
from datetime import datetime
from itertools import groupby
Visit = namedtuple("Visit", ["when", "page", "status"])
LINE_PATTERN = r"^(\S+ \S+) (GET|POST) (\S+) (\d{3})$"
def read_visits(path: str) -> list[Visit]:
"""Return the visits in a log file, skipping lines that are not visits."""
visits: list[Visit] = []
with open(path, encoding="utf-8") as file:
for line in file:
match = re.search(LINE_PATTERN, line.strip())
if match:
when = datetime.strptime(match.group(1), "%Y-%m-%d %H:%M:%S")
visits.append(Visit(when, match.group(3), int(match.group(4))))
return visits
def main() -> None:
parser = argparse.ArgumentParser(description="Summarize a visit log.")
parser.add_argument("logfile", help="the log file to read")
parser.add_argument("--top", type=int, default=3, help="pages to list")
args = parser.parse_args()
visits = read_visits(args.logfile)
span = visits[-1].when - visits[0].when
hours = span.seconds // 3600
print(f"{len(visits)} visits over {span.days} days and {hours} hours")
for day, group in groupby(visits, key=lambda visit: visit.when.date()):
print(f"{day:%a %d %b}: {len(list(group))} visits")
pages = Counter(visit.page for visit in visits)
for page, count in pages.most_common(args.top):
print(page, count)
errors: deque[Visit] = deque(maxlen=2)
for visit in visits:
if visit.status >= 400:
errors.append(visit)
for visit in errors:
print(f"Error {visit.status} on {visit.page} at {visit.when:%H:%M}")
if __name__ == "__main__":
main()The log file, visits.txt, in the same folder:
2026-03-02 09:15:02 GET /home 200
2026-03-02 09:15:40 GET /menu 200
2026-03-02 11:02:13 POST /order 500
2026-03-03 08:30:00 GET /home 200
2026-03-03 08:31:12 GET /menu 200
2026-03-03 08:33:57 GET /menus 404
this line is not a visit
2026-03-04 18:45:30 GET /home 200
2026-03-04 18:47:05 POST /order 200
Run with python log_report.py visits.txt --top 2:
8 visits over 2 days and 9 hours
Mon 02 Mar: 3 visits
Tue 03 Mar: 3 visits
Wed 04 Mar: 2 visits
/home 3
/menu 2
Error 500 on /order at 11:02
Error 404 on /menus at 08:33
| Lines | What to notice | Idea |
|---|---|---|
| 9 | Each visit is a small record that never changes: a namedtuple |
21.3 |
| 10 | ^ and $ make the pattern match a whole line; four groups capture the time, the request, the page, and the code |
21.9 |
| 18–21 | search returns None for the line that is not a visit, so if match: skips it |
21.9 |
| 20 | strptime parses the time, with %M for minutes and %m for the month |
21.8 |
| 26–29 | argparse reads the file name and --top; type=int makes args.top the int 2 |
21.11 |
| 32–34 | The last visit minus the first is 2 days, 9 hours, and 32 minutes; .seconds is right here, for the part after the whole days |
21.7 |
| 36–37 | The log is already in time order, so groupby on each visit’s date gives one run per day |
21.6 |
| 39–41 | Counter counts the pages, and most_common(args.top) lists the top 2 |
21.1 |
| 43–48 | A deque(maxlen=2) keeps only the two most recent errors |
21.4 |
span.seconds // 3600 is 34,323 seconds (9 hours, 32 minutes, 3 seconds) divided by 3600 and rounded down: 9 hours. The line this line is not a visit has no time, so the pattern does not match it, and the file’s eight real visits are all counted. Without the standard library, this program would need a hand-written counting loop, a grouping loop, a date parser that knows each month’s length, string slicing to pull each line apart, and code to read sys.argv and print a usage message.
Common mistakes
Use this table to go from a message to its usual cause. Older versions of Python may word some messages differently.
| What you see | Usual cause | Fix |
|---|---|---|
A Counter of single letters and ' ' |
Counting a string instead of its words | Counter(text.split()) |
TypeError: first argument must be callable or None |
defaultdict([]) or defaultdict(0): a value, not a function |
defaultdict(list), defaultdict(int) |
A defaultdict has keys nobody added |
Reading a missing key creates it | Check with in or get() |
AttributeError: can't set attribute |
Assigning to a namedtuple field |
record = record._replace(field=value) |
IndexError: pop from an empty deque |
popleft() or pop() on an empty deque |
Loop while queue: |
A loop over combinations, chain, or groupby does nothing |
The iterator was already used up | Make it again, or store list(...) once |
groupby repeats a key |
The data was not sorted by that key | Sort by the same key first |
| A length in hours is too small | .seconds leaves out the whole days |
total_seconds() |
ValueError: time data ... does not match format ... |
The text and the format codes differ | Fix the codes, or catch ValueError |
AttributeError: 'NoneType' object has no attribute 'group' |
re.search found no match |
if match: before match.group() |
| A regex match runs too far | + and * are greedy |
Use [^...] to stop at a character |
error: the following arguments are required |
A positional argument was left out | Type it, or make it an option with a default |
TypeError: 'str' object cannot be interpreted as an integer |
An argparse option without type=int |
Add type=int |
Vocabulary
| Term | Definition | Example |
|---|---|---|
| standard library | The collection of modules that comes with every Python installation | collections, re |
collections |
A standard module of specialized collection types | from collections import Counter |
Counter |
A dictionary that counts items; a missing item counts as 0 | Counter("banana")["a"] is 3 |
most_common(n) |
A Counter method giving the n most common (item, count) pairs |
[('pizza', 3), ('tacos', 2)] |
defaultdict |
A dictionary that creates a starting value when a missing key is used | defaultdict(list) |
| default factory | The function a defaultdict calls to make a new key’s starting value |
int, list |
namedtuple |
A tuple type whose positions also have names | Student("Ana", 10, 92).score |
deque |
A double-ended queue: fast to add and remove at both ends | deque(["Ana", "Ben"]).popleft() |
| queue | A first in, first out line: join at the back, leave from the front | A help desk line |
maxlen |
The largest size of a deque; a full one drops from the opposite end |
deque(maxlen=3) |
itertools |
A standard module of building blocks for loops | from itertools import chain |
chain |
Gives the items of several iterables, one after another | chain([1, 2], (3,)) |
combinations |
Every way to choose r items, order not mattering |
combinations("ABC", 2) |
permutations |
Every way to arrange r items, order mattering |
permutations("ABC", 2) |
groupby |
Splits an iterable into runs of neighbors with the same key | groupby("aaab") |
| run | Neighboring items that share the same key | aaa in aaab |
total_seconds() |
The whole length of a timedelta, in seconds |
timedelta(days=1).total_seconds() |
| formatting | Turning a date or time into text in a chosen layout | when.strftime("%d/%m/%Y") |
| parsing | Reading text and building a value, such as a date, from it | datetime.strptime(text, "%m/%d/%Y") |
strptime |
Parses text into a datetime, given format codes |
datetime.strptime("3/14/2026", ...) |
| ISO 8601 | The international date layout, year-month-day | 2026-03-14 |
| regular expression | A pattern that describes what text looks like (also regex) | \d{3}-\d{4} |
re |
The standard module for regular expressions | re.search(pattern, text) |
| raw string | A string literal, written with r before it, in which backslashes are ordinary characters |
r"\d+" |
| match object | What re.search returns for a match; group() gives the matched text |
match.group(1) |
| group | Part of a pattern in parentheses, whose matched text you can get back on its own | (\d+) |
| greedy | Matching as much text as possible, as + and * do |
".+" |
argparse |
The standard module for reading command-line arguments | argparse.ArgumentParser() |
| positional argument | A required command-line argument identified by its position | name in greet.py Ana |
| option | An optional command-line argument that starts with dashes | --times 2 |
| flag | An option with no value, True when typed |
--shout |
| usage | The one-line summary of how to run a command | usage: greet.py [-h] ... name |
Summary
- The standard library comes with Python. Before writing a utility, look for it there: library code is already written and tested.
collectionshasCounterfor counting (most_common, a missing item counts as 0),defaultdictfor grouping and totals without checking for missing keys,namedtuplefor small fixed records with named fields, anddequefor queues that are fast at both ends, withmaxlento keep only the last few items.itertoolshaschainto join iterables,combinationsto choose items where order does not matter,permutationsto arrange them where it does, andgroupbyto split data into runs, after sorting it by the same key. All return lazy iterators.datetimedoes calendar arithmetic (date - dateis atimedelta; usetotal_seconds()for exact lengths), formats withstrftimecodes, and parses withstrptimeorfromisoformat. Pass “today” into functions so they stay repeatable.- A regular expression describes what text looks like. With
re,searchfinds the first match (orNone),findalllists all of them, andsubreplaces them.argparsereads positional arguments, options, and flags, and writes the help and error messages for you.
Practice
Do these in your day21 folder. Type each program yourself, run it, and only then open the answer.
Question 13 · Short answer
Word count with Counter. Redo the Day 8 word count with Counter, and print the ten most common words with their counts. Read the text from a file, and let a regular expression split it into words so that punctuation is ignored.
Check your reasoning
One possible solution. Save a paragraph in story.txt, such as this one:
The garden was quiet in the morning. The bees came first, and then the birds.
A bee does not stop for long: it visits a flower, takes what it needs, and
moves on to the next flower. The birds were louder. They argued over the
feeder, and the smallest bird always seemed to win. By noon the garden was
warm, the bees were busy, and the birds had gone quiet in the shade. In the
evening the garden belonged to the moths.import re
from collections import Counter
from pathlib import Path
def top_words(text: str, how_many: int) -> list[tuple[str, int]]:
"""Return the most common words in text, with their counts.
Words are compared in lower case, and punctuation is ignored.
"""
words = re.findall(r"[a-z']+", text.lower())
return Counter(words).most_common(how_many)
text = Path("story.txt").read_text(encoding="utf-8")
for word, count in top_words(text, 10):
print(word, count)the 15
and 4
garden 3
in 3
birds 3
to 3
was 2
quiet 2
bees 2
a 2How it works:
- Line 11 does all of Day 8’s cleaning in one step.
text.lower()makesTheandthethe same word, andfindall(r"[a-z']+", ...)returns every run of letters (and apostrophes, so a word likedon'tstays whole). Punctuation and spaces are never part of a match, so there is noreplacefor each punctuation mark. - Line 12 counts the words and returns the top ten as
(word, count)pairs, which is why the return hint islist[tuple[str, int]](Day 19). - Several words have a count of 2: was, quiet, bees, a, it, flower, and were.
most_commonkeeps tied words in the order they first appeared, so the first four of them fill the last places in the top ten. - Day 8 needed an empty dictionary, a counting loop, and a separate maximum loop; here the counting and ranking are one line.
Reread 21.1 Counting with Counter and 21.10 Finding and Replacing: findall and sub if your result differed.
Question 14 · Short answer
Days until a date. Ask the user for a date, and print how many days away it is. Keep asking until the user types a real date. Write the day count as a function that takes “today” as a parameter.
Check your reasoning
One possible solution:
from datetime import date
TODAY = date(2026, 10, 15)
def days_until(target: date, today: date) -> int:
"""Return how many days from today until target (negative if past)."""
return (target - today).days
def ask_for_date(prompt: str) -> date:
"""Keep asking until the user types a real date as YYYY-MM-DD."""
while True:
text = input(prompt)
try:
return date.fromisoformat(text)
except ValueError:
print(f"{text} is not a date like 2026-12-25. Try again.")
target = ask_for_date("Date (YYYY-MM-DD): ")
days = days_until(target, TODAY)
if days > 0:
print(f"{target:%A %d %B %Y} is {days} days away.")
elif days == 0:
print("That is today!")
else:
print(f"{target:%A %d %B %Y} was {-days} days ago.")A run where the user first types two dates that do not exist:
Date (YYYY-MM-DD): next friday
next friday is not a date like 2026-12-25. Try again.
Date (YYYY-MM-DD): 2026-02-30
2026-02-30 is not a date like 2026-12-25. Try again.
Date (YYYY-MM-DD): 2026-12-25
Friday 25 December 2026 is 71 days away.How it works:
days_untilsubtracts the dates and returns.days: from 15 October to 25 December is 16 + 30 + 25 = 71 days. Becausetodayis a parameter, the function is repeatable and easy to test.TODAYis fixed at 15 October 2026 so that this page’s output never changes. A real program would writedays_until(target, date.today()), and its answer would change every day.ask_for_dateis Day 15’s “ask until valid” loop:date.fromisoformatraisesValueErrorfor text that is not a date, including2026-02-30, so theexceptprints a message and the loop asks again. Thereturninside thetryends the loop once a date is read.- The last lines handle three cases: a future date, today, and a past date, where
daysis negative and-daysmakes it positive.{target:%A %d %B %Y}formats the date with a weekday and month name.
Reread 21.7 Dates, Times, and Differences and 21.8 Formatting and Parsing Dates if your result differed.
Question 15 · Short answer
Finding email addresses. Extract every email address from a block of text with a regular expression.
Check your reasoning
One possible solution:
import re
EMAIL_PATTERN = r"[\w.+-]+@[\w-]+\.[\w.-]*\w"
def find_emails(text: str) -> list[str]:
"""Return every email address in text, in the order they appear."""
return re.findall(EMAIL_PATTERN, text)
notice = """Questions about the trip? Email ana.lee@example.com or
call the office. Tickets: tickets+trip@mail.example.org. Our old
address, info@example.net, still works. (Do not write to @school
or to someone@ with nothing after it.)"""
for email in find_emails(notice):
print(email)ana.lee@example.com
tickets+trip@mail.example.org
info@example.netHow it works, piece by piece:
| Piece | Matches |
|---|---|
[\w.+-]+ |
The name before the @: letters, digits, _, dots, plus signs, and dashes |
@ |
The @ itself |
[\w-]+ |
The first part of the domain, such as mail or example |
\. |
A real dot |
[\w.-]* |
The rest of the domain, which may hold more dots, such as example.org |
\w |
The last character must be a letter or digit |
- The final
\wis whytickets+trip@mail.example.org.comes back without the full stop that ends its sentence: a match cannot end on a dot. @schoolhas nothing before the@, andsomeone@has nothing after it, so neither matches.- The pattern has no groups, so
findallreturns the whole matches. Inside[...], a dot is a plain dot, and a dash at the end of the brackets is a plain dash.
Real email addresses can be stranger than this, so no short pattern catches every valid one. This pattern finds ordinary addresses, which is usually the job. Reread 21.9 Regular Expressions and re.search and 21.10 Finding and Replacing: findall and sub if your result differed.
Review questions
Question 16 · Multiple choice
You need to know, for each customer, the list of their orders, and the orders arrive in no particular order. Which is the shortest correct tool?
Counter(orders)defaultdict(list), appending each order to its customer’s listgroupby(orders, key=...)without sorting- A
deque(maxlen=10)per customer
Check your reasoning
B. Grouping in any order is the job of defaultdict(list): by_customer[order.customer].append(order), with no check for new customers.
- A is wrong: a
Counterwould tell you how many of each order there are, not which orders each customer made. - C is wrong:
groupbyonly joins neighbors, so unsorted orders give the same customer many separate runs. It works only after sorting by customer. - D is wrong:
maxlen=10would throw away all but the last ten orders, and you would still need a dictionary to find each customer’sdeque.
Reread 21.2 Default Values with defaultdict and 21.6 Grouping Runs with groupby if you chose differently.
Question 17 · Debugging
This program should group names by first letter. Find the error and fix it.
from collections import defaultdict
by_letter = defaultdict([])
by_letter["A"].append("Ana")
print(by_letter)Check your reasoning
Python stops on line 3:
TypeError: first argument must be callable or Nonedefaultdict needs a function to call each time it makes a new key’s starting value. [] is one list, not a function, so defaultdict refuses it. (If it accepted the list, every key would share that one list, the trap of Day 10’s mutable default.) Pass the list type itself, which makes a fresh empty list on each call:
by_letter = defaultdict(list)The same goes for counting: defaultdict(int), never defaultdict(0).
Reread 21.2 Default Values with defaultdict if you missed it.
Question 18 · Tracing
What does this program print?
from collections import Counter, namedtuple
from itertools import groupby
Sale = namedtuple("Sale", ["day", "item"])
sales = [
Sale("Mon", "tea"),
Sale("Mon", "cake"),
Sale("Tue", "tea"),
Sale("Mon", "tea"),
]
for day, group in groupby(sales, key=lambda sale: sale.day):
print(day, [sale.item for sale in group])
print(Counter(sale.item for sale in sales).most_common(1))Check your reasoning
Mon ['tea', 'cake']
Tue ['tea']
Mon ['tea']
[('tea', 3)]groupby splits the sales into runs by sale.day: the first two are Monday, then one Tuesday, then Monday again. The data is not sorted by day, so Monday appears as two separate runs. The comprehension reads sale.item from each named tuple in a run. The Counter sees every sale, wherever it is, and counts tea 3 and cake 1, so most_common(1) is tea.
Reread 21.3 Records with namedtuple and 21.6 Grouping Runs with groupby if your output differed.
Question 19 · Short answer
Write one line that prints a list of every time, such as 9:05 or 17:45, in this text, without the room number or anything else: "Doors 9:05, talks at 10:30 and 14:30, room 101. Ends 17:45."
Check your reasoning
One possible solution:
import re
schedule = "Doors 9:05, talks at 10:30 and 14:30, room 101. Ends 17:45."
print(re.findall(r"\b\d{1,2}:\d{2}\b", schedule))['9:05', '10:30', '14:30', '17:45']\d{1,2} is the hour (one or two digits), then a colon, and \d{2} the minutes. findall returns every match. The \b at each end makes the time a whole word, so a longer run of digits, such as 123:456, would not give a part of itself. 101 has no colon, so it never matches. A correct answer uses findall, a raw string, and allows one or two hour digits.
Reread 21.9 Regular Expressions and re.search and 21.10 Finding and Replacing: findall and sub if your answer differed.
Question 20 · Debugging
This program should print the greeting three times. Find the error and fix it.
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("name")
parser.add_argument("--times", default=1)
args = parser.parse_args(["Ana", "--times", "3"])
for _ in range(args.times):
print(f"Hello, {args.name}!")Check your reasoning
Python stops on line 7:
TypeError: 'str' object cannot be interpreted as an integerEvery command-line argument arrives as a string, so without type=int, args.times is the string "3", and range("3") is an error. (When the option is left out, args.times is the default, the int 1, so the bug hides until someone uses --times.) Add type=int:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("name")
parser.add_argument("--times", type=int, default=1)
args = parser.parse_args(["Ana", "--times", "3"])
for _ in range(args.times):
print(f"Hello, {args.name}!")Hello, Ana!
Hello, Ana!
Hello, Ana!Reread 21.11 Command-Line Arguments with argparse if you missed it.
Question 21 · Short answer
A classmate writes def is_overdue(due: date) -> bool: and calls date.today() inside it. Explain why def is_overdue(due: date, today: date) -> bool: is the better design, and write its body.
Check your reasoning
Model answer:
def is_overdue(due: date, today: date) -> bool:
"""Return True if today is after the due date."""
return today > dueWith date.today() inside, the answer depends on the day the code runs. A test (Day 20) that passes today could fail next month, and you could not test “one day late” or “due today” without waiting for those days. With today as a parameter, the function is repeatable: is_overdue(date(2026, 3, 3), date(2026, 3, 4)) is always True, and is_overdue(date(2026, 3, 3), date(2026, 3, 3)) is always False. Only the top of the program calls date.today() and passes it in. A correct answer mentions repeatable tests and compares the dates with >.
Reread 21.7 Dates, Times, and Differences if your reasoning differed.
Go further
The Python Tutorial’s Brief Tour of the Standard Library and Part II show more modules in the same spirit. When you write regular expressions often, the Regular Expression HOWTO goes much further than today.
Reflection
Check each statement. If the answer is “not yet,” reread the linked subsection.
- I can count items with Counter and list the most common ones. Reread
- I can group and total values with defaultdict, and explain why reading a key can add it. Reread
- I can make a small record with namedtuple and read its fields by name. Reread
- I can use a deque as a queue, and keep only the last few items with maxlen. Reread
- I can join iterables with chain, and choose between combinations and permutations. Reread
- I can split data into runs with groupby, sorting it first when needed. Reread
- I can add and subtract dates and times, and pass "today" into a function. Reread
- I can format a date with strftime and parse one with strptime. Reread
- I can write a regular expression and find a match with re.search. Reread
- I can list every match with re.findall and replace matches with re.sub. Reread
- I can read positional arguments, options, and flags with argparse. Reread
- I can look in the standard library before writing a utility myself. Reread