The Timing Decorator

A medium Python interview practice problem on DataDriven. Write the Python and run it against test cases, with instant feedback.

Domain
Python
Difficulty
medium
Seniority
L4
Asked in
Agoda

Problem

clock keeps running between calls: it holds a before and an after reading for each entry in calls, and only the span inside a call's own pair is its time. An on-call engineer replaying those [name, args, kwargs] calls against the starter's OPERATIONS wants each call's name, what it returned, and how long it took, with an operation's settings from defaults filling in whatever the call does not pass itself.

Wrap any function to capture how long it takes.

Example 1

Input:

calls = [["add",[1,2],{}],["concat",["eu","west"],{}],["concat",["us","east"],{"sep":"/"}]], clock = [1000,1012,1015,1018,1030,1037], defaults = {"concat":{"sep":"-"}}

Output:

[["add",3,12],["concat","eu-west",3],["concat","us/east",7]]

Example 2

Input:

calls = [["sub",[],{"a":10,"b":4}],["noop",[],{}]], clock = [0,5,9,9], defaults = {}

Output:

[["sub",6,5],["noop",null,0]]

Worked solution and explanation

What this really is

This is call reconstruction dressed up as a profiling report. Anyone can look the operation up in OPERATIONS and call it. What separates candidates is the order the keyword settings stack in, and which clock readings belong to which call. Put the call's own kwargs underneath defaults and the explicit sep of '/' loses, so 'us/east' comes back as 'us-east'. Treat clock as one running series and every duration after the first swallows the idle time between calls, so the profile blames operations for time nobody spent in them.


The walkthrough

Step 1: Split the clock into pairs

clock[0::2] is every before reading and clock[1::2] every after reading. Subtracting within a pair is the whole timing rule: in the first example the readings 1012 and 1015 belong to different calls, and the 3 units between them are idle time, not work.

Step 2: Layer defaults under the call

merge_settings(*layers) folds any number of dicts with {**settings, **layer}, and in a dict literal the later unpack wins. Passing defaults.get(name, {}) first and the call's kwargs last makes the caller's keywords the final word, and the empty-dict fallback lets an operation with no defaults run untouched instead of raising.

Step 3: Forward the call exactly as recorded

functools.partial(OPERATIONS[name], *args, **settings) freezes the call into a zero-argument callable, so a keyword-only call like sub(a=10, b=4) and a mixed call like concat('log', b='s') arrive intact instead of being squeezed into positions. Invoking it is a separate line.

Step 4: Emit one row per call

Walk calls and durations together and append (name, result, duration). Order is the call order, so the report lines up with the batch the engineer replayed.

Replaying the batch
import functools


def add(a, b):
    return a + b


def sub(a, b):
    return a - b


def mul(a, b):
    return a * b


def concat(a, b, sep=""):
    return a + sep + b


def noop():
    return None


OPERATIONS = {"add": add, "sub": sub, "mul": mul, "concat": concat, "noop": noop}


def merge_settings(*layers: dict) -> dict:
    # Later layers win, so the call's own keywords are passed last.
    settings = {}
    for layer in layers:
        settings = {**settings, **layer}
    return settings


def profile_calls(calls: list, clock: list, defaults: dict) -> list[tuple]:
    # Readings come in (before, after) pairs; the clock keeps running between calls.
    starts = clock[0::2]
    ends = clock[1::2]
    durations = [end - start for start, end in zip(starts, ends)]

    report = []
    for (name, args, kwargs), duration in zip(calls, durations):
        settings = merge_settings(defaults.get(name, {}), kwargs)
        bound = functools.partial(OPERATIONS[name], *args, **settings)
        result = bound()
        report.append((name, result, duration))
    return report
Defaults on top
merge_settings(kwargs, defaults.get(name, {})) lets the operation's sep of '-' overwrite the caller's '/', so the third call returns 'us-east'. It looks fine on every call that sets nothing of its own.
Call on top
merge_settings(defaults.get(name, {}), kwargs) fills gaps from defaults and lets anything the call passes win, so 'us/east' survives and 'eu-west' still picks up the default.

Common follow-up questions