Interactive exercises accompanying the Python lecture. Practise the key concepts — print() keyword arguments, list comprehensions and unpacking, classes and __str__, and regex with named capture groups.
Learning objective: After this step you will be able to use
print()’ssepandendkeyword arguments and unpack a list into separate arguments with*.
print() signatureprint(*args, sep=' ', end='\n', file=None, flush=False)
| Argument | Default | Effect |
|---|---|---|
sep |
' ' |
String inserted between multiple positional arguments |
end |
'\n' |
String appended after the last argument |
sep acts between arguments — use * to unpack a listWhen you pass multiple separate values, sep appears between them:
print("Hello", "World", sep='_') # Hello_World
To use sep with a list, prefix it with * to unpack it into separate arguments:
print(*["Hello", "World"], sep='_') # Hello_World
Without * the list is treated as a single argument, so sep has nothing to
separate and Python prints the list’s string representation instead:
print(["Hello", "World"], sep='_') # ['Hello', 'World']
Write one print() call that unpacks ["Hello", "World", "Students"] with *,
uses sep='_' and end='!\n'.
Expected output:
Hello_World_Students!
# What do you expect from this point? Predict the outcome then run the command
print(["Hello", "World"], sep='_')
# Write one print() call that unpacks the list with *
# and uses sep='_' and end='!\n'
# Expected output: Hello_World_Students!
1. What is the output of this statement?
print("A", "B", "C", sep='-', end='.')
Three separate string arguments are passed, so sep='-' is inserted between them:
A-B-C. The end='.' replaces the default newline with ., giving A-B-C.
(no trailing newline). When multiple arguments are passed, sep clearly separates them.
2. Why does print(["A", "B", "C"], sep='-') output ['A', 'B', 'C'] rather than A-B-C?
sep is placed between multiple positional arguments. One argument (the list)
means there is no gap for sep to fill. Python calls str() on the list, producing
['A', 'B', 'C']. To print list elements with a separator, unpack the list:
print(*["A", "B", "C"], sep='-') → A-B-C.
3. What does end='\n' do by default, and why would you change it?
By default, print() ends every call with a newline so the next output starts on a
new line. You can override end to append any string — end='!' adds ! before the
newline, end='' produces no newline at all (useful when building output incrementally
across multiple print() calls). sep and end are independent: sep goes between
arguments, end comes after the last one.
Learning objective: After this step you will be able to write list comprehensions using
range(), applymin(),max(), andsum()to lists, and unpack and swap variables with a single assignment.
A list comprehension creates a new list by applying an expression to every element of a sequence:
[expr(x) for x in sequence]
| Example | Result |
|---|---|
[x for x in range(1, 10)] |
[1, 2, 3, 4, 5, 6, 7, 8, 9] |
[2*x for x in range(4)] |
[0, 2, 4, 6] |
range(start, end) — integer sequencesrange(start, end) generates integers from start up to end - 1 (the upper bound is exclusive).
The default for start is 0, so range(4) is the same as range(0, 4).
min, max, sumThese are built-in functions — no import needed:
nums = [3, 1, 4, 1, 5]
print(min(nums)) # 1
print(max(nums)) # 5
print(sum(nums)) # 14
Python lets you unpack a list directly into named variables:
[a, b] = [1, 2]
print(a) # 1
print(b) # 2
And you can swap two variables in one line — no temporary variable needed:
a, b = b, a
print(a) # 2
print(b) # 1
Python evaluates the entire right-hand side first, then performs all assignments simultaneously.
“Syntactic sugar” refers to language features that make code easier to write without adding new capabilities.
Complete the four TODOs in the starter code:
evens = [] with a list comprehension using 2*x that produces [0, 2, 4, 6, 8].min, max, and sum of evens.evens into a and b.a and b in one line.Expected output:
[0, 2, 4, 6, 8]
0
8
20
Before swap: a=0, b=2
After swap: a=2, b=0
# 1. Create a list of even numbers 0, 2, 4, 6, 8 using a list comprehension
evens = [] # TODO: replace with a list comprehension
print(evens)
# 2. TODO: print min(evens), max(evens), and sum(evens) — one call per line
# 3. Unpack the first two elements of evens into a and b
a = 0 # TODO: replace these two lines with [a, b] = [evens[0], evens[1]]
b = 0
print(f"Before swap: a={a}, b={b}")
# 4. Swap a and b in one line, then print again
# TODO: replace this comment with a, b = b, a
print(f"After swap: a={a}, b={b}")
1. How many elements does range(1, 10) contain, and what is the last value?
range(start, end) generates integers from start up to end - 1.
range(1, 10) → 1, 2, 3, …, 9 — that is 9 elements, and the last value is 9.
The upper bound is always exclusive — the same convention Python uses for slice notation.
2. What does [2*x for x in range(4)] evaluate to?
range(4) is short for range(0, 4), generating 0, 1, 2, 3 (default start is 0).
The expression 2*x doubles each value: 0→0, 1→2, 2→4, 3→6.
Result: [0, 2, 4, 6].
3. After the following two lines, what are the values of a and b?
[a, b] = [10, 20]
a, b = b, a
Python evaluates the entire right-hand side of a, b = b, a first, capturing
(b, a) = (20, 10) as a temporary tuple, then assigns a=20 and b=10
simultaneously. No explicit temporary variable is needed — this is one of Python’s
most convenient syntactic-sugar features.
Learning objective: After this step you will be able to define a Python class with
__init__and__str__, and explain howprint(),str(), and f-strings all invoke__str__automatically.
class Point:
def __init__(self, x, y): # constructor
self.x = x # instance attribute
self.y = y
def __str__(self): # string representation
return f'Point({self.x}, {self.y})'
p = Point(3, 4)
print(p) # Point(3, 4)
print(str(p)) # Point(3, 4)
print(f"At: {p}") # At: Point(3, 4)
| Dunder method | Called automatically by |
|---|---|
__init__ |
Point(3, 4) — object construction |
__str__ |
print(obj), str(obj), and f-strings f"{obj}" |
All instance methods receive self as the first parameter — Python passes the object
automatically when you call a method on an instance.
The starter code gives you the Book class with __init__ already complete.
__str__ method inside the class that returns the book as a formatted string:
"<title>" by <author> (<year>)
Use an f-string. Note the literal double-quotes around the title.
my_book = Book("Pride and Prejudice", "Jane Austen", 1813)
__str__ automatically:
print(my_book) # 1. via print()
print(str(my_book)) # 2. via str()
print(f"The book is: {my_book}") # 3. via f-string
Expected output:
"Pride and Prejudice" by Jane Austen (1813)
"Pride and Prejudice" by Jane Austen (1813)
The book is: "Pride and Prejudice" by Jane Austen (1813)
class Book:
"""Represents a book with a title and an author."""
def __init__(self, title, author, year):
self.title = title
self.author = author
self.year = year
# Add your __str__ method here
# Create an instance of the class
my_book = Book("Pride and Prejudice", "Jane Austen", 1813)
# Print my_book three ways below
1. Without a __str__ method, what does print(my_book) display?
Without __str__, Python falls back to the default object representation:
<__main__.Book object at 0x...>. Defining __str__ replaces this with a
human-readable string. print() always calls str(obj) on its arguments — it
never raises TypeError for an object that lacks __str__.
2. All three calls below produce "Pride and Prejudice" by Jane Austen (1813).
What do they have in common?
print(my_book)
print(str(my_book))
print(f"The book is: {my_book}") # ignoring prefix
All three routes lead to __str__: print(obj) → str(obj) → obj.__str__();
str(obj) is the same path; and f-strings call str() on each interpolated value
automatically. Defining __str__ once gives you consistent, readable output
across every Python construct that converts an object to a string.
3. A student writes __str__ like this:
def __str__(self):
f'"{self.title}" by {self.author} ({self.year})'
print(my_book) outputs None. What is the bug?
A function with no return statement always returns None. The f-string expression
f'...' is evaluated but its value is thrown away. Adding return fixes it:
return f'"{self.title}" by {self.author} ({self.year})'. This is a very common
mistake — the f-string looks complete, but without return it produces nothing.
Learning objective: After this step you will be able to apply named capture groups,
re.compile(), andre.finditer()to extract structured data from an HTML requirements document.
So far you have used patterns that match a region of text and return the entire match. Named groups let you label sub-parts of a match and retrieve them by name:
import re
text = "Error 404: page not found"
m = re.search(r'Error (?P<code>\d+)', text)
if m:
print(m.group('code')) # '404'
The syntax (?P<name>...) is identical to a regular group (...) but each captured
sub-string can be retrieved by name with m.group('name').
re.compile() + re.finditer()re.compile(pattern, flags) pre-compiles a pattern into a reusable object.
Its .finditer() method returns an iterator of match objects so you can
access named groups from each result:
text = "apple costs $1.20, banana costs $0.45"
pattern = re.compile(r'(?P<fruit>\w+) costs \$(?P<price>[\d.]+)')
for m in pattern.finditer(text):
print(m.group('fruit'), '→', m.group('price'))
# apple → 1.20
# banana → 0.45
re.MULTILINEBy default, ^ matches only the very start of the whole string. Pass re.MULTILINE
to re.compile() to make ^ match the start of every line:
text = "line one\nAs a user, I want X so that Y\nline three"
# Without re.MULTILINE — ^ only anchors to the start of the string — no match
pattern = re.compile(r'^As an? (?P<user>.+), I want to .+ so that .+')
print(list(pattern.finditer(text))) # []
# With re.MULTILINE — ^ anchors to the start of each line
pattern = re.compile(r'^As an? (?P<user>.+), I want to .+ so that .+', re.MULTILINE)
for m in pattern.finditer(text):
print(m.group('user')) # user
\nA plain . does not match a newline. To span multiple lines, include \n explicitly:
text = "Given X is true\n When Y happens\n Then Z occurs"
pattern = re.compile(r'Given (?P<given>.+)\n.* When (?P<when>.+)\n*.* Then (?P<then>.+)', re.MULTILINE)
for m in pattern.finditer(text):
print(m.group('given'), '/', m.group('when'), '/', m.group('then'))
The starter file reads requirements.html into content and provides a match_regex
helper that applies re.MULTILINE automatically. Define the four patterns and call
match_regex to find:
User stories — lines starting with As a/an …, I want to … so that …
Print: User stories: <count> then each user group on its own indented line
Headings — all <h1>…</h1> tags
Print: Headings: <count> then each heading text on its own indented line
Acceptance criteria — three-line Given/When/Then blocks
Print: Acceptance criteria: <count> then for each: Given:, When:, Then: lines
File references — things like .pdf file, .zip file
Print: File references: <count> then each extension on its own indented line
import re
def match_regex(pattern):
# re.MULTILINE makes ^ match the start of each line
compiled_pattern = re.compile(pattern, re.MULTILINE)
return list(compiled_pattern.finditer(content))
with open('requirements.html', 'r', encoding='utf-8') as f:
content = f.read()
# 1. User stories
# e.g., As a reviewer, I want to be able to download all submitted files so that I can review them efficiently offline.
# 2. Headings
# e.g. <h1>Heading Text</h1>'
# 3. Acceptance criteria
# e.g., 'Given the user is logged in and submitted papers\n When they go to my paper\n Then they see their papers.'
# 4. File references
# e.g: .pdf file
<h1>Bulk Download for Reviewers</h1>
As a reviewer (or administrator), I want to be able to download all submitted files for a specific application in a single compressed folder (ZIP) so that I can review the materials efficiently offline.
Given I am logged in as a registered reviewer any many submissions have been created already,
When I navigate to the submission page of a valid submission
Then I see a "Download All Submission Files" button,
Given I am logged in as a registered reviewer and viewing the details of a specific, valid submission,
When I click the "Download All Submission Files" button,
Then The system should generate and prompt me to download a single .zip file containing every file uploaded by the applicant for that submission.
Given I am logged in as an applicant who created a valid submission before,
When I navigate to the submission page of my own own submission,
Then I do not see a "Download All Submission Files" button,
<h1>Applicant Registration</h1>
As an applicant, I want to be able to register and create a profile so that I can easily track my submissions and receive official communications.
Given I am a first-time user and I am on the system's registration page,
When I fill in all required fields and click the "Register" button,
Then My account should be created, and I should receive an email confirmation with a link to my new profile dashboard.
<h1>Multi-File Upload</h1>
As an applicant, I want to be able to upload multiple files (e.g., a PDF of my paper and a separate image file) so that I can provide all the required materials for my submission in one place.
Given I am creating a new submission with a pdf and a image, and both files are under the maximum size limit,
When I select both a .pdf file and a .jpg file using the upload tool,
Then Both files should be successfully attached to my submission and displayed in the confirmation summary.
<h1>Submission Confirmation</h1>
As an applicant, I want to receive an immediate email confirmation after successfully submitting my materials so that I have proof that my submission was received by the system.
Given I have completed all required fields and files for my application,
When I click the final "Submit Application" button,
Then The system should display a success message and immediately send an automated email containing a unique Submission ID and a summary of my provided data.
<h1>Administrative Filtering and Search</h1>
As an administrator, I want to be able to search and filter submissions based on criteria like submission status (e.g., "Pending," "Accepted," "Rejected") and applicant name so that I can quickly manage and process the application pool.
Given I am logged in as an administrator and viewing the main submissions list and there are many submissions in the system, some of which were submitted by user "Smith" and some of these have been accepted,
When I apply the filter for "Status: Accepted" and search for "Smith"
Then The list should only display submissions that have an Accepted status and contain "Smith" in the applicant's name field.
1. What does m.group('user') return after this match?
import re
text = "As a tester, I want to automate checks so that regressions are caught early"
pattern = re.compile(r'^As an? (?P<user>.+), I want to (?P<action>.+) so that (?P<reason>.+)', re.MULTILINE)
m = pattern.search(text)
The named group (?P<user>.+) captures everything between As a and , I want to.
That is 'tester'. The other two groups are accessible as m.group('action') →
'automate checks' and m.group('reason') → 'regressions are caught early'.
Named groups always return plain strings, not tuples — tuples are only produced by
re.findall() when the pattern has multiple groups.
2. How does re.finditer() differ from re.findall() when the pattern contains named groups?
re.finditer() yields match objects. Each object lets you call m.group('name')
to retrieve a specific named group by its label. re.findall() with groups returns a
list of tuples of raw strings in group order, discarding the names — you have to
remember which index corresponds to which group. re.finditer() is therefore clearer
when patterns have multiple named groups.
3. A pattern r'Given (?P<given>.+)\n.* When (?P<when>.+)\n*.* Then (?P<then>.+)' uses
explicit \n rather than re.DOTALL. Why?
With re.DOTALL, . matches any character including \n, making .+ very
greedy. It could swallow the newlines between two separate Given/When/Then blocks,
merging them into one large match. Using explicit \n keeps the pattern precise:
it crosses only the exact line boundaries that separate the three clauses.
4. (Spaced review — Step 1: print())
What is the output of print(["Given", "When", "Then"], sep='\n')?
The list is a single argument to print(), so sep='\n' has nothing to separate —
there is only one argument. Python prints the list’s string representation as one unit:
['Given', 'When', 'Then']. To print each element on its own line, unpack the list:
print(*["Given", "When", "Then"], sep='\n').
5. (Spaced review — Step 2: Classes)
After fixing the IndentationError in Book.__init__, which attribute would be
None if you accidentally wrote self.author = None instead of
self.author = author?
Each self.x = value line stores independently. If self.author = None is written,
only my_book.author would be None; my_book.title and my_book.year would still
hold their correct values. The __str__ method would then produce
'"Pride and Prejudice" by None (1813)'. This shows why reading the indentation of
each assignment carefully matters — Python executes only what you write.