Chapter 25 Advanced 58 Questions

Practice Questions — Decorators and Iterators

← Back to Notes
7 Easy
10 Medium
11 Hard

Topic-Specific Questions

Question 1
Easy
What is the output of the following code?
it = iter([10, 20, 30])
print(next(it))
print(next(it))
iter() creates an iterator. next() gets the next value.
10
20
Question 2
Easy
What is the output?
it = iter("abc")
print(next(it))
print(next(it))
print(next(it))
Iterating over a string yields one character at a time.
a
b
c
Question 3
Easy
What is the output?
from itertools import chain
result = list(chain([1, 2], [3, 4]))
print(result)
chain concatenates multiple iterables.
[1, 2, 3, 4]
Question 4
Easy
What is the output?
def shout(func):
    def wrapper():
        return func().upper()
    return wrapper

@shout
def greet():
    return "hello"

print(greet())
The decorator wraps greet and calls .upper() on its result.
HELLO
Question 5
Easy
What is the output?
my_list = [1, 2, 3]
print(hasattr(my_list, '__iter__'))
print(hasattr(my_list, '__next__'))
Lists are iterable but not iterators.
True
False
Question 6
Medium
What is the output?
from itertools import combinations
result = list(combinations([1, 2, 3], 2))
print(result)
print(len(result))
combinations(n, r) gives all subsets of size r.
[(1, 2), (1, 3), (2, 3)]
3
Question 7
Medium
What is the output?
from itertools import permutations
result = list(permutations("ab", 2))
print(result)
print(len(result))
permutations considers order, so (a,b) and (b,a) are different.
[('a', 'b'), ('b', 'a')]
2
Question 8
Medium
What is the output?
class Counter:
    def __init__(self, limit):
        self.limit = limit
        self.current = 0
    
    def __iter__(self):
        return self
    
    def __next__(self):
        if self.current >= self.limit:
            raise StopIteration
        self.current += 1
        return self.current

print(list(Counter(4)))
The iterator yields 1, 2, 3, 4 then raises StopIteration.
[1, 2, 3, 4]
Question 9
Medium
What is the output?
def double_result(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs) * 2
    return wrapper

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

print(add(3, 4))
print(add.__name__)
The decorator doubles the return value. No @wraps is used.
14
wrapper
Question 10
Medium
What is the output?
from functools import wraps

