Level 4 — ApplicationPython Intermediate

Python Intermediate

60 minutes50 marksprintable — key stays hidden on paper

Level: 4 — Application (novel problems, no hints) Time limit: 60 minutes Total marks: 50

Answer all questions. Write complete, runnable Python 3 code where asked. Assume no external packages unless the standard library is stated.


Question 1 — Log Parsing & Aggregation (12 marks)

A server writes lines to access.log in the format:

2024-03-01T09:15:22 GET /home 200
2024-03-01T09:15:23 POST /login 401
2024-03-01T09:16:01 GET /home 200

Each line is <ISO timestamp> <METHOD> <path> <status>.

Write a function top_paths(filename, n) that:

(a) Opens the file safely using a context manager and reads it line by line (do not load the whole file into memory at once). (3)

(b) Uses a regular expression with named groups to extract method, path, and status. Skip any line that does not match. (4)

(c) Counts requests per path but only for lines where status == "200", using an appropriate collections tool. (3)

(d) Returns the n most common paths as a list of (path, count) tuples, highest first. (2)


Question 2 — A Retry Decorator (11 marks)

Write a decorator retry(times, exceptions) that re-runs the decorated function up to times total attempts if it raises any exception listed in the tuple exceptions.

(a) The decorator must accept arguments and preserve the wrapped function's __name__ and __doc__. (4)

(b) If all attempts fail, re-raise the last exception caught. (3)

(c) On any attempt other than the last, print Attempt <k> failed: <message> before retrying. (2)

(d) Show a usage example decorating a function fetch() that should retry up to 3 times on ValueError only. (2)


Question 3 — Generators & Iterators (10 marks)

(a) Write a generator function running_average() that uses send(): each value sent in returns the running mean of all values received so far. The first next() primes it and yields None. (5)

(b) Write an iterator class Countdown(start) implementing __iter__ and __next__ that yields start, start-1, …, 1 and then raises StopIteration. (5)


Question 4 — Exceptions & Custom Hierarchy (9 marks)

You are validating user config dictionaries.

(a) Define a base custom exception ConfigError and two subclasses MissingKeyError and BadTypeError. (3)

(b) Write validate(cfg) that requires key "port" (an int) and key "host" (a str). Raise MissingKeyError if a key is absent and BadTypeError if a value has the wrong type, each with a helpful message. (4)

(c) Explain what except ConfigError as e: will catch and why, referencing the exception hierarchy. (2)


Question 5 — Predict the Output (8 marks)

For each snippet, state exactly what is printed (or the exception raised). Justify briefly.

(a) (2)

import itertools
print(list(itertools.accumulate([1, 2, 3, 4], lambda a, b: a * b)))

(b) (2)

from functools import reduce
print(reduce(lambda a, b: a + b, [], 10))

(c) (2)

def gen():
    try:
        yield 1
        yield 2
    finally:
        print("cleanup")
 
g = gen()
print(next(g))
g.close()

(d) (2)

import re
print(re.sub(r'(\w)\1', 'X', 'bookkeeper'))

Answer keyMark scheme & solutions

Question 1 (12 marks)

import re
from collections import Counter
 
LINE = re.compile(r'^\S+ (?P<method>\w+) (?P<path>\S+) (?P<status>\d+)$')
 
def top_paths(filename, n):
    counts = Counter()
    with open(filename) as f:          # (a) context manager
        for line in f:                 # (a) line-by-line, lazy
            m = LINE.match(line.strip())
            if not m:                  # (b) skip non-matching
                continue
            if m.group('status') == '200':   # (c) filter
                counts[m.group('path')] += 1
    return counts.most_common(n)       # (d)

Marks:

  • (a) with open(...) (1) + iterating for line in f rather than read()/readlines() (2) = 3.
  • (b) regex with named groups (?P<name>...) (2); using match/search and skipping on None (2) = 4.
  • (c) Counter (or defaultdict(int)) (2); only counting status == "200" (1) = 3.
  • (d) most_common(n) returns list of tuples sorted descending (2).

Why: iterating the file object streams lines so memory stays O(1); named groups make extraction self-documenting; Counter.most_common already sorts by count descending.


Question 2 (11 marks)

import functools
 
def retry(times, exceptions):
    def decorator(func):
        @functools.wraps(func)                     # (a) preserves name/doc
        def wrapper(*args, **kwargs):
            last = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except exceptions as e:            # (b) only listed exceptions
                    last = e
                    if attempt < times:            # (c) not last attempt
                        print(f"Attempt {attempt} failed: {e}")
            raise last                             # (b) re-raise last
        return wrapper
    return decorator
 
