class Person(object):
    """A class definition for a person. The following attributes are supported:
    
    Attributes:
    name: A string representing the person's name.
    age: An integer representing the person's age."""

    mammal = True
    
    
    def __init__(self, name, age):
        """Return a Person object with name and age set to the values supplied"""
        self.name = name
        self.age = age
    
    
    def __str__(self):
        return '{0} who is {1} years old.'.format(self.name, self.age)


person1 = Person('Alice', 25)
person2 = Person('Bob', 30)

print(person1, person2)

"""
Explanation: Week 11: Inheritance, abstraction, and crafting the public interface.
Last week we looked at several example projects and the classes we might use to implement them.
Before we revisit and expand on these we will cover the remaining material from last week.
End of explanation
"""


def next_fibonacci(status=[]):
    if len(status) < 2:
        status.append(1)
        return 1
    status.append(status[-2] + status[-1])
    return status[-1]

print(next_fibonacci(), next_fibonacci(), next_fibonacci(), next_fibonacci(), next_fibonacci(), next_fibonacci())

"""
Explanation: Before we go on a note of caution is needed for class attributes. Do you remember the strange fibonacci sequence function from our first class?
End of explanation
"""


class Person(object):
    """A class definition for a person. The following attributes are supported:
    
    Attributes:
    name: A string representing the person's name.
    age: An integer representing the person's age."""

    friends = []
    
    
    def __init__(self, name, age):
        """Return a Person object with name and age set to the values supplied"""
        self.name = name
        self.age = age
    
    
    def __str__(self):
        return '{0} who is {1} years old'.format(self.name, self.age)


person1 = Person('Alice', 25)
person2 = Person('Bob', 30)

person1.friends.append('Charlie')
person2.friends.append('Danielle')

print(person1.friends, person2.friends)

"""
Explanation: The same issue can happen with classes, only this is a much more common source of bugs.
If only using strings and numbers the behaviour will likely be much as you expect. However, if using a list, dictionary, or other similar type you may get a surprise.
End of explanation
"""


class Person(object):
    """A class definition for a person. The following attributes are supported:
    
    Attributes:
    name: A string representing the person's name.
    age: An integer representing the person's age."""

    
    
    def __init__(self, name, age):
        """Return a Person object with name and age set to the values supplied"""
        self._name = name
        self.age = age
        self.friends = []
    
    
    def __str__(self):
        return '{0} who is {1} years old'.format(self.name, self.age)
    
    def _pseudo_private(self):
        pass
    
    def __little_more_private(self):
        pass
        


person1 = Person('Alice', 25)
person2 = Person('Bob', 30)

person1.friends.append('Charlie')
person2.friends.append('Danielle')

print(person1.friends, person2.friends)

"""
Explanation: Both of our objects point to the same instance of the list type so adding a new friend to either object shows up in both.
The solution to this is creating our friends attribute only at instantiation of the object. This can be done by creating it in the __init__ method.
End of explanation
"""


print('This works:', person1.friends)
print('This does not work:', friends)

"""
Explanation: Objects have their own namespace, although we have created variables called name, age, and friends they can only be accessed in the context of the object.
End of explanation
"""


class Person(object):
    """A class definition for a person. The following attributes are supported:
    
    Attributes:
    name: A string representing the person's name.
    age: An integer representing the person's age."""

    
    
    def __init__(self, name, age):
        """Return a Person object with name and age set to the values supplied"""
        self.name = name
        self.age = age
        self.friends = []
    
    
    def __str__(self):
        """Return a string representation of the object"""
        return '{0} who is {1} years old'.format(self.name, self.age)
    
    
    def add_friend(self, friend):
        """Add a friend"""
        self.friends.append(friend)

        
person1 = Person('Alice', 25)
person2 = Person('Bob', 30)

person1.add_friend('Charlie')
person2.add_friend('Danielle')

print(person1.friends, person2.friends)