def bold(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return f"{func(*args, **kwargs)}"
    return wrapper

def italic(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        return f"{func(*args, **kwargs)}"
    return wrapper

@bold
@italic
def greet(name):
    return name

print(greet("Aarav"))
Decorators apply bottom to top. @italic wraps first, @bold wraps second.
<b><i>Aarav</i></b>
Question 11
Hard
What is the output?
from itertools import islice, count

def squares():
    n = 1
    while True:
        yield n * n
        n += 1

result = list(islice(squares(), 5))
print(result)

result2 = list(islice(count(10, 5), 4))
print(result2)
islice takes the first N values from any iterator. count(10, 5) counts 10, 15, 20, ...
[1, 4, 9, 16, 25]
[10, 15, 20, 25]
Question 12
Hard
What is the output?
from itertools import product

result = list(product([0, 1], repeat=3))
print(len(result))
print(result[0])
print(result[-1])
product with repeat=3 gives all 3-length combinations of [0,1], like binary numbers.
8
(0, 0, 0)
(1, 1, 1)
Question 13
Hard
What is the output?
def repeat(n):
    def decorator(func):
        def wrapper(*args, **kwargs):
            return [func(*args, **kwargs) for _ in range(n)]
        return wrapper
    return decorator

@repeat(3)
def greet(name):
    return f"Hi, {name}"

print(greet("Aarav"))
repeat(3) is a decorator factory. The inner decorator wraps greet to call it 3 times.
['Hi, Aarav', 'Hi, Aarav', 'Hi, Aarav']
Question 14
Hard
What is the output?
class Doubler:
    def __init__(self, func):
        self.func = func
    
    def __call__(self, *args, **kwargs):
        return self.func(*args, **kwargs) * 2

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

print(add(3, 4))
print(type(add).__name__)
Doubler is a class-based decorator. add is now an instance of Doubler.
14
Doubler
Question 15
Hard
What is the output?
it = iter([10, 20, 30])
print(next(it))        # 10
print(next(it))        # 20

for val in it:
    print(val, end=" ")
print()

for val in it:
    print(val, end=" ")
print("(empty)")
The for loop continues from where next() left off. After exhaustion, the second for loop gets nothing.
10
20
30
(empty)
Question 16
Medium
What is the difference between an iterable and an iterator?
Think about which methods each one must have.
An iterable is any object that has an __iter__() method, which returns an iterator. Lists, strings, tuples, dicts, sets, and ranges are all iterables. An iterator is an object that has both __iter__() and __next__() methods. Calling iter() on an iterable returns an iterator. Calling next() on an iterator returns the next value.
Question 17
Hard
Why should you always use @functools.wraps(func) in your decorators?
Think about what happens to the function's name and docstring.
Without @wraps(func), the decorated function loses its original __name__, __doc__, __module__, and other metadata. Instead, these are replaced with the wrapper function's metadata. This breaks debugging (stack traces show 'wrapper' instead of the real function name), documentation (help() shows nothing useful), and introspection tools. @wraps(func) copies the original function's metadata to the wrapper.

Mixed & Application Questions

Question 1
Easy
What is the output?
it = iter(range(3))
print(list(it))
print(list(it))
An iterator can only be consumed once.
[0, 1, 2]
[]
Question 2
Easy
What is the output?
from itertools import cycle

c = cycle(["A", "B"])
result = [next(c) for _ in range(5)]
print(result)
cycle repeats the iterable endlessly.
['A', 'B', 'A', 'B', 'A']
Question 3
Medium
What is the output?
def prefix(pre):
    def decorator(func):
        def wrapper(*args, **kwargs):
            return f"{pre}: {func(*args, **kwargs)}"
        return wrapper
    return decorator

@prefix("ERROR")
def message(text):
    return text

@prefix("INFO")
def info(text):
    return text

print(message("file not found"))
print(info("server started"))
prefix is a decorator factory. It creates decorators that prepend a string.
ERROR: file not found
INFO: server started
Question 4
Medium
What is the output?
from itertools import islice

def naturals():
    n = 1
    while True:
        yield n
        n += 1

def evens(numbers):
    for n in numbers:
        if n % 2 == 0:
            yield n

result = list(islice(evens(naturals()), 5))
print(result)
naturals yields 1, 2, 3, ... evens filters to even numbers. islice takes first 5.
[2, 4, 6, 8, 10]
Question 5
Medium
What is the output?
class Squares:
    def __init__(self, n):
        self.n = n
    
    def __iter__(self):
        for i in range(1, self.n + 1):
            yield i ** 2

print(list(Squares(5)))
print(sum(Squares(3)))
__iter__ can be a generator function (using yield). Each call to iter() creates a fresh generator.
[1, 4, 9, 16, 25]
14
Question 6
Medium
What is the output?
d = {"a": 1, "b": 2, "c": 3}
it = iter(d)
print(next(it))
print(next(it))
print(list(it))
Iterating over a dict yields its keys.
a
b
['c']
Question 7
Hard
What is the output?
def validate_positive(func):
    def wrapper(*args):
        for arg in args:
            if arg < 0:
                raise ValueError(f"Negative value: {arg}")
        return func(*args)
    return wrapper

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

print(add(3, 5))

try:
    print(add(-1, 5))
except ValueError as e:
    print(f"Error: {e}")
The decorator checks all arguments before calling the function.
8
Error: Negative value: -1
Question 8
Hard
What is the output?
from itertools import product, combinations

# How many ways to pick 2 from 4 students?
students = ["Aarav", "Priya", "Rohan", "Meera"]
pairs = list(combinations(students, 2))
print(f"Pairs: {len(pairs)}")

# How many 2-character strings from 'AB'?
codes = list(product("AB", repeat=2))
print(f"Codes: {len(codes)}")
print(f"First: {''.join(codes[0])}")
print(f"Last: {''.join(codes[-1])}")
C(4,2) = 6 combinations. 2^2 = 4 products.
Pairs: 6
Codes: 4
First: AA
Last: BB
Question 9
Hard
What is the output?
from functools import wraps

def debug(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        args_str = ", ".join(repr(a) for a in args)
        print(f"  {func.__name__}({args_str})")
        result = func(*args, **kwargs)
        print(f"  -> {result}")
        return result
    return wrapper

@debug
def factorial(n):
    if n <= 1:
        return 1
    return n * factorial(n - 1)

print(f"Result: {factorial(4)}")
The decorated factorial calls itself, so debug prints for every recursive call.
factorial(4)
factorial(3)
factorial(2)
factorial(1)
-> 1
-> 2
-> 6
-> 24
Result: 24
Question 10
Hard
What is the output?
class FibIterator:
    def __init__(self, limit):
        self.limit = limit
        self.a, self.b = 0, 1
    
    def __iter__(self):
        return self
    
    def __next__(self):
        if self.a > self.limit:
            raise StopIteration
        value = self.a
        self.a, self.b = self.b, self.a + self.b
        return value

print(list(FibIterator(20)))
print(sum(FibIterator(10)))
The iterator yields Fibonacci numbers up to the limit.
[0, 1, 1, 2, 3, 5, 8, 13]
20
Question 11
Hard
How do decorators with arguments (like @repeat(3)) differ from simple decorators (like @timer)?
Think about how many levels of function nesting each requires.
A simple decorator is a function that takes a function and returns a wrapper: def timer(func): def wrapper(...): ... return wrapper. A decorator with arguments needs an extra layer: def repeat(n): def decorator(func): def wrapper(...): ... return wrapper return decorator. The outer function (repeat(3)) returns the actual decorator. So @repeat(3) first calls repeat(3) which returns a decorator function, and then that decorator is applied to the function below.

Multiple Choice Questions

MCQ 1
What two methods must an iterator implement?
  • A. __init__ and __del__
  • B. __iter__ and __next__
  • C. __get__ and __set__
  • D. __call__ and __return__
Answer: B
B is correct. The iterator protocol requires __iter__() (returns the iterator) and __next__() (returns the next value or raises StopIteration).
MCQ 2
What does the @ symbol do before a function definition?
  • A. Makes the function private
  • B. Applies a decorator to the function
  • C. Creates a generator
  • D. Marks the function as deprecated
Answer: B
B is correct. @decorator above a function is syntactic sugar for func = decorator(func). It applies the decorator, which wraps or modifies the function.
MCQ 3
What exception is raised when an iterator has no more values?
  • A. IndexError
  • B. ValueError
  • C. StopIteration
  • D. EndOfIterator
Answer: C
C is correct. When an iterator's __next__() method has no more values, it raises StopIteration. A for loop catches this automatically to end the loop.
MCQ 4
What does iter([1, 2, 3]) return?
  • A. The list [1, 2, 3]
  • B. A list_iterator object
  • C. The number 1
  • D. A generator
Answer: B
B is correct. iter() called on a list returns a list_iterator object. This iterator has __next__() and produces values one at a time.
MCQ 5
What does a decorator function return?
  • A. None
  • B. The original function unchanged
  • C. A new function (wrapper) that replaces the original
  • D. A string representing the function
Answer: C
C is correct. A decorator takes a function as input and returns a new function (the wrapper) that typically calls the original function with added behavior. The wrapper replaces the original function.
MCQ 6
What does itertools.chain do?
  • A. Creates an infinite iterator
  • B. Concatenates multiple iterables into one
  • C. Repeats an iterable
  • D. Filters an iterable
Answer: B
B is correct. chain(iter1, iter2, ...) produces all elements from iter1, then all from iter2, etc. It concatenates iterables into a single sequence.
MCQ 7
What is functools.wraps used for?
  • A. Creating a new function
  • B. Wrapping a list in a tuple
  • C. Preserving the original function's metadata in a decorator wrapper
  • D. Converting a generator to a list
Answer: C
C is correct. @wraps(func) copies the original function's __name__, __doc__, and other metadata to the wrapper function. Without it, the decorated function appears to be named 'wrapper'.
MCQ 8
How are stacked decorators applied?
  • A. Top to bottom
  • B. Bottom to top
  • C. Alphabetically
  • D. Randomly
Answer: B
B is correct. @A @B def f means f = A(B(f)). The bottom decorator (B) wraps f first, then the top decorator (A) wraps the result. When called, A's wrapper runs first (outermost), then B's.
MCQ 9
What is the difference between permutations and combinations?
  • A. They are the same
  • B. Permutations consider order; combinations do not
  • C. Combinations consider order; permutations do not
  • D. Permutations work with numbers only; combinations work with strings
Answer: B
B is correct. Permutations treat (A,B) and (B,A) as different. Combinations treat them as the same. For n items taken r at a time, there are more permutations than combinations.
MCQ 10
Can a list be passed directly to next()?
  • A. Yes, next() works on any iterable
  • B. No, you must first call iter() to get an iterator
  • C. Yes, but only for non-empty lists
  • D. No, next() only works with generators
Answer: B
B is correct. next() requires an iterator (an object with __next__()). Lists do not have __next__(). You must call iter(my_list) first to get a list_iterator, then call next() on that.
MCQ 11
What does itertools.product(['A','B'], [1,2]) produce?
  • A. [('A', 'B'), (1, 2)]
  • B. [('A', 1), ('A', 2), ('B', 1), ('B', 2)]
  • C. [('A', 1), ('B', 2)]
  • D. [('A', 'B', 1, 2)]
Answer: B
B is correct. product creates the Cartesian product: every element from the first iterable paired with every element from the second. Result: [('A',1), ('A',2), ('B',1), ('B',2)].
MCQ 12
A class-based decorator must implement which method to be callable?
  • A. __init__
  • B. __call__
  • C. __next__
  • D. __decorator__
Answer: B
B is correct. A class-based decorator uses __call__ to make instances callable. When the decorated function is called, Python invokes instance.__call__(*args, **kwargs).
MCQ 13
What is the extra nesting level needed for in decorators with arguments?
  • A. To handle multiple return values
  • B. To receive the decorator's arguments before the function
  • C. To support async functions
  • D. To prevent memory leaks
Answer: B
B is correct. @repeat(3) first calls repeat(3), which returns the actual decorator function. That decorator then receives the function. Three levels: outer (args) -> middle (func) -> inner (wrapper).
MCQ 14
What does the @property decorator do?
  • A. Makes a variable immutable
  • B. Turns a method into an attribute that is computed on access
  • C. Makes a method static
  • D. Prevents a method from being overridden
Answer: B
B is correct. @property allows a method to be accessed like an attribute (without parentheses). The method is called automatically when the attribute is accessed. A companion @name.setter decorator can define assignment behavior.
MCQ 15
What is itertools.islice used for?
  • A. Slicing lists only
  • B. Slicing any iterator without creating intermediate lists
  • C. Creating infinite slices
  • D. Splitting an iterator into equal parts
Answer: B
B is correct. islice(iterable, start, stop, step) works like regular slicing but on any iterator, including generators and infinite iterators. It does not create intermediate lists, making it memory-efficient.
MCQ 16
What does itertools.count(5, 2) produce?
  • A. [5, 7, 9, 11, ...] (infinite)
  • B. [5, 2]
  • C. [5, 7] only
  • D. [2, 4, 6, 8, 10]
Answer: A
A is correct. count(5, 2) produces an infinite sequence starting at 5, incrementing by 2: 5, 7, 9, 11, 13, ... Use islice to take a finite number of values.
MCQ 17
If __iter__ returns a fresh iterator each time, what advantage does this provide?
  • A. The object can be iterated multiple times
  • B. The object uses less memory
  • C. The object is thread-safe
  • D. The object becomes immutable
Answer: A
A is correct. If __iter__ returns a new iterator each time (like a list does), the object can be iterated multiple times with for loops. If __iter__ returns self (like a generator does), the object can only be iterated once.
MCQ 18
What is the output of combinations('ABCD', 2) in terms of count?
  • A. 4
  • B. 6
  • C. 12
  • D. 16
Answer: B
B is correct. C(4,2) = 4!/(2! * 2!) = 6. The six combinations are: AB, AC, AD, BC, BD, CD. Combinations do not consider order, so AB and BA count as one.

Coding Challenges

Challenge 1: Custom Range Iterator

Easy
Create a class EvenNumbers that implements the iterator protocol and yields even numbers from start to stop (inclusive if even). Test it with a for loop, list(), and next().
Sample Input
(No input required)
Sample Output
For loop: 2 4 6 8 10 As list: [0, 2, 4, 6, 8] next: 4, 6, 8
Implement __iter__ and __next__. Raise StopIteration when done.
class EvenNumbers:
    def __init__(self, start, stop):
        self.stop = stop
        self.current = start if start % 2 == 0 else start + 1
    
    def __iter__(self):
        return self
    
    def __next__(self):
        if self.current > self.stop:
            raise StopIteration
        value = self.current
        self.current += 2
        return value

print("For loop:", end=" ")
for n in EvenNumbers(1, 10):
    print(n, end=" ")
print()

print(f"As list: {list(EvenNumbers(0, 9))}")

it = EvenNumbers(4, 9)
print(f"next: {next(it)}, {next(it)}, {next(it)}")

Challenge 2: Simple Logger Decorator

Easy
Write a decorator called logger that prints the function name, arguments, and return value every time the decorated function is called. Use functools.wraps to preserve metadata.
Sample Input
(No input required)
Sample Output
Calling add(3, 5) add returned 8 Result: 8 add.__name__: add
Use *args and **kwargs. Use functools.wraps.
from functools import wraps

def logger(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        args_str = ", ".join([repr(a) for a in args] + [f"{k}={v!r}" for k, v in kwargs.items()])
        print(f"Calling {func.__name__}({args_str})")
        result = func(*args, **kwargs)
        print(f"{func.__name__} returned {result}")
        return result
    return wrapper

@logger
def add(a, b):
    """Add two numbers."""
    return a + b

result = add(3, 5)
print(f"Result: {result}")
print(f"add.__name__: {add.__name__}")
print(f"add.__doc__: {add.__doc__}")

Challenge 3: itertools Practice Problems

Easy
Using itertools, solve these: (1) generate all 2-letter combinations from 'ABCD', (2) create all possible 3-digit binary numbers using product, (3) take the first 8 values from an infinite counter starting at 100 with step 5, (4) chain three lists into one.
Sample Input
(No input required)
Sample Output
2-letter combos: 6 total Binary 3-digit: 8 total, first: (0, 0, 0) Counter: [100, 105, 110, 115, 120, 125, 130, 135] Chained: [1, 2, 3, 4, 5, 6]
Use combinations, product, count with islice, and chain.
from itertools import combinations, product, count, islice, chain

# 1. All 2-letter combinations from ABCD
combos = list(combinations('ABCD', 2))
print(f"2-letter combos: {len(combos)} total")

# 2. All 3-digit binary numbers
binary = list(product([0, 1], repeat=3))
print(f"Binary 3-digit: {len(binary)} total, first: {binary[0]}")

# 3. First 8 from counter starting at 100, step 5
counter_vals = list(islice(count(100, 5), 8))
print(f"Counter: {counter_vals}")

# 4. Chain three lists
result = list(chain([1, 2], [3, 4], [5, 6]))
print(f"Chained: {result}")

Challenge 4: Memoize Decorator

Medium
Write a memoize decorator that caches function results based on arguments. Add a .cache attribute to inspect cached values and a .clear_cache() method to reset it. Test with a recursive fibonacci function.
Sample Input
(No input required)
Sample Output
fib(10) = 55 fib(20) = 6765 Cache size: 21 After clear: 0
Use a dictionary for the cache. Use functools.wraps. Attach cache and clear_cache to the wrapper.
from functools import wraps

def memoize(func):
    cache = {}
    
    @wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    
    wrapper.cache = cache
    wrapper.clear_cache = lambda: cache.clear()
    return wrapper

@memoize
def fib(n):
    if n < 2:
        return n
    return fib(n - 1) + fib(n - 2)

print(f"fib(10) = {fib(10)}")
print(f"fib(20) = {fib(20)}")
print(f"Cache size: {len(fib.cache)}")
fib.clear_cache()
print(f"After clear: {len(fib.cache)}")

Challenge 5: Deck of Cards Iterator

Medium
Create a Deck class that implements the iterator protocol to yield all 52 playing cards. Each card should be a tuple of (rank, suit). Support shuffling with a shuffle() method and iterating with for loops.
Sample Input
(No input required)
Sample Output
Total cards: 52 First 3: [('2', 'Hearts'), ('3', 'Hearts'), ('4', 'Hearts')] After shuffle (first 3): [('K', 'Diamonds'), ('7', 'Clubs'), ('3', 'Hearts')]
Use itertools.product to generate all cards. Implement __iter__ and __len__.
import random
from itertools import product

class Deck:
    RANKS = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']
    SUITS = ['Hearts', 'Diamonds', 'Clubs', 'Spades']
    
    def __init__(self):
        self.cards = [(r, s) for r, s in product(self.RANKS, self.SUITS)]
    
    def __iter__(self):
        return iter(self.cards)
    
    def __len__(self):
        return len(self.cards)
    
    def shuffle(self):
        random.shuffle(self.cards)
        return self

deck = Deck()
print(f"Total cards: {len(deck)}")

first_3 = [card for _, card in zip(range(3), deck)]
print(f"First 3: {first_3}")

random.seed(42)
deck.shuffle()
first_3_shuffled = [card for _, card in zip(range(3), deck)]
print(f"After shuffle (first 3): {first_3_shuffled}")

Challenge 6: Access Control Decorator

Medium
Write a decorator requires_role(role) that checks if the current user (passed as the first argument with a .role attribute) has the required role. If not, raise a PermissionError. Test with admin and viewer roles.
Sample Input
(No input required)
Sample Output
Admin panel: Welcome, Aarav PermissionError: Priya requires 'admin' role, has 'viewer'
The decorator should take a role argument. Use functools.wraps.
from functools import wraps

def requires_role(role):
    def decorator(func):
        @wraps(func)
        def wrapper(user, *args, **kwargs):
            if user.role != role:
                raise PermissionError(
                    f"{user.name} requires '{role}' role, has '{user.role}'"
                )
            return func(user, *args, **kwargs)
        return wrapper
    return decorator

class User:
    def __init__(self, name, role):
        self.name = name
        self.role = role

@requires_role('admin')
def admin_panel(user):
    return f"Welcome, {user.name}"

@requires_role('admin')
def delete_user(user, target):
    return f"{user.name} deleted {target}"

admin = User("Aarav", "admin")
viewer = User("Priya", "viewer")

print(f"Admin panel: {admin_panel(admin)}")

try:
    admin_panel(viewer)
except PermissionError as e:
    print(f"PermissionError: {e}")

Challenge 7: Infinite Iterator Toolkit

Hard
Create custom iterators (without using itertools): (1) InfiniteCounter(start, step) that counts forever, (2) Cycle(iterable) that repeats endlessly, (3) TakeWhile(predicate, iterator) that yields while the predicate is True, (4) Chain(*iterables) that concatenates iterables. Implement all using the iterator protocol.
Sample Input
(No input required)
Sample Output
Counter: [5, 8, 11, 14, 17] Cycle: ['a', 'b', 'c', 'a', 'b'] TakeWhile: [1, 3, 5] Chain: [1, 2, 3, 4, 5, 6]
Implement __iter__ and __next__ for each class. Do not use itertools.
class InfiniteCounter:
    def __init__(self, start=0, step=1):
        self.current = start
        self.step = step
    
    def __iter__(self):
        return self
    
    def __next__(self):
        value = self.current
        self.current += self.step
        return value

class Cycle:
    def __init__(self, iterable):
        self.items = list(iterable)
        self.index = 0
    
    def __iter__(self):
        return self
    
    def __next__(self):
        if not self.items:
            raise StopIteration
        value = self.items[self.index]
        self.index = (self.index + 1) % len(self.items)
        return value

class TakeWhile:
    def __init__(self, predicate, iterator):
        self.predicate = predicate
        self.iterator = iter(iterator)
        self.done = False
    
    def __iter__(self):
        return self
    
    def __next__(self):
        if self.done:
            raise StopIteration
        value = next(self.iterator)
        if not self.predicate(value):
            self.done = True
            raise StopIteration
        return value

class Chain:
    def __init__(self, *iterables):
        self.iterables = iter(iterables)
        self.current = iter([])
    
    def __iter__(self):
        return self
    
    def __next__(self):
        while True:
            try:
                return next(self.current)
            except StopIteration:
                self.current = iter(next(self.iterables))

# Test
counter = InfiniteCounter(5, 3)
print(f"Counter: {[next(counter) for _ in range(5)]}")

cycler = Cycle(['a', 'b', 'c'])
print(f"Cycle: {[next(cycler) for _ in range(5)]}")

tw = TakeWhile(lambda x: x < 7, [1, 3, 5, 7, 9, 2])
print(f"TakeWhile: {list(tw)}")

ch = Chain([1, 2], [3, 4], [5, 6])
print(f"Chain: {list(ch)}")

Challenge 8: Decorator Collection: Timer, Retry, and Type Check

Hard
Write three advanced decorators: (1) timer that measures and prints execution time, (2) retry(max_attempts, exceptions) that retries on specified exceptions, (3) type_check(**expected_types) that validates argument types before calling the function. All should use functools.wraps.
Sample Input
(No input required)
Sample Output
slow_func took 0.1001s Attempt 1 failed. Retrying... Attempt 2 succeeded. add(3, 5) = 8 TypeError: Expected b to be <class 'int'>, got <class 'str'>
All decorators must use functools.wraps. retry should re-raise on final failure. type_check should inspect function parameter names.
import time
from functools import wraps
import inspect

def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__} took {elapsed:.4f}s")
        return result
    return wrapper

def retry(max_attempts=3, exceptions=(Exception,)):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    result = func(*args, **kwargs)
                    print(f"Attempt {attempt} succeeded.")
                    return result
                except exceptions as e:
                    print(f"Attempt {attempt} failed. Retrying...")
                    if attempt == max_attempts:
                        raise
        return wrapper
    return decorator

def type_check(**expected_types):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            sig = inspect.signature(func)
            params = list(sig.parameters.keys())
            for i, arg in enumerate(args):
                if i < len(params) and params[i] in expected_types:
                    if not isinstance(arg, expected_types[params[i]]):
                        raise TypeError(
                            f"Expected {params[i]} to be {expected_types[params[i]]}, "
                            f"got {type(arg)}"
                        )
            return func(*args, **kwargs)
        return wrapper
    return decorator

@timer
def slow_func():
    time.sleep(0.1)
    return "done"

slow_func()

call_count = 0
@retry(max_attempts=3, exceptions=(ValueError,))
def flaky():
    global call_count
    call_count += 1
    if call_count < 2:
        raise ValueError("random failure")
    return "success"

flaky()

@type_check(a=int, b=int)
def add(a, b):
    return a + b

print(f"add(3, 5) = {add(3, 5)}")
try:
    add(3, "5")
except TypeError as e:
    print(f"TypeError: {e}")

Need to Review the Concepts?

Go back to the detailed notes for this chapter.

Read Chapter Notes

Want to learn Python with a live mentor?

Explore our Python course