Skip to main content

Python Parameters vs Arguments Practice Problems & Exercises

Practice: Parameters vs Arguments

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Rebind vs Mutate — Predict the OutputEasy
rebindingmutationpass-by-object-reference

Predict the output of each print call. Think about whether each function mutates the object or rebinds the local parameter.

Python
def add_element(lst):
    lst.append(99)

def replace_list(lst):
    lst = [999, 888]

def replace_key(d):
    d = {"y": 2}

data = [1, 2, 3]
add_element(data)
print(data)

replace_list(data)
print(data)

config = {"x": 1}
replace_key(config)
print(config)
Solution
[1, 2, 3, 99]
[1, 2, 3, 99]
{'x': 1}

Step-by-step:

  • add_element(data) — inside the function, lst is an alias for data. Calling lst.append(99) mutates the shared object. data is now [1, 2, 3, 99].
  • replace_list(data) — inside the function, lst = [999, 888] rebinds the local name lst to a brand new list. The caller's data is completely unaffected. It remains [1, 2, 3, 99].
  • replace_key(config) — inside the function, d = {"y": 2} rebinds the local name d. The caller's config still points to the original dict {"x": 1}.

Key insight: Mutation (.append(), [key] = val) changes the object that both names share. Rebinding (=) only changes the local name — the caller never sees it.

Expected Output
[1, 2, 3, 99]\n[1, 2, 3, 99]\n{'x': 1}
Hints

Hint 1: When a mutable object is passed to a function, the parameter and the argument point to the same object in memory.

Hint 2: Calling .append() mutates the object in place — all references see the change. Assigning with = rebinds the local name only.

#2Immutable Argument IllusionEasy
immutableintstringrebinding

Predict the output. All three functions attempt to "modify" their argument. Does any modification reach the caller?

Python
def try_increment(n):
    n = n + 5

def try_uppercase(s):
    s = s.upper()

def try_extend_tuple(t):
    t = t + (4, 5)

x = 10
try_increment(x)
print(x)

msg = "hello"
try_uppercase(msg)
print(msg)

coords = (1, 2, 3)
try_extend_tuple(coords)
print(coords)
Solution
10
hello
(1, 2, 3)

Why nothing changes:

  • n + 5 creates a new integer 15 and rebinds n locally. The caller's x still points to 10.
  • s.upper() returns a new string "HELLO" and rebinds s locally. The caller's msg still points to "hello".
  • t + (4, 5) creates a new tuple (1, 2, 3, 4, 5) and rebinds t locally. The caller's coords still points to (1, 2, 3).

Key insight: Immutable types (int, str, tuple, frozenset) cannot be changed in place. Every "modification" produces a new object, and assigning it to the parameter only rebinds the local name. This is why immutable arguments behave as if Python were pass-by-value — but the mechanism is different. Python still passes a reference to the same object; it is the immutability that prevents the caller from seeing changes.

Expected Output
10\nhello\n(1, 2, 3)
Hints

Hint 1: Integers, strings, and tuples are immutable. Operations on them always create new objects.

Hint 2: n + 5 creates a new int object and rebinds the local name n to it. The caller still points to the original object.

#3Prove Identity with id()Easy
idis-operatoridentitypass-by-object-reference

Predict the output. Use id() to trace when the parameter points to the same object as the argument and when it diverges.

Python
original = [10, 20, 30]
original_id = id(original)

def check_identity(lst):
    print(id(lst) == original_id)
    lst.append(40)
    print(id(lst) == original_id)
    lst = [99]
    print(id(lst) == original_id)

check_identity(original)
Solution
True
True
False

Trace through:

  1. id(lst) == original_id is True — when the function is called, lst receives a reference to the same object as original. Same object means same id().
  2. After lst.append(40), id(lst) == original_id is still True.append() mutates the list in place without creating a new object. The id does not change.
  3. After lst = [99], id(lst) == original_id is False — rebinding creates a brand new list object with a different id. lst now points to [99] while original still points to [10, 20, 30, 40].

Key insight: id() returns the memory address of the object in CPython. Mutation does not change the id (same object, modified in place). Rebinding always changes the id (new object entirely). This is the clearest way to prove Python's pass-by-object-reference model.

Expected Output
True\nTrue\nFalse
Hints

