def foo():
    return 1
foo()

"""
Explanation: CSX91: Python Tutorial
1. Functions

Fucntions in Python are created using the keyword def
It can return values with return
Let's create a simple function:
End of explanation
"""


aString = 'Global var'
def foo():
    a = 'Local var'
    print locals()

foo()
print globals()

"""
Explanation: Q. What happens if there is no return?
2. Scope

In python functions have their own scope (namespace).
Python first looks at the function's namespace first before looking at the global namespace.
Let's use locals() and globals() to see what happens:
End of explanation
"""


def foo():
    x = 10

foo()
print x

"""
Explanation: 2.1 Variable lifetime
Variables within functions exist only withing their namespaces. Once the function stops, all the variables inside it gets destroyed. For instance, the following won't work.
End of explanation
"""


aString = 'Global var'
def foo():
    print aString

foo()

"""
Explanation: 3. Variable Resolution

Python first looks at the function's namespace first before looking at the global namespace.
End of explanation
"""


aString = 'Global var'
def foo():
    aString = 'Local var'
    print aString

foo()

"""
Explanation: If you try and reassign a global variable inside a function, like so:
End of explanation
"""


aString = 'Global var'
def foo():
    global aString # <------ Declared here
    aString = 'Local var'
    print aString

def bar():
    print aString

foo()
bar()

"""
Explanation: Q. What would be the value of aString now? For instance, if I did this:

As we can see, global variables can be accessed (even changed if they are mutable data types) but not (by default) assigned to.
Global variables are very dangerous. So, python wants you to be sure of what you're doing.
If you MUST reassign it. Declare it as global. Like so:
End of explanation
"""


def foo(x):
    print locals()

foo(1)

"""
Explanation: 4. Function Arguments: args and kwargs

Python allows us to pass function arguments (duh..) 
There arguments are local to the function. For instance:
End of explanation
"""


"Args"
def foo(x,y):
    print x+y

"kwargs"
def bar(x=5, y=8):
    print x-y

"Both"
def foobar(x,y=100):
    print x*y

"Calling with args"
foo(5,12)

"Calling with kwargs"
bar()

"Calling both"
foobar(10)

"""
Explanation: Arguments in functions can be classified as:
Args
kwargs (keyword args)


When calling a function, args are mandatory. kwargs are optional.
End of explanation
"""


"Args"
def foo(x,y):
    print x+y

"kwargs"
def bar(x=5, y=8):
    print x-y

"Both"
def foobar(x,y=100):
    print x*y

"kwargs"
bar(5,8) # kwargs as args (default: x=5, y=8)
bar(5,y=8) # x=5, y=8
"Change the order of kwargs if you want"
bar(y=8, x=5)

"args as kwargs will also work"
foo(x=5, y=12)

"""
Explanation: Other ways of calling:

All the following are legit:
End of explanation
"""


"Args"
def foo(x,y):
    print x+y

"kwargs"
def bar(x=5, y=8):
    print x-y

"Both"
def foobar(x,y=100):
    print x*y

bar(x=9, 7) #1
foo(x=5, 6) #2


"""
Explanation: Q. will these two work?
End of explanation
"""


def outer():
    x=1
    def inner():
        print x
    inner()

outer()

"""
Explanation: Never call args after kwargs

5. Nesting functions

You can nest functions.
Class nesting is somewhat uncommon, but can be done.
End of explanation
"""


def outer():
    x = 1
    def inner(): 
        x = 2
        print 'Inner x=%d'%(x)
    inner()
    return x

print 'Outer x=%d'%outer()

"""
Explanation: All the namespace conventions apply here.

What would happen if I changed x inside inner()?
End of explanation
"""


x = 4
def outer(): 
    global x
    x = 1
    def inner(): 
        global x
        x = 2
        print 'Inner x=%d'%(x)
    inner()
    return x

print 'Outer x=%d'%outer()
print 'Global x=%d'%x

"""
Explanation: What about global variables?
End of explanation
"""


class foo():
    def __init__(i, arg1): # self can br replaced by anything.
        i.arg1 = arg1
    def bar(i, arg2): # Always use self as the first argument
        print i.arg1, arg2

FOO = foo(7)

FOO.bar(5)

print FOO.arg1

"""
Explanation: Declare global every time the global x needs changing

6. Classes

Define classes with the class keyword
Here's a simple class
End of explanation
"""


class foo():
    def __init__(i, num):
        i.num = num

d = foo(2)
d()

"""
Explanation: All arg and kwarg conventions apply here

6.1 Overriding class methods
Lets try:
End of explanation
"""


class foo():
    def __init__(i, num):
        i.num = num
    def __call__(i):
        return i.num
d = foo(2)
d()

"""
Explanation: We know the __call__ raises an exception. Python lets you redefine it:
End of explanation
"""


class foo():
    def __init__(i, num):
        i.num = num
FOO = foo(5)
FOO += 1

"""
Explanation: There are many such redefinitions permitted by python. See Python Docs

6.2 Emulating numeric types

A very useful feature in python is the ability to emulate numeric types.

Would this work?
End of explanation
"""


class foo():
    def __init__(i, num):
        i.num = num
    def __add__(i, new):
        i.num += new
        return i
    def __sub__(i, new):
        i.num -= new
        return i

FOO = foo(5)
FOO += 1
print FOO.num
FOO -= 4
print FOO.num

