import time

t = time.localtime()
t.tm_mday

"""
Explanation: Retrospective

Interpreter
Basic Calculations
Data Structures
Control flow
Functions
Documentation

Packages
Packages are folders containing Modules. If you are lucky, these modules also work together in a way. The folder needs to contain an __init__.py file that will tell python what to do with the package.
End of explanation
"""


class Dog(object):
    color = "yellow"
    def __init__(self, name):
            self.name = name
            
Dog

"""
Explanation: Pypi
Python has a very active development community and the Python package index contains a myriad of packages for every need.
These can be install by using pip which is installed with newer python versions:
pip install numpy
Object Orientation

Programming Paradigms

Object orientation in Python

everything is a object (even functions)

You can define your own objects by using the class keyword:
End of explanation
"""


class Dog(object):
    color = "yellow"
    def __init__(self, name, size):
            self.name = name
            self.size = size
    def bark(self):
        print 'whoof, whoof!'

b = Dog("blitzer", 100)
b.bark()

"""
Explanation: Instances
Classes can be used to create objects with certain attributes and behavior:
End of explanation
"""


class Dachshund(Dog):
    color = "brownish"
    def __init__(self, name):
        super(Dachshund, self).__init__(name, 20)
        
d = Dachshund('bello')
d.bark()

"""
Explanation: Inheritance
Classes can inherit traits (and even skills) from other classes:
End of explanation
"""


class Pekingese(Dog):
    color = "brownish"
    def __init__(self, name):
        super(Pekingese, self).__init__(name, 15)
        
    def bark(self):
        print 'whif.'
        
d = Pekingese('tiny')
d.bark()

"""
Explanation: Shadowing
Classes can alter functionality of their parents:
End of explanation
"""


class Pekingese(Dog):
    color = "brownish"
    def __init__(self, name):
        super(Pekingese, self).__init__(name, 15)

    def __len__(self):
        return self.size
        
    def bark(self):
        print 'whif.'

p = Pekingese("wang")
len(p)

"""
Explanation: Special methods
End of explanation
"""


class Dog(object):
    color = "yellow"
    def __init__(self, name, size):
            self.name = name
            self.size = size
            
    def __add__(self, dog):
        if not isinstance(dog, Dog):
            raise TypeError
        return Dog(self.name + " " + dog.name, (self.size + dog.size)/2)
    
    def bark(self):
        print 'whoof, whoof!'

b = Dog("blitzer", 100)
dog = b + p
dog.bark()
print dog.name
print dog.size

"""
Explanation: Mating
End of explanation
"""


def outer_function():
    print "out here"
    def inner_1():
        return "in room 1"
        
    def inner_2():
        return "in room 2"
        
    print inner_1()
    print inner_2()
        
outer_function()

"""
Explanation: Why do I need this?
OOP facilitates some desirable features in software projects:

code organization
encapsulation
DRY principle
modularization

How we will use it
Our project will make extensive use of object orientation and inheritance.


classes use each other to run the model
each process knows which species to update, makes modularization possible
simulation class manages everything, registers processes
states/processes inherit from base class

Decorators
Some helpful syntax
Nested Functions
Functions can contain other functions:
End of explanation
"""


def greetings(some_function):
    def wrapper(args):
        print "Hello!"
        some_function(args)
        print "Goodbye!"
    return wrapper

def say_something(sentence):
    print "Say: {0}".format(sentence)

polite_talker = greetings(say_something)

polite_talker("Good to see you")

"""
Explanation: try to call inner_1

Wrappers
Decorators are functions wrapped around functions:
End of explanation
"""


@greetings
def shout(sentence):
    print sentence.upper()

shout("Am I polite, or what?!")

"""
Explanation: Decorator Syntax
End of explanation
"""


class Molecule(object):
    def __init__(self, mass):
        self.mass = mass
        
    @property
    def mass(self):
        return self.__mass
    
    @mass.setter
    def mass(self, value):
        if not isinstance(value, int):
            raise TypeError("Mass must be Integer.")
        self.__mass = value

m = Molecule(1)

m.mass = "20"

"""
Explanation: How will we use it?
End of explanation
"""


A = [5]
B = A
print B

"""
Explanation: getter and setter methods

Further Tricks
Some python specialties you should know about
Copy
By Reference/Value
As you might know you can assign values of one variable to an other like:
End of explanation
"""


A[0] = 1
print B

"""
Explanation: But if you change the value of variable A as it is done blow, variable B changes, too.
End of explanation
"""


A = 1
B = A
A += 1
print B

"""
Explanation: The problem is that variables are just names referring to an object. Assigning the object of a variable to an other does not create a copy of this object. It creates a new variable B which refers to the same object A refers to. Hence, there is only one object but two variables are referring to this one. This holds only for mutable objects like lists, sets, dictionaries and so on.
There are also immutable objects like inits, floats, strings, tuple and so on.
End of explanation
"""


