def func(n):
    return n + 1

x = func(1)
print(x)
y = func(2)
print(y)

"""
Explanation: Decorators
Decorators can be thought of as functions which modify the functionality of another function. This is also called metaprogramming as one part of the program tries to modify another part of program at compile time. It can also be used to add functionality to the existing code.
There are 2 types of decorators:

Function Decorators
Class Decorators

To understand decorators better lets start with some important aspects of functions. First you have to know or remember that function names are references to functions and that we can assign multiple names to the same function:
End of explanation
"""


del y
print(x)

print(y)

"""
Explanation: Here x and y refers to the same function object.
The next important fact is that we can delete either "succ" or "successor" without deleting the function itself.
End of explanation
"""


def f():
    
    def g():
        print("Hi, it's me 'g'")
        print("Thanks for calling me")
        
    print("This is the function 'f'")
    print("I am calling 'g' now:")
    g()

    
f()

def temperature(t):
    def celsiusToFarhenheit(x):
        return 9 * x / 5 + 32
    result = "It's " + str(celsiusToFarhenheit(t)) + " degrees"
    return result

temperature(10)

"""
Explanation: Functions within functions
End of explanation
"""


def inc(x):
    return x + 1
def dec(x):
    return x - 1
def operate(func,x):
    result = func(x)
    return result

operate(inc,1)

operate(dec,2)

"""
Explanation: Functions as Parameters
Functions can be passed as arguments to another function. Such functions that take other functions as arguments are called Higher Order Functions.
End of explanation
"""


def f(x):
    def g(y):
        return y + x + 3 
    return g

nf1 = f(1)
nf2 = f(3)

print(nf1(1))
print(nf2(1))

def make_pretty(func):
    def inner():
        print("I got decorated")
        func()
    return inner

def ordinary():
    print("I am ordinary")

ordinary()

pretty = make_pretty(ordinary)
pretty()

"""
Explanation: Functions returning functions
The output of a function is also a reference to an object.
End of explanation
"""


@make_pretty
def ordinary():
    print("I am ordinary")
    
ordinary()

"""
Explanation: In the example shown above the function make_pretty() is a decorator. The function ordinary() got decorated and the returned function was given the name pretty.
We can see that the decorator function added some new functionality to the original function. This is similar to packing a gift. The decorator acts as a wrapper. The nature of the object that got decorated does not alter. But now, it looks pretty.
Generally we decorate a function and reassign it as 
python
ordinary = make_pretty(ordinary)
This is a common construct and for this reason, Python has a syntax to simplify this. We can use the @ symbol along with the name of the decorator function and place it above the definition of the function to be decorated. For example,
python
@make_pretty
def ordinary():
    print("I am ordinary")
is equivalent to
python
def ordinary():
    print('I am ordinary')
ordinary = make_pretty(ordinary)
This is just a syntactic sugar to implement decorators.
End of explanation
"""


def divide(a,b):
    return a/b

divide(2,5)
divide(2,0)

"""
Explanation: Decorating Functions with Parameters
The above decorator was simple and it only worked with functions that did not have any parameters. What if we had functions that took in parameters like below?
python
def divide(a,b):
    return a/b
The function has 2 parameters a and b. We know, it will give error if we pass in b as 0.
End of explanation
"""


def smart_divide(func):
    def inner(a,b):
        if b == 0:
            print('Cannot divide by zero')
            return
        return func(a,b)
    return inner

@smart_divide
def divide(a,b):
    return a / b

divide(5,2)

divide(5,0)

"""
Explanation: To avoid this error lets make a decorator to check for this case.
End of explanation
"""


def works_for_all(func):
    def inner(*args, **kwargs):
        print('I can decorate any function...')
        return func(*args, **kwargs)
    return inner

"""
Explanation: If you observered closely the number of parameters of the nested inner() function inside the decorator is same as the parameters of functions it decorates. Taking this into account, now we can make general decorators that work with any number of parameter.
This magic is done as function(*args, **kwargs). In this way, args will be the tuple of positional arguments and kwargs will be the dictionary of keyword arguments. An example of such decorator will be.
End of explanation
"""


def star(func):
    def inner(*args, **kwargs):
        print('*'* 30)
        func(*args, **kwargs)
        print('*'* 30)
    return inner

def percent(func):
    def inner(*args, **kwargs):
        print('%'*30)
        func(*args,**kwargs)
        print('%'*30)
    return inner
    
@star
@percent
def printer(msg):
    print(msg)
    
printer("Hello")

"""
Explanation: Chaining Decorators in Python
Multiple decorators can be chained in python.
End of explanation
"""


def printer(msg):
    print(msg)
    
printer = star(percent(printer))
printer('Hello')

"""
Explanation: This is equivalent to:
End of explanation
"""


@percent
@star
def printer(msg):
    print(msg)
    
printer('Hello')

"""
Explanation: Please note the order in which we chain the decorators also matter.
End of explanation
"""