Hint 1: When a mutable object is passed to a function, id(parameter) == id(argument) inside the function — they are the same object.

Hint 2: After rebinding with =, the parameter points to a different object with a different id.

#4Positional vs Keyword ArgumentsEasy
positional-argumentskeyword-argumentscalling-conventions

Predict the output. Pay attention to how arguments are matched to parameters in each call.

Python
def describe(name, age, city):
    print(f"{name} is {age} from {city}")

describe("Alice", 30, "NYC")

describe(city="LA", name="Bob", age=25)

describe("Eve", city="Chicago", age=40)
Solution
Alice is 30 from NYC
Bob is 25 from LA
Eve is 40 from Chicago

How each call works:

  1. describe("Alice", 30, "NYC") — all positional. Matched left to right: name="Alice", age=30, city="NYC".
  2. describe(city="LA", name="Bob", age=25) — all keyword. Order does not matter; matched by name. name="Bob", age=25, city="LA".
  3. describe("Eve", city="Chicago", age=40) — mixed. The positional argument "Eve" is matched to the first parameter name. The keyword arguments city and age are matched by name. Positional arguments must come before keyword arguments in the call.

Key insight: Positional arguments and keyword arguments are two ways to pass values at the call site. The function signature defines parameters; how you pass the arguments determines whether they are positional (by position) or keyword (by name).

Expected Output
Alice is 30 from NYC\nBob is 25 from LA\nEve is 40 from Chicago
Hints

Hint 1: Positional arguments are matched left to right. Keyword arguments are matched by name regardless of order.

Hint 2: You can mix positional and keyword arguments, but positional must come first.


Medium

#5Dict Mutation Through Function CallsMedium
dict-mutationpass-by-object-referencealiasing

Predict the output. Three functions touch the same config dict in different ways.

Python
def add_debug(config):
    config["debug"] = True

def reset_config(config):
    config = {"host": "default", "port": 80}

def update_for_prod(config):
    config.update({"host": "prod.example.com", "port": 443})

settings = {"host": "localhost", "port": 8080}

add_debug(settings)
print(settings)

reset_config(settings)
print(settings)

update_for_prod(settings)
print(settings)
Solution
{'host': 'localhost', 'port': 8080, 'debug': True}
{'host': 'localhost', 'port': 8080, 'debug': True}
{'host': 'prod.example.com', 'port': 443, 'debug': True}

Analysis:

  1. add_debug(settings)config["debug"] = True mutates the dict in place. The caller sees debug: True added.
  2. reset_config(settings)config = {"host": "default", "port": 80} rebinds the local name config to a completely new dict. The caller's settings is unchanged. It still has all three keys from step 1.
  3. update_for_prod(settings)config.update(...) mutates the dict in place, overwriting host and port while keeping debug. The caller sees all three changes.

Key insight: config[key] = val and config.update(...) both mutate the existing dict object. Only config = new_dict creates a new object — and that is invisible to the caller.

Expected Output
{'host': 'localhost', 'port': 8080, 'debug': True}\n{'host': 'localhost', 'port': 8080, 'debug': True}\n{'host': 'prod.example.com', 'port': 443, 'debug': True}
Hints

Hint 1: dict[key] = value mutates the dict in place. The caller sees the change because the function and the caller share the same object.

Hint 2: dict.update() also mutates in place. But dict = new_dict only rebinds the local name.

#6The Mutable Default Argument TrapMedium
mutable-defaultfunction-definitiongotcha

Predict the output. This is one of the most famous Python gotchas.

Python
def append_to(item, target=[]):
    target.append(item)
    return target

print(append_to(1))
print(append_to(2))
print(append_to(3))
print(append_to(99, []))
print(append_to.__defaults__[0])
Solution
[1]
[1, 2]
[1, 2, 3]
[99]
[1, 2, 3]

What happens:

  1. append_to(1) — no target argument, so Python uses the default list. Appends 1. Returns [1]. But that default list is now [1].
  2. append_to(2) — same default list (which is now [1]). Appends 2. Returns [1, 2].
  3. append_to(3) — same default list (now [1, 2]). Appends 3. Returns [1, 2, 3].
  4. append_to(99, []) — an explicit empty list is passed. Appends 99 to that new list. The default is not touched. Returns [99].
  5. append_to.__defaults__[0] — inspects the default value stored on the function object. It is [1, 2, 3] because calls 1-3 mutated it.