"""
Explanation: We are not limited to special methods when creating classes. Standard functions, or in this context methods, are an integral part of object oriented programming. Their definition is identical to special methods and functions outside of classes.
End of explanation
"""


class Item(object):
    
    
    def __init__(self, name, description, location):
        self.name = name
        self.description = description
        self.location = location
        
        
    def update_location(self, new_location):
        pass

class Equipment(Item):
    pass

class Consumable(Item):
    
    
    def __init__(self, name, description, location, initial_quantity, current_quantity, storage_temp, flammability):
        self.name = name
        self.description = description
        self.location = location
        self.initial_quantity = initial_quantity
        self.current_quantity = current_quantity
        self.flammability = flammability
        
        
    def update_quantity_remaining(self, amount):
        pass

"""
Explanation: Private vs Public
Some programming languages support hiding methods and attributes in an object. This can be useful to simplify the public interface someone using the class will see while still breaking up components into manageable blocks 'under-the-hood'. We will discuss designing the public interface in detail in future classes.
Python does not support private variables beyond convention. Names prefixed with a underscore are assumed to be private. This means they may be changed without warning between different versions of the package. For public attributes/methods this is highly discouraged.
Glossary
Class: Our definition, or template, for an object.
Object: An instance of a class.
Method: A function that belongs to an object
Attribute: A characteristic of an object, these can be data attributes and methods.
Now we will revisit the laboratory inventory system from last week.
Example 1: A Laboratory Inventory
I would like to keep track of all the items in the laboratory so I can easily find them the next time I need them. Both equipment and consumables would be tracked. We have multiple rooms, and items can be on shelves, in refrigerators, in freezers, etc. Items can also be in boxes containing other items in all these places.
The words in bold would all be good ideas to turn into classes. Now we know some of the classes we will need we can start to think about what each of these classes should do, what the methods will be. Let's consider the consumables class:
For consumables we will need to manage their use so there should be an initial quantity and a quantity remaining that is updated every time we use some. We want to make sure that temperature sensitive consumables are always stored at the correct temperature, and that flammables are stored in a flammables cabinet etc.
The consumable class will need a number of attributes:

Initial quantity
Current quantity
Storage temperature
Flammability

The consumable class will need methods to:

Update the quantity remaining
Check for improper storage?

The consumable class might interact with the shelf, refrigerator, freezer, and/or box classes.
Reading back through our description of consumables there is reference to a flammables cabinet that was not mentioned in our initial description of the problem. This is an iterative design process so we should go back and add a flammables cabinet class. 
If we expand our list to all the classes we plan to use we get the following:

Items
Attributes
Name
Description
Location


Methods
Update location


Interactions
Every other class except items and consumables


Laboratory
Attributes
?


Methods
Search


Interactions
Every other class


Equipment
Attributes
Name
Description
Location


Methods
Update location


Interactions
Every other class except items and consumables


Consumables
Attributes
Name
Description
Location
Initial quantity
Current quantity
Storage temperature
Flammability


Methods
Update location
Update quantity remaining
Check for appropriate storage


Interactions
Every other class except equipment and items


Rooms
Attributes
Name
Description
Location
Storage locations within this location
Items stored here


Methods
Search


Interactions
Every other class


Shelves
Attributes
Name
Description
Location
Storage locations within this location
Items stored here


Methods
Search


Interactions
Every other class possible although refrigerator and freezer are unlikely


Refrigerators
Attributes
Name
Description
Location
Storage locations within this location
Items stored here
Temperature


Methods
Search


Interactions
Every other class possible although freezer and flammables cabinet unlikely


Freezers
Attributes
Name
Description
Location
Storage locations within this location
Items stored here
Temperature


Methods
Search


Interactions
Every other class possible although refrigerator and flammables cabinet unlikely


Boxes
Attributes
Name
Description
Location
Storage locations within this location
Items stored here


Methods
Search


Interactions
Every other class


Flammables Cabinet
Attributes
Name
Description
Location
Storage locations within this location
Items stored here


Methods
Search


Interactions
Every other class possible although refrigerator and freezer unlikely



Although this is a long list careful examination reveals that there is a lot of repetition.
Items and equipment are identical and consumables is similar, adding several extra attributes and methods.
Rooms, shelves, refrigerators, freezers, boxes and flammables cabinet are all similar, only differing in the occasional attribute.
Our three main groups are:
* Laboratory
* Items (Items, equipment, and consumables)
* Locations (Rooms, shelves, refrigerators, freezers, boxes and flammables cabinet)
So much duplication is problematic, it is diffcult to maintain and subject to greater risk of bugs. 
Inheritance
There is a better way - we can create a generic class with the shared functionality and then inherit from it when we create the other classes.
For example an Item class would contain the basic attributes and methods. The Equipment class could then inherit from this class without modification. The Consumable class would also inherit from Item and only add the extra attributes and methods uniquely needed by the Consumable class.
End of explanation
"""