# (d) usage
@retry(3, (ValueError,))
def fetch():
    """Fetch data, may fail."""
    ...

Marks:

  • (a) three nested layers (args → func → wrapper) (2); @functools.wraps(func) (2) = 4.
  • (b) except exceptions as e capturing only the tuple (1); storing last and raise last after loop (2) = 3.
  • (c) conditional print with correct attempt number only when attempt < times (2).
  • (d) correct @retry(3, (ValueError,)) — tuple with single exception (2).

Why: three levels are needed because the decorator itself takes arguments; wraps copies __name__/__doc__; re-raising the stored exception after the loop preserves the original error type.


Question 3 (10 marks)

(a)

def running_average():
    total = 0
    count = 0
    value = yield None          # prime: first next() yields None
    while True:
        total += value
        count += 1
        value = yield total / count

Marks: initial yield None on priming (2); correct accumulation of total & count (2); yielding the mean back to the caller (1) = 5.

Usage check: g=running_average(); next(g)None; g.send(2)2.0; g.send(4)3.0.

(b)

class Countdown:
    def __init__(self, start):
        self.current = start
    def __iter__(self):
        return self
    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        val = self.current
        self.current -= 1
        return val

Marks: __iter__ returning self (2); __next__ decrementing and returning (2); raise StopIteration at end (1) = 5.

Why: an iterator must return itself from __iter__; StopIteration is the signal that ends a for loop cleanly.


Question 4 (9 marks)

(a)

class ConfigError(Exception):
    pass
class MissingKeyError(ConfigError):
    pass
class BadTypeError(ConfigError):
    pass

Marks: base subclassing Exception (1); both subclasses inheriting from ConfigError (2) = 3.

(b)

def validate(cfg):
    required = {"port": int, "host": str}
    for key, typ in required.items():
        if key not in cfg:
            raise MissingKeyError(f"Missing required key: {key!r}")
        if not isinstance(cfg[key], typ):
            raise BadTypeError(
                f"Key {key!r} must be {typ.__name__}, got {type(cfg[key]).__name__}")

Marks: missing-key check → MissingKeyError (2); type check via isinstanceBadTypeError (2) = 4. (Note: bool is a subclass of int; accepting or excepting it explicitly earns full marks if mentioned.)

(c) except ConfigError as e: catches any of ConfigError, MissingKeyError, and BadTypeError, because both subclasses inherit from ConfigError; an except clause matches the named class and all its descendants in the exception hierarchy. (2)


Question 5 (8 marks)

(a) [1, 2, 6, 24]accumulate with a multiply function gives running products: 1, 1·2, 2·3, 6·4. (2)

(b) 10reduce on an empty iterable with an initializer returns the initializer unchanged (the function is never called). (2)

(c) Prints:

1
cleanup

next(g) yields 1; g.close() throws GeneratorExit at the suspended yield, running the finally block which prints cleanup. No 2 is produced. (2)

(d) Xokkeeper → wait: apply carefully. Pattern (\w)\1 matches a doubled character; re.sub replaces the leftmost non-overlapping matches. bookkeeper: oo→X, then kk→X, then ee→X gives bXXpXpr. Answer: bXXpXpr. (2)

Why (d): scanning left→right non-overlapping: b, oo(match), kk(match), ee(match), p, rb X X p X p r = bXXpXpr.

[
  {"claim":"Q5a accumulate product gives [1,2,6,24]","code":"import itertools; result = list(itertools.accumulate([1,2,3,4], lambda a,b: a*b)) == [1,2,6,24]"},
  {"claim":"Q5b reduce of empty list with init 10 is 10","code":"from functools import reduce; result = reduce(lambda a,b:a+b, [], 10) == 10"},
  {"claim":"Q5d re.sub of doubled chars in bookkeeper gives bXXpXpr","code":"import re; result = re.sub(r'(\\w)\\1','X','bookkeeper') == 'bXXpXpr'"},
  {"claim":"Q3a running_average sends give correct means","code":"def running_average():\n total=0; count=0; value=yield None\n while True:\n  total+=value; count+=1; value=yield total/count\ng=running_average(); next(g); a=g.send(2); b=g.send(4); result = (a==2.0 and b==3.0)"},
  {"claim":"Q3b Countdown(3) yields [3,2,1]","code":"class Countdown:\n def __init__(s,start): s.current=start\n def __iter__(s): return s\n def __next__(s):\n  if s.current<=0: raise StopIteration\n  v=s.current; s.current-=1; return v\nresult = list(Countdown(3))==[3,2,1]"}
]