The fix: Use None as the default and create a new list inside the function:

def append_to(item, target=None):
if target is None:
target = []
target.append(item)
return target

Why this happens: Default values are evaluated once at function definition time (when def executes) and stored in func.__defaults__. If the default is mutable, every call that relies on it shares the same object.

Expected Output
[1]\n[1, 2]\n[1, 2, 3]\n[99]\n[1, 2, 3]
Hints

Hint 1: Default argument values are evaluated ONCE when the function is defined, not each time the function is called.

Hint 2: If the default is a mutable object (like a list), all calls that use the default share the same object.

#7Defensive Copy PatternMedium
defensive-copyshallow-copyfunction-design

Write two functions that process data without mutating the caller's original objects. This is the defensive copy pattern — essential for writing safe, predictable functions.

Python
def process_scores(scores):
    return [s for s in scores if s >= 60]

def normalize_config(config):
    result = dict(config)
    defaults = {"debug": False, "verbose": False, "retries": 3}
    for key, value in defaults.items():
        result.setdefault(key, value)
    return result

original_scores = [85, 42, 91, 55, 73, 38]
filtered = process_scores(original_scores)
print(f"Original scores: {original_scores}")
print(f"Filtered: {filtered}")

original_config = {"host": "localhost"}
normalized = normalize_config(original_config)
print(f"Original config: {original_config}")
print(f"Normalized: {normalized}")
Solution
def process_scores(scores):
"""Build a new list — never modify the input."""
return [s for s in scores if s >= 60]

def normalize_config(config):
"""Copy the dict, then fill in defaults on the copy."""
result = dict(config) # shallow copy
defaults = {"debug": False, "verbose": False, "retries": 3}
for key, value in defaults.items():
result.setdefault(key, value)
return result

Why the defensive copy matters:

Without the copy in normalize_config, result.setdefault(key, value) would mutate the caller's dict directly. The caller would unexpectedly find debug, verbose, and retries keys in their original dict.

Three copy strategies:

  1. List comprehension — builds an entirely new list. Best for filtering or transforming.
  2. dict(config) or config.copy() — shallow copy. Good for flat dicts.
  3. copy.deepcopy(config) — deep copy. Required when values are also mutable (nested dicts, lists inside dicts).

Rule of thumb: If your function should not modify the input, make a copy at the top of the function and work only on the copy. Return the copy. Never mutate the parameter.

def process_scores(scores):
    """Remove failing scores (below 60) and return the filtered list.
    MUST NOT modify the caller's original list.
    """
    # TODO: implement without mutating the input
    pass

def normalize_config(config):
    """Add default values for missing keys and return the updated config.
    MUST NOT modify the caller's original dict.
    """
    # TODO: implement without mutating the input
    # Defaults: debug=False, verbose=False, retries=3
    pass
Expected Output
Original scores: [85, 42, 91, 55, 73, 38]\nFiltered: [85, 91, 73]\nOriginal config: {'host': 'localhost'}\nNormalized: {'host': 'localhost', 'debug': False, 'verbose': False, 'retries': 3}
Hints

Hint 1: Use list comprehension to build a new list instead of modifying the input.

Hint 2: Use dict.copy() or {**original} to create a shallow copy of the dict before adding defaults.

Hint 3: dict.setdefault(key, value) only sets the key if it is not already present — but it mutates in place, so use it on the copy.

#8Parameter Ordering RulesMedium
parameter-orderingargs-kwargskeyword-onlypositional-only

Predict the output. These functions demonstrate the full parameter ordering rules in Python.

Python
def full_spec(a, b, c, *args, x=0, y=0, **kwargs):
    print(a, b, c, args, f"x={x}", f"y={y}")

full_spec(1, 2, 3, 4, 5, x=10, y=20)

print("---")

def keyword_only(*, result, mode="default"):
    print(f"result={result}", f"mode={mode}")

keyword_only(result=15, mode="fast")

print("---")

def positional_only(a, b, /, **kwargs):
    print(a, f"b={kwargs.get('b', 'missing')}")

positional_only(3, 4, b=4)
Solution
1 2 3 (4, 5) x=10 y=20
---
result=15 mode=fast
---
3 b=4