class Item(object):
    def safely_stored(self):
        raise NotImplementedError('override in subclass')
        
class Consumable(Item):
    def safely_stored(self):
        return True

a = Item()

a.safely_stored()

b = Consumable()

b.safely_stored()

"""
Explanation: Each of the classes we create inheriting from our general class can be thought of as having an 'is-a' relationship with the general class. For example, Equipment is a Item, Consumable is a Item.
Not yet implemented methods
There is one other situation we should consider. Occasionally we will want a class of a particular type to always implement a particular method even though we are unable to implement that method in our parent class. We need some way of raising an error when the parent class is inherited and the method is not implemented.
As a simple example consider a class representing length. We might create classes for meters, miles, feet, etc. Keeping the original units when performing operations (adding, subtracting, etc) would prevent rounding errors but each class would need custom logic.
Returning to our laboratory inventory system one way we can implement this is below:
End of explanation
"""


from abc import ABCMeta, abstractmethod

class Item(object, metaclass=ABCMeta):
    @abstractmethod
    def safely_stored(self):
        pass
    
    def test(self):
        pass

class Consumable(Item):
    def safely_stored(self):
        return True

a = Item()

b = Consumable()
b.safely_stored()

"""
Explanation: A disadvantage with this approach is we only see the error message when we call the method. The error is in the way we implemented the class so it would be more intuitive to get an error earlier, when we first create the object.
This can be achieved using the abstract method decorator.
End of explanation
"""


class A(object):
    def a(self):
        print('a in class A')

class B(A):
    def a(self):
        A.a(self)
        print('b in class B')

a = A()
a.a()

b = B()
b.a()

class A(object):
    def a(self):
        print('a in class A')

class B(A):
    def a(self):
        super().a()
        print('b in class B')

a = A()
a.a()

b = B()
b.a()

"""
Explanation: Either of these approaches work well for adding new methods or completely changing the behaviour of a method. Often we only need to make a more subtle change. In this situation it can be useful to call a method from a parent class while only implementing our new functionality in the child class.
There are two approaches for this.
End of explanation
"""


class A(object):
    def a(self):
        print('A-a')

class A2(object):
    def a(self):
        print('A2-a')

class B(A, A2):
    pass

a = A()
a.a()

a2 = A2()
a2.a()

b = B()
b.a()

class A(object):
    def a(self):
        print('A-a')

class A2(object):
    def a(self):
        print('A2-a')

class B(A):
    pass

class C(B, A2):
    pass

a = A()
a.a()

a2 = A2()
a2.a()

c = C()
c.a()

"""
Explanation: Using super() is usually the best approach, the reasons for this are covered in detail in this blog post
Multiple Inheritance
We are not limited to inheriting from a single class. It is possible to merge functionality from multiple different classes simply by inheriting from them.
When inheriting from multiple classes that contain a method or attribute with the same name there is a particular order in which the names are resolved.
End of explanation
"""


