# Example - 1 
# Function Decorators
def start_and_end_1(func):
    
    def wrapper():
        print("Start")
        func()
        print("End")
    return wrapper
    

def print_name():
    print("Alex")

print_name = start_and_end_1(print_name)
print_name()

# Output
Start
Alex
End

# ----------------------------------------------------------------------------------

# Example - 2 
# Function Arguments
import functools

def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print("Start")
        result = func(*args, **kwargs)
        print(result)
        print("End")
        return result
    return wrapper

@my_decorator
def add5(x):
    return x + 5

add5(10)

# Ouput
Start
15
End

# ----------------------------------------------------------------------------------

# Example - 3
# Return values and function identity
import functools
def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print("Do something before")
        result = func(*args, **kwargs)
        print(result)
        print("Do something after")
        return result
    return wrapper

@my_decorator
def add_numbers(n1, n2):
    return n1, n2

add_numbers(1, 2)
print('--------------------------------')

#Output
Do something before
(1, 2)
Do something after

# ----------------------------------------------------------------------------------

# Example - 4
# Decorator function arguments
import functools
def repeat(num_times):
    def decorator_repeat(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for _ in range(num_times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator_repeat

@repeat(num_times=6)
def greet(name):
    print(f"Hello {name}")
    
greet('Abhi')
print('--------------------------------')

#Output
Hello Abhi
Hello Abhi
Hello Abhi
Hello Abhi
Hello Abhi
Hello Abhi

# ----------------------------------------------------------------------------------

# Example - 5
# Nested Decorators
import functools
def start_end_decorator_2(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        print("Start")
        result = func(*args, **kwargs)
        print("End")
        return result
    return wrapper

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

@debug
@start_end_decorator_2
def say_hello(name):
    greeting = f'Hello {name}'
    print(greeting)
    return greeting

say_hello("Alex")
print('--------------------------------')

#Output
Calling say_hello('Alex')
Start
Hello Alex
End
'say_hello' returned 'Hello Alex'

# ----------------------------------------------------------------------------------

# Example - 6
# Class decorators

class CountCalls:
    def __init__(self, func):
        self.func = func
        self.num_calls = 0
    def __call__(self, *args, **kwargs):
        self.num_calls += 1
        print(f"Call {self.num_calls} of {self.func.__name__!r}")
        return self.func(*args, **kwargs)

@CountCalls
def say_hello():
    print("Hello")
    
say_hello()
say_hello()
say_hello()

#Output
Call 1 of 'say_hello'
Hello
Call 2 of 'say_hello'
Hello
Call 3 of 'say_hello'
Hello