A = [[2,3,1]]
B = A
A[0].append(4)
print B
A[0].sort()
print B

"""
Explanation: The difference in this example is, that we are not incrementing the value of A, but instead we are creating a new object and assigning this new object to our variable A. After this we have two objects the Integer 1 and the Integer 2 and two variables B and A referring to the objects, respectively.
Some operations change the object instead creating a copy of the object assigned to a variable.
End of explanation
"""


A = [[2,3,1]]
B = A
A = A + [4]
print B
print sorted(A[0])
print B

"""
Explanation: However, some operations directly create new objects.
End of explanation
"""


A = ((1,2,3))  # assigning a immutable tuple object to A
B = A
A += (3,4,5)
print "A: ", A
print "B: ", B

"""
Explanation: So we end up with two objects and two variables referring to these objects, respectively.
But we have to pay attention when we use operations like +=. The behaviour of this kind of operations depends on the object it is applied on.
End of explanation
"""


A = [1,2,3]  # assigning a mutable list object to A
B = A
A += [1]
print "A: ", A
print "B: ", B

"""
Explanation: If we apply this operation on a variable referring to an immutable object - a new object is created, but if we apply this to an object referring to a mutable object like lists, sets and so on we are just changing the object.
End of explanation
"""


import copy
A = [5]
B = copy.copy(A)
A[0] = 1
print "A: ", A
print "B: ", B

"""
Explanation: In this case the += operation on a list is equivalent to the A.extend([1]) operation.
Copy
The copy operation can be used to make copies of an object. Like
End of explanation
"""


A = [[5]]
B = copy.copy(A)
A[0][0] = 1
print "A: ", A
print "B: ", B

"""
Explanation: The copy operation creates a copy of the object (in this case a list) assigned to variable A and set references of the inner objects. The inner object (integer 1) is immutable, hence, the variable B will not change if we change A. But if we have something like
End of explanation
"""


import copy
A = [[5]]
B = copy.deepcopy(A)
A[0][0] = 1
print "A: ", A
print "B: ", B

"""
Explanation: We will change variable B if we change the inner object of variable A. This is because we have a copy of the first object (outer list) and just set a reference of the inner object. This brings us back to the problem we discussed above.
Deepcopy
To avoid this we have the possibility to make a deepcopy of our object assigned to a variable.
End of explanation
"""


A = [1, 2, 3]
B = ['a', 'b', 'c']
z = zip(A, B)
print "zipping: ", z
print "unzipping: ", zip(*z)

"""
Explanation: With this operation we are creating a new object of the first object and for all nested objects, too. This means that the entire structure is copied and we end up with two objects (containing arbitray nested objects) assigned to two variables.
Zipping unzipping lists and iterables
End of explanation
"""


# dictionary comprehension
name_space = "a","b","c","d","e"
dictionary = {name_space[x]: x**2 for x in range(len(name_space))}
print "dictionary comprehension: ", dictionary

# creating a dictionary using zip function
dictionary = dict(zip(['A', 'B', 'C'], [1, 2, 3]))
print "dictionary: ", dictionary

# default dictionary
from collections import defaultdict
default_dict = defaultdict(int)
default_dict['a'] += 1  # increasing the value of key "a" directly during the initialisation of the key
print "default dictionary: ", default_dict

# normal dictionary initialisation
no_default = dict()
no_default['a'] += 1

"""
Explanation: Dictionaries
Creating dictionaries
End of explanation
"""


dictionary = dict(a=1, b=2, c=3)  # normal dict generation
print "dictionary: ", dictionary

# inverting dictionary using zip function
inverted_dict = dict(zip(dictionary.values(), dictionary.keys()))
print "inverted dict: ", inverted_dict

"""
Explanation: Inverting dictionaries
End of explanation
"""


from collections import OrderedDict
ordered_dict = OrderedDict((name_space[x], x**2) for x in range(len(name_space)))
print "ordered dictionary: ", ordered_dict

"""
Explanation: Ordered dictionaries
End of explanation
"""


import itertools
def k_mer(word, size):
    z = (itertools.islice(word, i, None) for i in range(size))
    return zip(*z)

string = "HelloWorld"
k_mer(string,3)

"""
Explanation: Itertools
Sliding window
End of explanation
"""


import itertools
for p in itertools.permutations([1,2,3]):
    print "permutations: ", p

"""
Explanation: Permutations
End of explanation
"""


import itertools
a_list = [[1,2],[3,4],[5,6]]

a_flatted_list = [x for i in a_list for x in i]
print "a_flatted_list: ", a_flatted_list


"""
Explanation: Flatting Lists
End of explanation
"""