class Item(object):
    def safely_stored(self):
        raise NotImplementedError('override in subclass')
        
class Consumable(Item):
    def safely_stored(self):
        return True
    
a = Item()
b = Consumable()

print('a instance of Item:', isinstance(a, Item))
print('b instance of Consumable:', isinstance(b, Consumable))
print('b instance of Item:', isinstance(b, Item))
print('a instance of Consumable:', isinstance(a, Consumable))

"""
Explanation: A simple rule-of-thumb is that search is depth first. The details are a little more complicated.
isinstance
Often we need to check whether a particular variable is an instance of a particular class. For example, returning to our laboratory inventory system we would want to check that we only add instances of Item or its subclasses to our storage locations.
End of explanation
"""


class Ingredient(object):
    """The ingredient object that contains nutritional information"""
    
    def __init__(self, name, carbs, protein, fat):
        self.name = name
        self.carbs = carbs
        self.protein = protein
        self.fat = fat
        
    
    def get_nutrition(self):
        """Returns the nutritional information for the ingredient"""
        return (self.carbs, self.protein, self.fat)
    

class Recipe(object):
    """The Recipe object containing the ingredients"""
    
    def __init__(self, name, ingredients):
        self.name = name
        self.ingredients = ingredients
        
        
    def get_nutrition(self):
        """Returns the nutritional information for the recipe"""
        nutrition = [0, 0, 0]
        for amount, ingredient in self.ingredients:
            nutrition[0] += amount * ingredient.carbs
            nutrition[1] += amount * ingredient.protein
            nutrition[2] += amount * ingredient.fat
        return nutrition
    

bread = Recipe('Bread', [(820, Ingredient('Flour', 0.77, 0.10, 0.01)), 
                         (30, Ingredient('Oil', 0, 0, 1)), 
                         (36, Ingredient('Sugar', 1, 0, 0)), 
                         (7, Ingredient('Yeast', 0.3125, 0.5, 0.0625)),
                         (560, Ingredient('Water', 0, 0, 0))])
print(bread.ingredients)
print(bread.get_nutrition())

"""
Explanation: Duck typing
A popular alternative in python is duck typing, an approach named after the idea that,
If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.
What this means for programming is that instead of checking for a particular class, instead the methods and attributes that are actually needed are checked for.
Composition
Let's switch to another example from last week. We looked at a cookbook application and were often unsure whether things like ingredients should be attributes on the recipe class or classes in their own right. 
Often the answer is both. 
These are the interactions that change a collection of different classes into a functioning program. This is called composition. The Recipe object is a composite object, it has ingredients, it has instructions, etc.
Let's look at how we can design our classes to be easy to use, for both programmer-class and class-class interactions.
End of explanation
"""


import requests

r = requests.get('https://api.github.com/repos/biof509/biof509/events')
print(r.status_code, r.ok)
print(r.headers['content-type'])

print(r.text[:1000])

print(r.json()[0]['payload']['commits'][0])

type(r)

"""
Explanation: This has the basic functionality implemented but there are some improvements we can make. 
Before we look at making changes we can seek inspiration. Requests and Pandas are two packages well regarded for having well implemented interfaces.
Requests: HTTP for Humans
Requests is a package used for making HTTP requests. There are options in the python standard library for making http requests but they can seem difficult to use.
End of explanation
"""


import pandas as pd

data = pd.DataFrame([[0,1,2,3], [4,5,6,7], [8,9,10,11]], 
                    index=['a', 'b', 'c'], 
                    columns=['col1', 'col2', 'col3', 'col4'])
data

print(data.shape)
print(data['col1'])
print(data.col1)

import matplotlib.pyplot as plt

%matplotlib inline

data.plot()

data.to_csv('Wk05-temp.csv')

data2 = pd.DataFrame.from_csv('Wk05-temp.csv', index_col=0)
data2

"""
Explanation: The API documentation for requests
The Response class
Some useful features:

property

Pandas
pandas is an open source, BSD-licensed library providing high-performance, easy-to-use data structures and 
data analysis tools for the Python programming language.
End of explanation
"""


