Python Intermediate
Chapter: 1.3 Python Intermediate Level: 3 — Production (from-scratch derivations, code-from-memory, explain-out-loud) Time limit: 45 minutes Total marks: 60
Instructions: Write all code from memory. Where "explain out loud" is requested, write a short prose justification. Assume Python 3.10+. Do not use third-party libraries unless a question explicitly permits it.
Question 1 — Decorators from memory (12 marks)
(a) Write, from scratch, a decorator retry(times, exceptions) that re-runs the wrapped function up to times attempts, catching only the exception types listed in the exceptions tuple. If all attempts fail, the last exception must be re-raised. Use functools.wraps to preserve metadata. (7)
(b) Explain out loud: why is functools.wraps needed, and what specifically breaks if it is omitted? (3)
(c) Explain why retry needs three nested function layers rather than two. (2)
Question 2 — Generators & the send protocol (11 marks)
(a) Write a generator function running_average() that yields the running mean of numbers fed to it via send(). The first next() call must prime the generator and yield None. (6)
(b) Given your generator, state the exact output of:
g = running_average()
next(g)
print(g.send(10))
print(g.send(20))
print(g.send(30))(3)
(c) Explain out loud why the initial next(g) (or g.send(None)) is mandatory before any value-carrying send(). (2)
Question 3 — Custom context manager (10 marks)
(a) Implement a class-based context manager Timer using __enter__ / __exit__ that records wall-clock elapsed time. On exit it must store the elapsed seconds in an attribute elapsed, and it must not suppress exceptions raised inside the with block. (6)
(b) Explain out loud: what must __exit__ return to suppress an exception, and why the default (no explicit return) does not suppress. (2)
(c) State the exact order in which __enter__, the block body, and __exit__ execute when an exception is raised mid-block. (2)
Question 4 — Iterator protocol (9 marks)
Write, from scratch, an iterator class Countdown(n) that yields n, n-1, …, 1 and then stops correctly. It must implement __iter__ and __next__ and raise StopIteration at the right moment. Then explain out loud the difference between an iterable and an iterator, and why __iter__ returning self matters here. (9)
Question 5 — Regular expressions (9 marks)
(a) Write a single regex and a re call that extracts all key=value pairs from a string like "a=1; b=22; name=bob" into a list of (key, value) tuples. (4)
(b) Using re.sub, write code that replaces every run of one-or-more whitespace characters in a string with a single space. (3)
(c) Explain out loud the difference between re.match and re.search. (2)
Question 6 — Exception hierarchy & file I/O (9 marks)
(a) Write a function read_int_from(path) that opens a file, reads its first line, converts it to int, and returns it. Using try/except/else/finally, it must: return None on FileNotFoundError, return None on ValueError, run cleanup that always prints "done", and use else to hold the successful return. (6)
(b) Explain out loud why catching Exception instead of the two specific exceptions above would be considered poor practice, and name one exception you would not want silently swallowed. (3)
Answer keyMark scheme & solutions
Question 1 (12)
(a) (7 marks)
import functools
def retry(times, exceptions):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
last_exc = None
for _ in range(times):
try:
return func(*args, **kwargs)
except exceptions as e:
last_exc = e
raise last_exc
return wrapper
return decoratorMarks: outer retry(times, exceptions) (1); inner decorator(func) (1); @wraps(func) (1); loop over range(times) (1); try/except exceptions catching only listed types (1); return on success (1); re-raise last exception after exhausting attempts (1).
(b) (3 marks) Without wraps, the wrapper replaces the original — so func.__name__, func.__doc__, __module__, __wrapped__, and signature metadata become those of wrapper (1). This breaks introspection, debugging, documentation tools, and anything relying on __name__ (1). wraps copies these attributes back onto the wrapper (1).
(c) (2 marks) The outermost layer accepts the decorator arguments (times, exceptions) (1); the middle layer accepts the function being decorated; the inner layer is the actual replacement callable. A parametrised decorator needs one extra layer versus a plain @decorator because @retry(3, (ValueError,)) calls retry(...) first, and its return value is then applied to the function (1).
Question 2 (11)
(a) (6 marks)
def running_average():
total = 0
count = 0
average = None
while True:
value = yield average
total += value
count += 1
average = total / countMarks: init total/count (1); while True loop (1); value = yield average captures sent value (1); first yield produces None (prime) (1); accumulate total & count (1); compute and yield running mean (1).
(b) (3 marks)
10.0
15.0
20.0
One mark each. (10/1, 30/2, 60/3.)
(c) (2 marks) A generator starts suspended before its first line; send(value) resumes it at a paused yield, injecting value as that expression's result (1). Before the first next()/send(None) there is no paused yield to receive the value, so send(non-None) raises TypeError: can't send non-None value to a just-started generator (1).
Question 3 (10)
(a) (6 marks)
import time
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.elapsed = time.perf_counter() - self.start
return FalseMarks: __enter__ records start (1); __enter__ returns self (1); __exit__ signature with 3 exc args (1); computes elapsed (1); stores in self.elapsed (1); returns falsy / omits return so exceptions propagate (1).
(b) (2 marks) __exit__ must return a truthy value to suppress the exception (1); the default return None (no explicit return) is falsy, so the exception propagates normally (1).
(c) (2 marks) __enter__ runs first (1); then the block body until the exception occurs, at which point __exit__ is called with the exception info, then the exception propagates (since not suppressed) (1).
Question 4 (9)
class Countdown:
def __init__(self, n):
self.n = n
def __iter__(self):
return self
def __next__(self):
if self.n <= 0:
raise StopIteration
value = self.n
self.n -= 1
return valueMarks: __init__ stores n (1); __iter__ returns self (1); __next__ defined (1); StopIteration raised when exhausted (1); decrements state (1); returns current value before decrement effect correctly (1).
Explanation (3): An iterable is any object with __iter__ producing an iterator; an iterator is the object with __next__ that holds position/state and knows when to stop (1). Returning self from __iter__ makes the object serve as its own iterator, so it works directly in a for loop (2). (Award full 3 for a clear iterable-vs-iterator distinction plus the self-return rationale.)
Question 5 (9)
(a) (4 marks)
import re
pairs = re.findall(r'(\w+)=(\w+)', "a=1; b=22; name=bob")
# [('a', '1'), ('b', '22'), ('name', 'bob')]Marks: two capture groups (1); correct char classes / \w+ (1); re.findall returning tuples (1); correct result (1).
(b) (3 marks)
import re
result = re.sub(r'\s+', ' ', text)Marks: \s+ matches whitespace runs (1); replacement is single space (1); re.sub used correctly (1).
(c) (2 marks) re.match anchors at the start of the string only (1); re.search scans the whole string and returns the first match anywhere (1).
Question 6 (9)
(a) (6 marks)
def read_int_from(path):
try:
f = open(path)
line = f.readline()
value = int(line)
except FileNotFoundError:
return None
except ValueError:
return None
else:
return value
finally:
print("done")Marks: try opening/reading (1); except FileNotFoundError returns None (1); except ValueError returns None (1); else returns successful value (1); finally prints "done" (1); overall correct structure / conversion with int (1).
(Accept with open(path) as f: inside try; equally valid.)
(b) (3 marks) A bare except Exception swallows unrelated bugs — e.g. KeyError, AttributeError, typos surfacing as NameError, or MemoryError — masking real defects and returning None when the true cause is a programming error (2). One example you'd not want swallowed: KeyboardInterrupt (technically outside Exception) or a TypeError from a coding bug (1).
[
{"claim": "running_average yields 10.0, 15.0, 20.0 for sends 10,20,30",
"code": "def running_average():\n total=0; count=0; average=None\n while True:\n value=yield average\n total+=value; count+=1; average=total/count\ng=running_average(); next(g)\nout=[g.send(10),g.send(20),g.send(30)]\nresult = out==[10.0,15.0,20.0]"},
{"claim": "Countdown(4) produces [4,3,2,1]",
"code": "class Countdown:\n def __init__(self,n): self.n=n\n def __iter__(self): return self\n def __next__(self):\n if self.n<=0: raise StopIteration\n v=self.n; self.n-=1; return v\nresult = list(Countdown(4))==[4,3,2,1]"},
{"claim": "regex extracts key=value pairs as tuples",
"code": "import re\nresult = re.findall(r'(\\w+)=(\\w+)', 'a=1; b=22; name=bob')==[('a','1'),('b','22'),('name','bob')]"},
{"claim": "re.sub collapses whitespace runs to single space",
"code": "import re\nresult = re.sub(r'\\s+',' ','a b\\t\\nc')=='a b c'"}
]