"""
Explanation: Let's rewrite this:
End of explanation
"""


class foo():
    "Me is foo"
    def __init__(i, num):
        i.num = num
    def __add__(i, new):
        i.num += new
        return i
    def __sub__(i, new):
        i.num -= new
        return i
    def __repr__(i):
        return i.__doc__
    def __getitem__(i, num):
        print "Nothing @ %d"%(num)

FOO = foo(4)
FOO[2]

"""
Explanation: Aside: __repr__, __call__,__getitem__,...  are all awesome.
End of explanation
"""


issubclass(int, object)

"""
Explanation: 7. Functions and Class are Objects

Functions and objects are like anything else in python.
All objects inherit from a base class in python.
For instance,
End of explanation
"""


a = 9
dir(a)

"""
Explanation: It follows that the variable a here is a class.
End of explanation
"""


from pdb import set_trace # pdb is quite useful

def add(x,y): return x+y
def sub(x,y): return x-y
def foo(x,y,func=add): 
    set_trace()
    return func(x,y)

foo(7,4,sub)

"""
Explanation: This means: 
Functions and Classes can be passed as arguments.
Functions can return other functions/classes.
End of explanation
"""


def foo():
    x=1
    
foo()
print x

"""
Explanation: 8. Closures
Remember this example?
End of explanation
"""


def foo():
    x='Outer String'
    def bar():
        print x
    return bar

test = foo()

test()

"""
Explanation: Obviously, this fails. Why? As per variable lifetime rules (see 2.1), foo() has ceased execution, x is destroyed.
So how about this?
End of explanation
"""


def foo(x,y): return x**y

bar = lambda x,y: x**y # <--- Notice no return statements

print foo(4,2)
print bar(4,2)

"""
Explanation: This works. But it shouldn't, because x is local to foo(), when foo() has ceased execution, x must be destroyed. Right?
Turns out, Python supports a feature called function closure. This enables nested inner functions to keep track of their namespaces.
8.1 Aside: lambda functions and sorted

Anonymous functions in python can be defined using the lambda keyword.
The following two are the same:
End of explanation
"""


foo = lambda x: lambda y: x+y
print foo(3)(5)

"""
Explanation: Nested lambda is permitted (idk why you'd use them, still, worth a mention)
End of explanation
"""


student_tuples = [ #(Name, height(cms), weight(kg))
        ('john', 180, 85),
        ('doe', 177, 99),
        ('jane', 169, 69),
]

# Sort based on height
print 'Weight: ', sorted(student_tuples,  key=lambda stud: stud[1])

# Sort based on Name
print 'Name: ', sorted(student_tuples, key=lambda stud: stud[0])

# Sort based on BMI
print 'BMI: ', sorted(student_tuples, key=lambda stud: stud[2]*100/stud[1])



"""
Explanation: 8.1.1 Sorted

Python's sorted function can sort based on a key argument, key is a lambda function that deterimes how the data is sorted.
End of explanation
"""


def outer(func):
    def inner(*args):
        "Inner"
        print 'Decorating...'
        ret = func()
        ret += 1
        return ret
    return inner

def foo():
    "I'm foo"
    return 1

print foo()

decorated_foo = outer(foo)

print decorated_foo()

"""
Explanation: 9. Decorators!

Decorators are callables that take a function as argument, and return a replacement function (with additional functionalities)
End of explanation
"""


def outer(func):
    def inner(*args):
        "Inner"
        print 'Decorating...'
        ret = func()
        ret += 1
        return ret
    print inner.__doc__, inner
    return inner

def foo():
    "I'm foo"
    return 1

print foo.__name__, foo

decorated_foo = outer(foo)

print decorated_foo.__name__, decorated_foo

"""
Explanation: Lets look at memory locations of the functions.
End of explanation
"""


def outer(func):
    def inner():
        "Inner"
        print 'Decorating...'
        ret = func()
        ret += 1
        return ret
    return inner

def foo():
    "I'm foo"
    return 1

print foo()
foo = outer(foo)

print foo()

"""
Explanation: A common practice is to replace the original function with the decorated function
End of explanation
"""


def outer(func):
    def inner():
        "Inner"
        print 'Decorating...'
        ret = func()
        ret += 1
        return ret
    return inner

@outer
def foo():
    "I'm foo"
    return 1

print foo()


"""
Explanation: Python uses @ to represent foo = outer(foo). The above code can be retwritten as follows:
End of explanation
"""


import time
from pdb import set_trace

def logger(func):
    def inner(*args, **kwargs):
        print "Arguments were: %s, %s"%(args, kwargs)
        return func(*args, **kwargs)
    return inner

def timer(func):
    def inner(*args, **kwargs):
        tb=time.time()
        result = func(*args, **kwargs)
        ta=time.time()
        print "Time taken: %f sec"%(ta-tb)
        return result
    return inner

@logger
@timer
def foo(a=5, b=2):
    return a+b

@logger
@timer
def bar(a=10, b=1):
    time.sleep(0.1)
    return a-b

if __name__=='__main__': ## <----- Note
    foo(2,3)
    bar(5,7)

"""
Explanation: 9.1 Logging and timing a function


Decorators can be classes, they can take input arguments/keyword args.


Lets build a decorator that logs and times another function
End of explanation
"""