Analysis:

  1. full_spec(1, 2, 3, 4, 5, x=10, y=20)a=1, b=2, c=3 fill the regular parameters. 4, 5 are captured by *args as (4, 5). x=10, y=20 are keyword-only parameters.

  2. keyword_only(result=15, mode="fast") — everything after * is keyword-only. You cannot call keyword_only(15, "fast") — that would raise TypeError: keyword_only() takes 0 positional arguments.

  3. positional_only(3, 4, b=4)a and b are before /, so they can only be passed positionally. The positional 4 fills parameter b. The keyword b=4 goes into **kwargs because the parameter b is positional-only and does not "claim" keyword arguments. Inside the function, kwargs is {"b": 4}.

Parameter ordering rule:

def f(pos_only, /, regular, *args, kw_only, **kwargs)
^^^^^^^ ^^^^^^^ ^^^^ ^^^^^^^ ^^^^^^
before / normal extra after * extra kw
Expected Output
1 2 3 (4, 5) x=10 y=20\n---\nresult=15 mode=fast\n---\n3 b=4
Hints

Hint 1: Python parameter order: positional-only (before /) → regular → *args → keyword-only (after *) → **kwargs.

Hint 2: Parameters after * can only be passed as keyword arguments. Parameters before / can only be passed as positional arguments.


Hard

#9Nested Mutable Argument MutationHard
nested-mutationshallow-copydeep-copyaliasing

Predict the output. This problem exposes the critical difference between shallow copy and deep copy when dealing with nested mutable structures.

Python
import copy

def modify_shallow(config):
    config = config.copy()
    config["users"][0]["role"] = "admin"
    return config

def modify_deep(config):
    config = copy.deepcopy(config)
    config["users"][0]["role"] = "viewer"
    return config

original = {
    "users": [
        {"name": "Alice", "role": "viewer"},
        {"name": "Bob", "role": "user"},
    ],
    "version": 1,
}

result1 = modify_shallow(original)
print(original)
print(original["users"][0] is result1["users"][0])

original["users"][0]["role"] = "viewer"

result2 = modify_deep(original)
print(original)
print(original["users"][0] is result2["users"][0])

print(result2)
print(original["users"][0]["role"] == "viewer")
Solution
{'users': [{'name': 'Alice', 'role': 'admin'}, {'name': 'Bob', 'role': 'user'}], 'version': 1}
True
{'users': [{'name': 'Alice', 'role': 'viewer'}, {'name': 'Bob', 'role': 'user'}], 'version': 1}
False
{'users': [{'name': 'Alice', 'role': 'viewer'}, {'name': 'Bob', 'role': 'user'}], 'version': 1}
True

Step-by-step:

  1. modify_shallowconfig.copy() creates a new outer dict, but config["users"] still points to the same list, and that list contains the same inner dicts. Changing config["users"][0]["role"] mutates the original inner dict. So original shows role: admin. The is check is True — same inner dict object.

  2. We reset original["users"][0]["role"] back to "viewer".

  3. modify_deepcopy.deepcopy(config) recursively copies everything: the outer dict, the users list, and each inner dict. Changing config["users"][0]["role"] only affects the deep copy. original is untouched. The is check is False — different inner dict objects.

  4. result2 shows role: viewer (the deep copy was modified independently). original still has role: viewer (unchanged by the deep copy function). The final equality check is True.

Memory picture:

Shallow copy:
original["users"] ──► [ptr0, ptr1]
copy["users"] ──► [ptr0, ptr1] (same inner dicts!)


{"name":"Alice","role":"admin"} (shared!)

Deep copy:
original["users"] ──► [ptr0, ptr1] ──► {"name":"Alice","role":"viewer"}
copy["users"] ──► [ptr2, ptr3] ──► {"name":"Alice","role":"viewer"} (independent!)

Rule: Use copy.deepcopy() when your data has nested mutable structures and you need full isolation.

Expected Output
{'users': [{'name': 'Alice', 'role': 'admin'}, {'name': 'Bob', 'role': 'user'}], 'version': 1}\nTrue\n{'users': [{'name': 'Alice', 'role': 'admin'}, {'name': 'Bob', 'role': 'user'}], 'version': 1}\nFalse\n{'users': [{'name': 'Alice', 'role': 'viewer'}, {'name': 'Bob', 'role': 'user'}], 'version': 1}\nTrue
Hints

