# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: light
#       format_version: '1.5'
#       jupytext_version: 1.14.4
#   kernelspec:
#     display_name: Python 3
#     language: python
#     name: python3
# ---

# Youtube videos: 
#     * [https://www.youtube.com/watch?v=kr0mpwqttM0&t=34s]
#     * [https://www.youtube.com/watch?v=swU3c34d2NQ]
#     * [https://www.youtube.com/watch?v=FsAPt_9Bf3U&t=35s]
#     * [https://www.youtube.com/watch?v=KlBPCzcQNU8]

# #### First class functions
#
# We can treat functions just like anyother object or variable.

# +
def square(x):
    return x*x

def cube(x):
    return x*x*x

# This is custom-built map function which is going to behave like in-bulit map function.
def my_map(func, arg_list):
    result = []
    for i in arg_list:
        result.append(func(i))
    return result

squares = my_map(square, [1,2,3,4])

print(squares)

cubes = my_map(cube, [1,2,3,4])

print(cubes)


# -

# #### Closures
#
# A closure closes over free variables from their environment.

# +
def html_tag(tag):
    def wrap_text(msg):
        print('<{0}>{1}<{0}>'.format(tag, msg))
    return wrap_text

print_h1 = html_tag('h1')
print_h1('Test Headline')
print_h1('Another Headline')

print_p = html_tag('p')
print_p('Test Paragraph!')


# -

# #### Decorators
#
# Decorators are a way to dynamically alter the functionality of your functions. So for example, if you wanted to 
# log information when a function is run, you could use a decorator to add this functionality without modifying the 
# source code of your original function.

# +
def decorator_function(original_function):
    def wrapper_function():
        print("wrapper executed this before {}".format(original_function.__name__))
        return original_function()
    return wrapper_function

def display():
    print("display function ran!")

decorated_display = decorator_function(display)
decorated_display()


# +
# The above code is functionally the same as below:

def decorator_function(original_function):
    def wrapper_function():
        print("wrapper executed this before {}".format(original_function.__name__))
        return original_function()
    return wrapper_function

@decorator_function
def display():
    print("display function ran!")

display()


# +
# Lets make our decorator function to work with functions with different number of arguments
# For this we use, *args (arguments) and **kwargs (keyword arguments). 
# args and kwargs are convention, you can use any other name you want like *myargs, **yourkeywordargs

def decorator_function(original_function):
    def wrapper_function(*args, **kwargs):
        print("wrapper executed this before {}".format(original_function.__name__))
        return original_function(*args, **kwargs)
    return wrapper_function

@decorator_function
def display():
    print("display function ran!")

@decorator_function
def display_info(name, age):
    print('display_info ran with arguments ({}, {})'.format(name, age))

display()
display_info('John', 25)


# +
# Now let's use a class as a decorator instead of a function

class decorator_class(object):
    def __init__(self, original_function):
        self.original_function = original_function
    
    def __call__(self, *args, **kwargs): # This method is going to behave just like our wrapper function behaved
        print('call method executed this before {}'.format(self.original_function.__name__))
        return self.original_function(*args, **kwargs)

@decorator_class
def display():
    print("display function ran!")

@decorator_class
def display_info(name, age):
    print('display_info ran with arguments ({}, {})'.format(name, age))

display()
display_info('John', 25)


# -

# #### Some practical applications of decorators

# +
#Let's say we want to keep track of how many times a specific function was run and what argument were passed to that function
def my_logger(orig_func):
    import logging
    logging.basicConfig(filename='{}.log'.format(orig_func.__name__), level=logging.INFO) #Generates a log file in current directory with the name of original funcion
    
    def wrapper(*args, **kwargs):
        logging.info(
            'Ran with args: {}, and kwargs: {}'.format(args, kwargs))
        return orig_func(*args, **kwargs)
    
    return wrapper

@my_logger
def display_info(name, age):
    print('display_info ran with arguments ({}, {})'.format(name, age))

display_info('John', 25) 
# Note: 
# Since display_info is decorated with my_logger, the above call is equivalent to:
# decorated_display = my_logger(display_info)
# decorated_display('John', 25)

# +
def my_timer(orig_func):
    import time
    
    def wrapper(*args, **kwargs):
        t1 = time.time()
        result = orig_func(*args, **kwargs)
        t2 = time.time() - t1
        print('{} ran in: {} sec'.format(orig_func.__name__, t2))
        return result
    
    return wrapper

@my_timer
def display_info(name, age):
    print('display_info ran with arguments ({}, {})'.format(name, age))

display_info('John', 25)
# Note: 
# Since display_info is decorated with my_timer, the above call is equivalent to:
# decorated_display = my_timer(display_info)
# decorated_display('John', 25)
# (or simply put)
# my_timer(display_info('John', 25))
# -

# #### Chaining of Decorators

# +
@my_timer
@my_logger
def display_info(name, age):
    print('display_info ran with arguments ({}, {})'.format(name, age))

display_info('John', 25) # This is equivalent to my_timer(my_logger(display_info('John', 25)))
# The above code will give us some unexpected results.
# Instead of printing "display_info ran in: ---- sec" it prints "wrapper ran in: ---- sec"
# -

# Let's see if switching the order of decorators helps

# +
@my_logger
@my_timer
def display_info(name, age):
    print('display_info ran with arguments ({}, {})'.format(name, age))

display_info('John', 25) # This is equivalent to my_logger(my_timer(display_info('John', 25)))
# Now this would create wrapper.log instead of display_info.log like we expected.

# +
# To understand why wrapper.log is generated instead of display_info.log let's look at the following code.
def display_info(name, age):
    print('display_info ran with arguments ({}, {})'.format(name, age))

display_info = my_timer(display_info('John',25))
print(display_info.__name__)

# +
# So how do we solve this problem
# The answer is by using the wraps decorator
# For this we need to import wraps from functools module
from functools import wraps

def my_logger(orig_func):
    import logging
    logging.basicConfig(filename='{}.log'.format(orig_func.__name__), level=logging.INFO) #Generates a log file in current directory with the name of original funcion
    @wraps(orig_func)
    def wrapper(*args, **kwargs):
        logging.info(
            'Ran with args: {}, and kwargs: {}'.format(args, kwargs))
        return orig_func(*args, **kwargs)
    return wrapper

def my_timer(orig_func):
    import time
    @wraps(orig_func)
    def wrapper(*args, **kwargs):
        t1 = time.time()
        result = orig_func(*args, **kwargs)
        t2 = time.time() - t1
        print('{} ran in: {} sec'.format(orig_func.__name__, t2))
        return result
    return wrapper

@my_logger
@my_timer
def display_info(name, age):
    print('display_info ran with arguments ({}, {})'.format(name, age))

display_info('Hank', 22)


# -

# #### Decorators with Arguments

# +
def prefix_decorator(prefix):
    def decorator_function(original_function):
        def wrapper_function(*args, **kwargs):
            print(prefix, "Executed before {}".format(original_function.__name__))
            result = original_function(*args, **kwargs)
            print(prefix, "Executed after {}".format(original_function.__name__), '\n')
            return result
        return wrapper_function
    return decorator_function

@prefix_decorator('LOG:')
def display_info(name, age):
    print('display_info ran with arguments ({}, {})'.format(name, age))

display_info('John', 25)

StarcoderdataJupyterScriptsDedupFiltered