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.
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 fromdefaultsand lets anything the call passes win, so 'us/east' survives and 'eu-west' still picks up the default.
Common follow-up questions
- How would this change if `clock` came from `time.perf_counter()` read around each call? (Tests reading real readings around the bound call instead of replaying recorded ones.)
- What should the report say when an operation raises? (Tests whether the duration is still recorded and how the failure is surfaced.)
- How would you let any function report its own timing without its callers changing? (Tests moving the per-call timing into a reusable wrapper around the operation.)