Hint 1: A shallow copy (dict.copy()) creates a new outer dict, but the inner values still reference the same objects.

Hint 2: If you mutate a nested object (like a dict inside a list inside the copied dict), the original sees the change — the shallow copy does not protect nested structures.

Hint 3: copy.deepcopy() recursively copies all nested objects, fully isolating the copy from the original.

#10Build a Safe AccumulatorHard
accumulatormutable-defaultclosurefunction-design

Implement two accumulator factories that avoid the mutable default argument trap. Each factory returns a function that appends to its own independent collection. Optionally accept an initial collection to start from (without mutating the original).

Python
def make_accumulator(initial=None):
    if initial is None:
        items = []
    else:
        items = list(initial)

    def accumulate(item):
        items.append(item)
        return items

    return accumulate

def make_dict_accumulator(initial=None):
    if initial is None:
        store = {}
    else:
        store = dict(initial)

    def accumulate(key, value):
        store[key] = value
        return store

    return accumulate

# Test list accumulator — independent instances
acc1 = make_accumulator()
print(acc1(1))
print(acc1(2))
print(acc1(3))

acc2 = make_accumulator()
print(acc2("a"))
print(acc2("b"))

# Test with initial value
acc3 = make_accumulator([10, 20])
print(acc3(1))
print(acc3(2))

# Test dict accumulator
dacc1 = make_dict_accumulator()
print(dacc1("x", 1))
print(dacc1("y", 2))

dacc2 = make_dict_accumulator()
print(dacc2("a", 10))
Solution
def make_accumulator(initial=None):
if initial is None:
items = []
else:
items = list(initial) # Copy to avoid mutating caller's list

def accumulate(item):
items.append(item)
return items

return accumulate

def make_dict_accumulator(initial=None):
if initial is None:
store = {}
else:
store = dict(initial) # Copy to avoid mutating caller's dict

def accumulate(key, value):
store[key] = value
return store

return accumulate

Why this works correctly:

  1. No mutable default: The default is None (immutable). A new list/dict is created inside the function body on every call to the factory.

  2. Closure isolation: Each call to make_accumulator() creates a new local variable items. The inner accumulate function closes over this variable. Different accumulators close over different lists — they are fully independent.

  3. Defensive copy of initial: list(initial) creates a shallow copy. Without this, passing a list as initial would create an alias — the accumulator would mutate the caller's original list.

The anti-pattern this avoids:

# BAD — all callers share the same default list
def make_accumulator_bad(items=[]):
def accumulate(item):
items.append(item)
return items
return accumulate

With the bad version, every accumulator returned by make_accumulator_bad() would share the same list, because the default [] is evaluated once at definition time.

def make_accumulator(initial=None):
    """Return a function that accumulates items into a list.
    
    Each call to the returned function appends an item and returns
    the current list. Different accumulators must be independent.
    Must not use the mutable default argument anti-pattern.
    """
    # TODO: implement
    pass

def make_dict_accumulator(initial=None):
    """Return a function that accumulates key-value pairs into a dict.
    
    Each call takes (key, value) and returns the current dict.
    Different accumulators must be independent.
    """
    # TODO: implement
    pass
Expected Output
[1]\n[1, 2]\n[1, 2, 3]\n['a']\n['a', 'b']\n[10, 20, 1]\n[10, 20, 1, 2]\n{'x': 1}\n{'x': 1, 'y': 2}\n{'a': 10}
Hints

Hint 1: Use None as the default and create a new list/dict inside the function body — the standard pattern for avoiding mutable defaults.

Hint 2: The returned inner function closes over the local variable, creating an independent accumulator per call.

Hint 3: When initial is provided, make a copy of it so the caller s original is not mutated.

#11Argument Passing Audit ToolHard
idis-operatordecoratormutation-detection

Build a decorator that audits whether a function mutated any of its mutable arguments. This is a powerful debugging tool for catching unintended side effects.

Python
import functools
import copy