class Ingredient(object):
    """The ingredient object that contains nutritional information"""
    
    def __init__(self, name, carbs, protein, fat):
        self.name = name
        self.carbs = carbs
        self.protein = protein
        self.fat = fat
        
    
    def __repr__(self):
        return 'Ingredient({0}, {1}, {2}, {3})'.format(self.name, self.carbs, self.protein, self.fat)
    
    
    def get_nutrition(self):
        """Returns the nutritional information for the ingredient"""
        return (self.carbs, self.protein, self.fat)
    

class Recipe(object):
    """The Recipe object containing the ingredients"""
    
    def __init__(self, name, ingredients):
        self.name = name
        self.ingredients = ingredients
        
        
    def get_nutrition(self):
        """Returns the nutritional information for the recipe"""
        nutrition = [0, 0, 0]
        for amount, ingredient in self.ingredients:
            nutrition[0] += amount * ingredient.carbs
            nutrition[1] += amount * ingredient.protein
            nutrition[2] += amount * ingredient.fat
        return nutrition
    

bread = Recipe('Bread', [(820, Ingredient('Flour', 0.77, 0.10, 0.01)), 
                         (30, Ingredient('Oil', 0, 0, 1)), 
                         (36, Ingredient('Sugar', 1, 0, 0)), 
                         (7, Ingredient('Yeast', 0.3125, 0.5, 0.0625)),
                         (560, Ingredient('Water', 0, 0, 0))])
print(bread.ingredients)
print(bread.get_nutrition())

"""
Explanation: The API documentation for the DataFrame object.
The actual code.
Some useful features:
* classmethod
* property for html styling and property for shape
* __getitem__
* Public and private attributes/methods
* __getattr__
Cookbook
We can now return to our cookbook example.
Displaying the ingredients needs to be improved.
End of explanation
"""


class Ingredient(object):
    """The ingredient object that contains nutritional information"""
    
    def __init__(self, name, nutrition):
        self.name = name
        self.nutrition = nutrition

        
    def __repr__(self):
        return 'Ingredient({0}, {1}, {2}, {3})'.format(self.name, 
                                                       self.nutrition['carbs'], 
                                                       self.nutrition['protein'], 
                                                       self.nutrition['fat'])
    
    
    

class Recipe(object):
    """The Recipe object containing the ingredients"""
    
    def __init__(self, name, ingredients):
        self.name = name
        self.ingredients = ingredients
        
    @property
    def nutrition(self):
        """Returns the nutritional information for the recipe"""
        nutrition = {}
        for amount, ingredient in self.ingredients:
            # use get_nutrition
            for k,v in ingredient.nutrition.items():
                nutrition[k] = amount * v + nutrition.get(k, 0)
        return nutrition
    

bread = Recipe('Bread', [(820, Ingredient('Flour', {'carbs':0.77, 
                                                    'protein':0.10, 
                                                    'fat':0.01})), 
                         (30, Ingredient('Oil', {'carbs':0, 
                                                    'protein':0, 
                                                    'fat':1})), 
                         (36, Ingredient('Sugar', {'carbs':1, 
                                                    'protein':0, 
                                                    'fat':0})), 
                         (7, Ingredient('Yeast', {'carbs':0.3125, 
                                                    'protein':0.5, 
                                                    'fat':0.0625})),
                        (560, Ingredient('Water', {'carbs':0, 
                                                    'protein':0, 
                                                    'fat':0})),
                        ])
print(bread.ingredients)
print(bread.nutrition)

# Recipe and Ingredient now have a consistent interface and we can combine them.

# Recipe for toast - add butter to a 12th of a loaf

toast = Recipe('Toast', [(1/12, bread),
                        (25, Ingredient('Butter', {'carbs':0,
                                                  'protein':0,
                                                  'fat':1}))])