def audit_mutation(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        mutable_types = (list, dict, set)

        # Snapshot positional args
        pos_snapshots = {}
        for i, arg in enumerate(args):
            if isinstance(arg, mutable_types):
                pos_snapshots[i] = copy.deepcopy(arg)

        # Snapshot keyword args
        kw_snapshots = {}
        for key, val in kwargs.items():
            if isinstance(val, mutable_types):
                kw_snapshots[key] = copy.deepcopy(val)

        result = func(*args, **kwargs)

        # Check positional args for mutation
        mutated = False
        for i, snapshot in pos_snapshots.items():
            if args[i] != snapshot:
                print(f"[WARNING] {func.__name__} mutated argument {i}: {type(args[i]).__name__}")
                mutated = True

        # Check keyword args for mutation
        for key, snapshot in kw_snapshots.items():
            if kwargs[key] != snapshot:
                print(f"[WARNING] {func.__name__} mutated argument '{key}': {type(kwargs[key]).__name__}")
                mutated = True

        if not mutated:
            print(f"{func.__name__}: no mutation warnings")

        return result

    return wrapper

@audit_mutation
def safe_process(data):
    return [x * 2 for x in data]

@audit_mutation
def dangerous_process(data):
    data.append(99)
    return data

@audit_mutation
def sneaky_update(config):
    config["b"] = 2

original = [1, 2, 3]
safe_process(original)
print(f"Original after safe: {original}")

dangerous_process(original)
print(f"Original after dangerous: {original}")

conf = {"a": 1}
sneaky_update(config=conf)
print(f"Config after sneaky: {conf}")
Solution
import functools
import copy

def audit_mutation(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
mutable_types = (list, dict, set)

# Snapshot all mutable positional arguments
pos_snapshots = {}
for i, arg in enumerate(args):
if isinstance(arg, mutable_types):
pos_snapshots[i] = copy.deepcopy(arg)

# Snapshot all mutable keyword arguments
kw_snapshots = {}
for key, val in kwargs.items():
if isinstance(val, mutable_types):
kw_snapshots[key] = copy.deepcopy(val)

# Call the original function
result = func(*args, **kwargs)

# Compare each mutable argument to its snapshot
mutated = False
for i, snapshot in pos_snapshots.items():
if args[i] != snapshot:
print(f"[WARNING] {func.__name__} mutated argument {i}: {type(args[i]).__name__}")
mutated = True

for key, snapshot in kw_snapshots.items():
if kwargs[key] != snapshot:
print(f"[WARNING] {func.__name__} mutated argument '{key}': {type(kwargs[key]).__name__}")
mutated = True

if not mutated:
print(f"{func.__name__}: no mutation warnings")

return result

return wrapper

How the audit works:

  1. Before the call: For every argument that is a mutable type (list, dict, set), take a deepcopy snapshot. This captures the state before the function runs.
  2. After the call: Compare each mutable argument to its snapshot using ==. If they differ, the function mutated that argument.
  3. Report: Print a warning identifying which argument was mutated, by position or keyword name.

Why deepcopy is necessary for snapshots: A shallow copy would fail for nested structures. If the function mutates a nested list inside a dict, a shallow copy of the dict would also reflect the change (since the inner list is shared). deepcopy guarantees a fully independent snapshot.

Production considerations:

  • deepcopy is expensive for large objects. In production, you would use this decorator only in debug/test mode.
  • This does not catch mutation of custom objects unless they implement __eq__ correctly.
  • A more advanced version could use id() checks on nested objects to detect structural changes without full value comparison.
import functools
import copy

def audit_mutation(func):
    """Decorator that detects whether a function mutated any of its
    mutable arguments (list, dict, set).
    
    After the function runs, compare each mutable argument to a
    snapshot taken before the call. Print a warning for each
    mutated argument.
    
    Return the function's original return value.
    """
    # TODO: implement the decorator
    pass
Expected Output
safe_process: no mutation warnings\nOriginal after safe: [1, 2, 3]\n[WARNING] dangerous_process mutated argument 0: list\nOriginal after dangerous: [1, 2, 3, 99]\n[WARNING] sneaky_update mutated argument 'config': dict\nConfig after sneaky: {'a': 1, 'b': 2}
Hints

Hint 1: Use copy.deepcopy() to snapshot each mutable argument before the call. After the call, compare with == to detect changes.

Hint 2: Check both positional args and keyword kwargs for mutable types (list, dict, set).

Hint 3: Use functools.wraps to preserve the original function metadata.

© 2026 EngineersOfAI. All rights reserved.