print(toast.nutrition)

# We can also add additional types of nutrients

bread = Recipe('Bread', [(820, Ingredient('Flour', {'carbs':0.77, 
                                                    'protein':0.10, 
                                                    'fat':0.01,
                                                    'sodium':0.00002})), 
                         (30, Ingredient('Oil', {'carbs':0, 
                                                    'protein':0, 
                                                    'fat':1,
                                                    'sodium':0.00002})), 
                         (36, Ingredient('Sugar', {'carbs':1, 
                                                    'protein':0, 
                                                    'fat':0,
                                                    'sodium':0})), 
                         (7, Ingredient('Yeast', {'carbs':0.3125, 
                                                    'protein':0.5, 
                                                    'fat':0.0625,
                                                    'sodium':0.00050})),
                        (560, Ingredient('Water', {'carbs':0, 
                                                    'protein':0, 
                                                    'fat':0,
                                                    'sodium':0})),
                        ])
print(bread.ingredients)
print(bread.nutrition)

# If we want to accept nutritional information either as a dictionary or the older style numerical values we have 
# a few options

# First, an alternate entry point using the @classmethod decorator

class Ingredient(object):
    """The ingredient object that contains nutritional information"""
    
    def __init__(self, name, carbs, protein, fat):
        self.name = name
        self.nutrition = {'carbs': carbs,
                         'protein': protein,
                         'fat': fat}
        
    
    @classmethod
    def from_dict(cls, name, nutrition):
        ingredient = cls(name, 0, 0, 0)
        ingredient.nutrition = nutrition
        return ingredient
        
    def __repr__(self):
        return 'Ingredient({0}, {1}, {2}, {3})'.format(self.name, 
                                                       self.nutrition['carbs'], 
                                                       self.nutrition['protein'], 
                                                       self.nutrition['fat'])

# Creating an ingredient instance using the from_dict method
a = Ingredient.from_dict('Flour', {'carbs':0.77, 
                        'protein':0.10, 
                        'fat':0.01,
                        'sodium':0.00002})
print(a)

# Creating an ingredient instance using the __init__ method
b = Ingredient('Flour', 0.77, 0.10, 0.01,)
print(b)

# Second we can alter the __init__ method to accept either numerical values or a dictionary

# Default values are used below. A similar solution could be implemented using the *args and/or **kwargs functionality

class Ingredient(object):
    """The ingredient object that contains nutritional information"""
    
    def __init__(self, name, carbs, protein=None, fat=None):
        self.name = name
        if isinstance(carbs, dict):
            self.nutrition = carbs
        elif isinstance(carbs, float) or isinstance(carbs, int):
            self.nutrition = {'carbs': carbs,
                         'protein': protein,
                         'fat': fat}
        else:
            raise Exception('Unexpected argument types supplied')
        
        
    def __repr__(self):
        return 'Ingredient({0}, {1}, {2}, {3})'.format(self.name, 
                                                       self.nutrition['carbs'], 
                                                       self.nutrition['protein'], 
                                                       self.nutrition['fat'])

# Creating an ingredient instance using a dictionary
a = Ingredient('Flour', {'carbs':0.77, 
                        'protein':0.10, 
                        'fat':0.01,
                        'sodium':0.00002})
print(a)

# Creating an ingredient instance using numercal values
b = Ingredient('Flour', 0.77, 0.10, 0.01,)
print(b)

a = {'x':1, 'y':2}
a['x']

a.get('x')

a['z']

a.get('z', 0)

"""
Explanation: Viewing the ingredients now looks much better. Let's now look at the get_nutrition method.
There are still a number of areas that could be improved

When we call get_nutrition it is not clear what the different values returned actually are
We don't use the get_nutrition method when calculating the nutrition values in the Recipe class
There is no way to add additional types of nutrient
Ingredient and Recipe return different types from get_nutrition, tuple and list respectively
Recipe could not be used as an ingredient for another Recipe
End of explanation
"""


